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,1401 @@
1
+ /**
2
+ * Deployment Orchestrator
3
+ * Logic for executing the deployment after configuration is gathered
4
+ */
5
+
6
+ import dns from 'node:dns';
7
+ import { readFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import * as p from '@clack/prompts';
10
+ import { c } from '../colors.js';
11
+ import { runCommand, runCommandAsync } from '../command.js';
12
+ import {
13
+ loadCredentials,
14
+ loadProjectConfig,
15
+ registerProject,
16
+ saveProjectConfig,
17
+ } from '../config.js';
18
+ import { waitForDNSPropagation } from '../dns-propagation.js';
19
+ import { ensureOperatorIpAccess } from '../operator-ip.js';
20
+ import { perfAsync, perfTimer } from '../perf.js';
21
+ import { createTracker } from '../tracker.js';
22
+ import { renderBundle } from './bundle.js';
23
+ import { sideloadCompose } from './image.js';
24
+ import { buildRemote } from './remote-build.js';
25
+ import { StateTracker } from './state.js';
26
+ import { generateSSHKeyPair } from './utils.js';
27
+
28
+ /**
29
+ * Probe the public /api/health endpoint to verify the app is reachable
30
+ * over the internet.
31
+ *
32
+ * K8s cold deploys need a generous budget: after the GHA workflow returns,
33
+ * cert-manager may still be finishing cert issuance, DNS may be
34
+ * propagating, and the ingress may be picking up new routes. 10 minutes
35
+ * handles first-deploy cert+DNS tail without false-negatives. Compose
36
+ * already has a faster on-server health gate before we ever get here, so
37
+ * 60s is enough to catch DNS edge cases.
38
+ *
39
+ * When `ACME_CA_SERVER` is set to a Let's Encrypt staging URL (e2e
40
+ * tests, dev envs avoiding LE rate limits), cert-manager issues a cert
41
+ * signed by the LE Staging CA which Node's default trust store does not
42
+ * include — fetch() would reject every probe with "self-signed cert"
43
+ * even though the app is healthy. Detect that case and use an undici
44
+ * dispatcher that skips TLS verification. Real-world prod runs are
45
+ * unaffected.
46
+ *
47
+ * Returns `{ ok: true, status }` on first 2xx, or a structured failure
48
+ * descriptor on timeout — never null. Capturing last-status and last-error
49
+ * lets the caller throw with actionable context (e.g. "lastStatus=503 over
50
+ * 120 attempts" tells you ingress is up but app is failing) instead of the
51
+ * old opaque null which made the error message default to whatever stderr
52
+ * tail the runner happened to scrape (the 2026-04-27 morning matrix's k8s
53
+ * timeout surfaced as a CSP header dump because of this exact gap).
54
+ */
55
+ async function probePublicHealth(domain, { timeoutMs = 60_000, intervalMs = 10_000 } = {}) {
56
+ const url = `https://${domain}/api/health`;
57
+ const deadline = Date.now() + timeoutMs;
58
+ let firstAttempt = true;
59
+ let attempts = 0;
60
+ let lastStatus = null;
61
+ let lastErrorClass = null;
62
+ let lastErrorMessage = null;
63
+ let lastErrorCause = null;
64
+ // The probe runs IMMEDIATELY after we (re)write the DNS record, so the
65
+ // system resolver may still have a cached NXDOMAIN or a prior placeholder
66
+ // IP. Fetch then fails for the entire probe budget while curl (run once
67
+ // in the diagnostic dump much later) gets a fresh getaddrinfo and
68
+ // succeeds — the symptom that surfaced as "TypeError: fetch failed (121
69
+ // attempts)" while the diagnostic curl returned `{"status":"ok"}` in
70
+ // matrix #3 (k8s + k8s-ha). Pin the resolver to public DNS for the
71
+ // probe so we always see the freshest record. setServers is per-process
72
+ // and doesn't affect anything else in this orchestrator instance.
73
+ try {
74
+ dns.setDefaultResultOrder('verbatim');
75
+ dns.setServers(['1.1.1.1', '8.8.8.8']);
76
+ } catch {
77
+ // setServers can throw if called with an empty list or invalid IPs;
78
+ // both are caller errors, not runtime concerns. Fall through and use
79
+ // whatever resolver is currently configured.
80
+ }
81
+ // Lazy-load undici only when we need an insecure dispatcher — keeps the
82
+ // happy path zero-cost and avoids a hard dep on undici internals.
83
+ //
84
+ // CRITICAL: when we use undici's Agent we MUST also use undici's `fetch`,
85
+ // not Node 24's `globalThis.fetch`. Node 24 ships its own bundled undici;
86
+ // its built-in fetch's dispatcher protocol is incompatible with the npm
87
+ // undici package's Agent (different `onRequestStart` signature). Mixing
88
+ // them produces `InvalidArgumentError: invalid onRequestStart method` on
89
+ // every probe call, with the misleading wrapper "TypeError: fetch failed"
90
+ // — which is what dominated every k8s + k8s-ha matrix failure for weeks.
91
+ // Always pair Agent + fetch from the same undici instance.
92
+ let dispatcher = null;
93
+ let fetchFn = fetch;
94
+ if ((process.env.ACME_CA_SERVER || '').includes('staging')) {
95
+ const undici = await import('undici');
96
+ dispatcher = new undici.Agent({ connect: { rejectUnauthorized: false } });
97
+ fetchFn = undici.fetch;
98
+ }
99
+ // Periodic progress logging: every 5 attempts, write a one-line summary
100
+ // of the most recent failure class + lastStatus to stderr. Without this,
101
+ // the only signal we get from a 5-min probe is the FINAL outcome — which
102
+ // tells us nothing about whether the early failures were DNS-related,
103
+ // TCP-refused, or app-side 5xx. iter-perfwave2 analysis: k8s-ha probe
104
+ // hits exactly 300s = 30 × 10s (DNS TTL match suspicious), but with no
105
+ // per-attempt visibility we can't distinguish "DNS cached for 300s" from
106
+ // "ACME issuance pending for 300s" from "app slow to converge".
107
+ const startTime = Date.now();
108
+ const logProgress = () => {
109
+ const elapsedSec = Math.round((Date.now() - startTime) / 1000);
110
+ const lastSig = lastStatus
111
+ ? `lastStatus=${lastStatus}`
112
+ : lastErrorCause
113
+ ? `lastError=${lastErrorClass}/${lastErrorCause}`
114
+ : lastErrorClass
115
+ ? `lastError=${lastErrorClass}`
116
+ : 'no-response-yet';
117
+ console.error(`[probe] ${url} attempt=${attempts} elapsed=${elapsedSec}s ${lastSig}`);
118
+ };
119
+ while (Date.now() < deadline) {
120
+ if (!firstAttempt) await new Promise((r) => setTimeout(r, intervalMs));
121
+ firstAttempt = false;
122
+ attempts++;
123
+ try {
124
+ const res = await fetchFn(url, {
125
+ method: 'GET',
126
+ signal: AbortSignal.timeout(8_000),
127
+ ...(dispatcher && { dispatcher }),
128
+ });
129
+ lastStatus = res.status;
130
+ if (res.ok) return { ok: true, status: String(res.status), attempts };
131
+ } catch (err) {
132
+ // Capture failure class + short message so the eventual throw can
133
+ // say *why* the probe never reached 2xx. Common patterns:
134
+ // - TimeoutError → ingress not routing or pod hung
135
+ // - TypeError "fetch failed" → DNS or TCP refused
136
+ // - Error "self-signed certificate" → LE staging cert + dispatcher
137
+ // misconfig (we should have set the insecure dispatcher above)
138
+ // Undici nests the actual reason in err.cause (e.g. ENOTFOUND,
139
+ // ECONNREFUSED, EAI_AGAIN, UND_ERR_SOCKET). The wrapper "fetch
140
+ // failed" is useless without it — capture the cause's name+message
141
+ // so we can distinguish DNS failure from TCP refused from TLS
142
+ // handshake failure without re-running the matrix.
143
+ lastErrorClass = err?.name ?? 'Unknown';
144
+ lastErrorMessage = (err instanceof Error ? err.message : String(err)).slice(0, 200);
145
+ const cause = err && typeof err === 'object' && 'cause' in err ? err.cause : null;
146
+ if (cause) {
147
+ const causeName = cause?.name ?? cause?.code ?? 'UnknownCause';
148
+ const causeMsg = (cause instanceof Error ? cause.message : String(cause)).slice(0, 200);
149
+ lastErrorCause = `${causeName}: ${causeMsg}`;
150
+ }
151
+ }
152
+ if (attempts % 5 === 0) logProgress();
153
+ }
154
+ return {
155
+ ok: false,
156
+ attempts,
157
+ timeoutMs,
158
+ lastStatus,
159
+ lastErrorClass,
160
+ lastErrorMessage,
161
+ lastErrorCause,
162
+ url,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Execute the deployment based on gathered configuration
168
+ */
169
+ export async function executeDeployment(args, gatheredConfig) {
170
+ const {
171
+ projectConfig,
172
+ envConfig,
173
+ environment,
174
+ config,
175
+ apiToken,
176
+ region,
177
+ secondaryRegion,
178
+ serverType,
179
+ masterServerType,
180
+ supabaseServerType,
181
+ workerServerType,
182
+ minWorkers,
183
+ maxWorkers,
184
+ domain,
185
+ dnsProvider,
186
+ cloudflareApiToken,
187
+ cloudflareZoneId,
188
+ hetznerDnsZoneId,
189
+ s3Config,
190
+ backupS3Config,
191
+ backupConfig,
192
+ services,
193
+ } = gatheredConfig;
194
+
195
+ const { deployMode } = config;
196
+ const isComposeDeploy = deployMode === 'compose' || deployMode === 'compose-ha';
197
+ const isHA = deployMode === 'compose-ha' || !!config.ha;
198
+
199
+ // Operator-IP access: detect + persist + (when env already deployed) patch
200
+ // the live Hetzner firewall. On a fresh first deploy this just appends to
201
+ // projectConfig.operatorCidrs in memory and on disk — the firewall itself
202
+ // is created later in this function with the correct source_ips because
203
+ // every program-builder call site reads projectConfig.operatorCidrs.
204
+ const accessResult = await ensureOperatorIpAccess({
205
+ projectConfig,
206
+ environment,
207
+ isHA,
208
+ apiToken,
209
+ yes: !!args.yes,
210
+ onMessage: (msg) => p.log.info(msg),
211
+ });
212
+ if (accessResult.added && accessResult.fromEnv) {
213
+ p.log.info(`Bootstrapped operator CIDRs from ALLOWED_SSH_IPS env var: ${accessResult.cidr}`);
214
+ }
215
+
216
+ // Initialize state tracker for granular resume
217
+ const state = new StateTracker(projectConfig.projectName, environment);
218
+
219
+ // Clear state if --full is passed
220
+ if (args.full) {
221
+ state.clear();
222
+ }
223
+
224
+ const s3Spinner = p.spinner();
225
+ const { HetznerS3Provider } = await import('../providers/hetzner-s3.js');
226
+ const s3Provider = new HetznerS3Provider(s3Config.accessKey, s3Config.secretKey, s3Config.region);
227
+
228
+ // --- STEP 1: S3 Setup (Idempotent) ---
229
+ const s3Inputs = {
230
+ bucket: s3Config.bucket,
231
+ backupBucket: backupS3Config.bucket,
232
+ region: s3Config.region,
233
+ };
234
+ if (!state.shouldSkip('s3-setup', s3Inputs)) {
235
+ state.startStep('s3-setup', s3Inputs);
236
+ try {
237
+ s3Spinner.start(`Creating S3 bucket: ${s3Config.bucket}`);
238
+ const bucket = await s3Provider.createBucket(s3Config.bucket);
239
+ s3Spinner.stop(
240
+ bucket.created
241
+ ? `S3 bucket created: ${s3Config.bucket}`
242
+ : `S3 bucket exists: ${s3Config.bucket}`,
243
+ );
244
+
245
+ try {
246
+ s3Spinner.start('Configuring bucket CORS');
247
+ const corsOrigins = domain ? [`https://${domain}`] : ['*'];
248
+ await s3Provider.configureCORS(s3Config.bucket, corsOrigins);
249
+ s3Spinner.stop('CORS configured');
250
+ } catch {
251
+ s3Spinner.stop('CORS configuration skipped (not supported by provider)');
252
+ }
253
+
254
+ s3Spinner.start(`Creating backup bucket: ${backupS3Config.bucket}`);
255
+ try {
256
+ const backupBucket = await s3Provider.createBucket(backupS3Config.bucket);
257
+ s3Spinner.stop(
258
+ backupBucket.created
259
+ ? `Backup bucket created: ${backupS3Config.bucket}`
260
+ : `Backup bucket exists: ${backupS3Config.bucket}`,
261
+ );
262
+ } catch (backupBucketError) {
263
+ s3Spinner.stop(`Backup bucket creation failed: ${backupBucketError.message}`);
264
+ }
265
+
266
+ const result = { region: s3Provider.region, endpoint: s3Provider.getEndpoint() };
267
+ state.completeStep('s3-setup', result);
268
+ } catch (error) {
269
+ const errMsg =
270
+ error.message ||
271
+ error.Code ||
272
+ error.name ||
273
+ `HTTP ${error.$metadata?.httpStatusCode || 'unknown'}`;
274
+ s3Spinner.stop(`S3 setup failed: ${errMsg}`);
275
+ process.exit(1);
276
+ }
277
+ }
278
+
279
+ // Apply resolved S3 values from state
280
+ const s3Result = state.getStepResult('s3-setup');
281
+ if (s3Result) {
282
+ s3Config.region = s3Result.region;
283
+ s3Config.endpoint = s3Result.endpoint;
284
+ backupS3Config.region = s3Config.region;
285
+ backupS3Config.endpoint = s3Config.endpoint;
286
+ }
287
+
288
+ // SKELETON SAVE: persist the minimum env entry that `vibecarbon destroy`
289
+ // needs to recover, BEFORE any `pulumi up` runs. Without this, a crash
290
+ // during k3s install / cloud-init / health probe leaves Pulumi state in
291
+ // S3 + real Hetzner infra alive while .vibecarbon.json has no entry —
292
+ // destroy reads the empty config, finds nothing, exits cleanly, and the
293
+ // PG/network/firewall/FIP outlive the test until the sweep catches them
294
+ // (or, worse, blocks the next deploy with a quota error). Observed in
295
+ // matrix #3 (3 PGs + 3 networks + 1 firewall leaked from k8s/k8s-ha
296
+ // health-probe failures). Fields: deployMode + region + s3 give k8s
297
+ // destroy the Pulumi backend; secondaryRegion + ha.enabled give k8s-ha
298
+ // destroy the `-primary` / `-standby` stack enumeration; the compose
299
+ // destroy path already falls back to the deterministic Pulumi name
300
+ // `${projectName}-${envName}` when servers[] is empty (see
301
+ // src/destroy.js:1067-1093), so the skeleton is sufficient there too.
302
+ // Subsequent saves (mid-flight from compose/ha.js + k8s/ha, plus the
303
+ // final save at the end of executeDeployment) merge with on-disk state,
304
+ // so the skeleton is non-destructive — they layer on top of it.
305
+ {
306
+ const existingEnv = projectConfig.environments?.[environment] ?? {};
307
+ const skeletonEnv = {
308
+ ...existingEnv,
309
+ envName: environment,
310
+ status: 'deploying',
311
+ deployMode,
312
+ provider: config.provider,
313
+ region,
314
+ ...(secondaryRegion ? { secondaryRegion } : {}),
315
+ ...(domain ? { domain } : {}),
316
+ ...(dnsProvider ? { dnsProvider } : {}),
317
+ s3: { bucket: s3Config.bucket, region: s3Config.region, endpoint: s3Config.endpoint },
318
+ backupS3: backupS3Config?.bucket
319
+ ? {
320
+ bucket: backupS3Config.bucket,
321
+ region: backupS3Config.region,
322
+ endpoint: backupS3Config.endpoint,
323
+ }
324
+ : existingEnv.backupS3,
325
+ ...(deployMode === 'compose-ha' || (deployMode === 'kubernetes' && secondaryRegion)
326
+ ? {
327
+ ha: {
328
+ ...(existingEnv.ha ?? {}),
329
+ enabled: true,
330
+ ...(secondaryRegion ? { failoverRegion: secondaryRegion } : {}),
331
+ },
332
+ }
333
+ : {}),
334
+ lastAttempt: new Date().toISOString(),
335
+ };
336
+ projectConfig.environments = projectConfig.environments ?? {};
337
+ projectConfig.environments[environment] = skeletonEnv;
338
+ saveProjectConfig(projectConfig);
339
+ }
340
+
341
+ const tracker = createTracker('deploy', {
342
+ environment: config.environment,
343
+ provider: config.provider,
344
+ region,
345
+ });
346
+
347
+ // --- STEP 2: Image Resolve ---
348
+ // resolveBuildMode returns one of:
349
+ // 'local' — k3s default: deployK3s builds + sideloads + applies
350
+ // manifests inline. No CI image dependency.
351
+ // 'direct' — compose: build locally on dev, push image directly
352
+ // to server (auto-detected when CI/CD is not configured).
353
+ // 'push' — compose: git push triggers GHA build + Flux reconcile
354
+ // (auto-detected when CI/CD is configured).
355
+ const { ensureCIImageReady, buildImageRef, resolveBuildMode, waitForGhcrManifest } = await import(
356
+ '../ci-setup.js'
357
+ );
358
+ const imageResolveTimer = perfTimer('deploy.image.resolve');
359
+ const buildMode = resolveBuildMode(args, process.cwd(), deployMode);
360
+ const isDirectDeploy = buildMode === 'direct';
361
+ const isComposeMode = deployMode === 'compose' || deployMode === 'compose-ha';
362
+ const isComposeLocal = buildMode === 'local' && isComposeMode;
363
+ let imageReadyPromise;
364
+ // For compose-local: kicked off here in parallel with iac.upStack below.
365
+ // The build is awaited later (just before sideload). Other modes leave
366
+ // this null and use the existing remote-build / push paths.
367
+ let composeLocalBuildPromise = null;
368
+
369
+ const localImageTag = `${projectConfig.projectName}-app:local`;
370
+
371
+ if (isComposeLocal) {
372
+ // Build the app image locally NOW, in parallel with iac.upStack which
373
+ // starts ~immediately below. The tag is fixed (no serverIp dependency)
374
+ // and matches what bundle.js stamps into APP_IMAGE in the rendered
375
+ // .env, so docker-compose.prod.yml's `image: ${APP_IMAGE}` resolves to
376
+ // the sideloaded image. iac.upStack typically runs ~109s; a warm
377
+ // operator-side build runs ~15-30s, so the build finishes well inside
378
+ // the upStack window — net saving is the full build duration off the
379
+ // critical path. State-tracker stepId 'compose-localbuild' is distinct
380
+ // from 'compose-upstack' so concurrent state writes don't collide
381
+ // (state.js uses last-writer-wins per stepId).
382
+ // silent: true so BuildKit progress doesn't interleave with the
383
+ // spinner output from upStack (which is running concurrently). On
384
+ // failure runCommandAsync includes captured stdout/stderr in the
385
+ // thrown error — operator still sees what went wrong.
386
+ // Plumb VITE_* build args through to docker build so the browser bundle
387
+ // ships with real values for VITE_SUPABASE_URL etc. Without these the
388
+ // Dockerfile's ARG VITE_* defaults to empty strings and Vite inlines
389
+ // empty values, causing the client-side supabase init to throw at
390
+ // page load. Mirrors what docker-compose.yml's build.args block does
391
+ // for local-dev `docker compose up --build`. See
392
+ // src/lib/deploy/compose/build-args.js for the rationale.
393
+ const { collectComposeBuildArgs, buildArgFlags } = await import('./compose/build-args.js');
394
+ const localBuildArgs = collectComposeBuildArgs(process.cwd(), {
395
+ projectName: projectConfig.projectName,
396
+ domain,
397
+ });
398
+ composeLocalBuildPromise = perfAsync('deploy.image.localBuild', () =>
399
+ runCommandAsync(
400
+ ['docker', 'build', ...buildArgFlags(localBuildArgs), '-t', localImageTag, process.cwd()],
401
+ { silent: true },
402
+ ),
403
+ );
404
+ imageReadyPromise = Promise.resolve({
405
+ imageTag: localImageTag,
406
+ githubOwner: 'noop',
407
+ repoName: projectConfig.projectName,
408
+ ghcrPullCreds: null,
409
+ isLocal: true,
410
+ });
411
+ } else if (buildMode === 'local') {
412
+ // K8s 'local' build mode: deployK3s handles the build + sideload +
413
+ // manifest apply inline. No CI image to wait for; stub the
414
+ // image-ready contract so downstream code sees a deterministic shape.
415
+ imageReadyPromise = Promise.resolve({
416
+ imageTag: localImageTag,
417
+ githubOwner: 'noop',
418
+ repoName: projectConfig.projectName,
419
+ ghcrPullCreds: null,
420
+ isLocal: true,
421
+ });
422
+ } else if (buildMode === 'direct') {
423
+ imageReadyPromise = Promise.resolve({ imageTag: localImageTag, isLocal: true });
424
+ } else {
425
+ // CI path: ensureCIImageReady performs the workflow dispatch + GHA-build
426
+ // wait. On a cold push deploy this is the dominant cost (~10 min).
427
+ imageReadyPromise = perfAsync('deploy.image.ciWait', () =>
428
+ ensureCIImageReady({ yes: args.yes, tracker }),
429
+ );
430
+ }
431
+
432
+ const ciReady = await imageReadyPromise;
433
+ if (!ciReady.isLocal) {
434
+ p.log.info(
435
+ `Deploying image: ${c.bold(buildImageRef(ciReady.githubOwner, ciReady.repoName, ciReady.imageTag))}`,
436
+ );
437
+ // Bridge the race between the GitHub packages API (ghcrTagExists) and
438
+ // the OCI manifests endpoint that kubelet/docker actually pull from.
439
+ // HEAD-probe the manifest URL with short polling instead of a blanket
440
+ // 30s sleep — typical hit is <5s, worst observed ~15s.
441
+ const s = p.spinner();
442
+ s.start('Waiting for GHCR manifest to propagate...');
443
+ const manifestReady = await perfAsync('deploy.image.manifestPropagate', () =>
444
+ waitForGhcrManifest(ciReady.githubOwner, ciReady.repoName, ciReady.imageTag),
445
+ );
446
+ s.stop(manifestReady ? 'Manifest propagated' : 'Manifest probe timed out (proceeding anyway)');
447
+ }
448
+ imageResolveTimer.end();
449
+ const imageRef = ciReady.isLocal
450
+ ? ciReady.imageTag
451
+ : buildImageRef(ciReady.githubOwner, ciReady.repoName, ciReady.imageTag);
452
+
453
+ // --- STEP 3: Local Bundle Rendering (Unified) ---
454
+ // renderBundle is sync (cp + writeFile fan-out); perfTimer captures total
455
+ // wall-clock time for the staging-dir build. Typical ~50-300ms.
456
+ const bundleRenderTimer = perfTimer('deploy.bundle.pack');
457
+ // verbose=false: per-file `[bundle] Copying X` lines bypass the clack
458
+ // gutter and print flush-left, breaking the visual flow. Bundle content
459
+ // is reproducible from the project tree; debug it by re-running with
460
+ // VIBECARBON_PERF=1 + VIBECARBON_BUNDLE_VERBOSE=1 if needed.
461
+ const bundlePath = renderBundle(projectConfig.projectName, {
462
+ domain,
463
+ image: imageRef,
464
+ observability: services.observability,
465
+ n8n: services.n8n,
466
+ metabase: services.metabase,
467
+ redis: services.redis,
468
+ s3: s3Config,
469
+ backupS3: backupS3Config,
470
+ verbose: process.env.VIBECARBON_BUNDLE_VERBOSE === '1',
471
+ });
472
+ bundleRenderTimer.end();
473
+
474
+ // --- STEP 4: DNS Warm-up (parallel with VM provisioning) ---
475
+ // Kick off the placeholder-DNS write in the background so TTL starts
476
+ // draining while we spin up the VM. The real-IP update below awaits the
477
+ // warm-up handle to avoid racing two writes on the same record.
478
+ const dnsInputs = { domain, dnsProvider, cloudflareZoneId, hetznerDnsZoneId };
479
+ const dnsWarmupPromise =
480
+ domain && dnsProvider && dnsProvider !== 'manual' && !state.shouldSkip('dns-setup', dnsInputs)
481
+ ? (async () => {
482
+ state.startStep('dns-setup', dnsInputs);
483
+ try {
484
+ const { setupSimple: updateDnsIp } =
485
+ dnsProvider === 'cloudflare'
486
+ ? await import('../cloudflare.js')
487
+ : await import('../hetzner-dns.js');
488
+ const token = dnsProvider === 'cloudflare' ? cloudflareApiToken : apiToken;
489
+ const zoneId = dnsProvider === 'cloudflare' ? cloudflareZoneId : hetznerDnsZoneId;
490
+ if (!zoneId) {
491
+ p.log.info('DNS warm-up skipped (Zone ID not found)');
492
+ return null;
493
+ }
494
+ const result = await updateDnsIp(token, zoneId, domain, '0.0.0.0');
495
+ state.completeStep('dns-setup', result);
496
+ p.log.info('DNS records warmed up');
497
+ return result;
498
+ } catch (error) {
499
+ p.log.warn(`Initial DNS setup failed: ${error.message}`);
500
+ return null;
501
+ }
502
+ })()
503
+ : Promise.resolve(null);
504
+
505
+ // --- STEP 5: Architecture Dispatch ---
506
+ let deployResult;
507
+ if (isComposeDeploy) {
508
+ if (deployMode === 'compose-ha') {
509
+ const { deployComposeHA } = await import('./compose/ha.js');
510
+ const sshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${environment}`);
511
+ const s = tracker.spinner();
512
+ s.start('Starting HA deployment...');
513
+ // Wrap the entire compose-HA pipeline so the perf trace shows the
514
+ // headline cost (which contains compose-ha.deploy.* sub-stages from
515
+ // compose/ha.js).
516
+ deployResult = await perfAsync('deploy.ha.compose.full', () =>
517
+ deployComposeHA({
518
+ projectName: projectConfig.projectName,
519
+ environment,
520
+ sshKeyPath,
521
+ domain,
522
+ region,
523
+ secondaryRegion: secondaryRegion || undefined,
524
+ serverType: serverType || 'cx23',
525
+ apiToken,
526
+ operatorCidrs: projectConfig.operatorCidrs ?? [],
527
+ cloudflareApiToken,
528
+ cloudflareZoneId,
529
+ hetznerDnsZoneId,
530
+ dnsProvider,
531
+ services,
532
+ dockerHubCreds: loadCredentials().dockerHub || null,
533
+ ghcrPullCreds: ciReady.ghcrPullCreds,
534
+ imageRef,
535
+ envConfig,
536
+ s3Config,
537
+ backupS3Config,
538
+ backupConfig,
539
+ bundlePath,
540
+ onProgress: (msg) => s.message(msg),
541
+ }),
542
+ );
543
+ s.stop('Compose HA deployment complete');
544
+ } else {
545
+ const { setupServer, dockerLoginOnServer, startComposeStack } = await import(
546
+ './compose/index.js'
547
+ );
548
+ const { setupServerFiles, waitForSSH: waitForComposeSSH } = await import(
549
+ './compose/index.js'
550
+ );
551
+ const sshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${environment}`);
552
+
553
+ let serverIp = envConfig.servers?.[0]?.ip || null;
554
+ // Track the Hetzner server's API id + literal name so destroy can find
555
+ // the VPS without falling back to the literal string "master" (which
556
+ // never matches the Pulumi-assigned name `${projectName}-${env}`).
557
+ // Without this, destroy → re-deploy hits 'server name is already used
558
+ // (uniqueness_error)' because the previous VPS was never deleted
559
+ // (caught in compose restore matrix runs).
560
+ let serverHetznerId = envConfig.servers?.[0]?.id || null;
561
+ let serverHetznerName = envConfig.servers?.[0]?.hetznerName || null;
562
+ if (!serverIp) {
563
+ generateSSHKeyPair(sshKeyPath);
564
+ const { upStack } = await import('../iac/index.js');
565
+ const { buildHetznerComposeProgram } = await import('../iac/programs/hetzner-compose.js');
566
+ const program = buildHetznerComposeProgram({
567
+ projectName: projectConfig.projectName,
568
+ environment,
569
+ sshPublicKey: readFileSync(`${sshKeyPath}.pub`, 'utf-8').trim(),
570
+ location: region,
571
+ serverType: serverType || 'cx23',
572
+ labels: { 'managed-by': 'vibecarbon' },
573
+ allowedSshIps: (projectConfig.operatorCidrs ?? []).map((e) => e.cidr),
574
+ });
575
+ // VM provisioning via Pulumi — typical 30-90s on Hetzner cx23.
576
+ const result = await perfAsync('deploy.iac.upStack', () =>
577
+ upStack(environment, program, { hcloudToken: apiToken, s3Config }),
578
+ );
579
+ serverIp = result.outputs.serverIp;
580
+ serverHetznerId = result.outputs.serverId || null;
581
+ serverHetznerName = `${projectConfig.projectName}-${environment}`;
582
+ await perfAsync('deploy.iac.waitForSSH', () => waitForComposeSSH(serverIp, sshKeyPath, 30));
583
+ }
584
+
585
+ // --- Warm path (StateTracker) ---
586
+ // On a second deploy against an already-provisioned server, skip the
587
+ // idempotent-but-slow steps (cloud-init probe, GHCR login, bundle rsync)
588
+ // when their inputs haven't changed. `docker compose up -d` is cheap
589
+ // and always runs — it applies diffs to the live stack.
590
+ const setupInputs = { serverIp };
591
+ if (!state.shouldSkip('compose-setup-server', setupInputs)) {
592
+ state.startStep('compose-setup-server', setupInputs);
593
+ // Cloud-init readiness probe — first-deploy spends 20-40s here while
594
+ // ufw + unattended-upgrades land. Warm deploys hit the ready marker
595
+ // immediately, so this is mostly cold-deploy noise.
596
+ await perfAsync('deploy.compose.cloudInitReady', async () => {
597
+ if (isDirectDeploy || isComposeLocal) {
598
+ // Both direct (build over DOCKER_HOST=ssh://) and local
599
+ // (sideload via docker save | ssh docker load) need dockerd
600
+ // up on the server before they can proceed. Push mode doesn't
601
+ // because the server pulls from GHCR after compose-up starts.
602
+ const { waitForDockerReady } = await import('./compose/index.js');
603
+ await setupServer(serverIp, sshKeyPath);
604
+ await waitForDockerReady(serverIp, sshKeyPath);
605
+ } else {
606
+ await setupServer(serverIp, sshKeyPath);
607
+ }
608
+ });
609
+ state.completeStep('compose-setup-server', { serverIp });
610
+ }
611
+
612
+ // --- Image transfer to server (mode-dependent) ---
613
+ if (isComposeLocal) {
614
+ // Local-build path: the build was kicked off in parallel with
615
+ // iac.upStack at the top of this function. By the time we reach
616
+ // here (post-cloudInitReady), the build has almost always finished.
617
+ // Await the promise (cheap if already settled) then sideload the
618
+ // image tarball over SSH. Sideload uses docker save | gzip | ssh
619
+ // | docker load — gzip drops ~600MB → ~150-200MB on the wire.
620
+ await composeLocalBuildPromise;
621
+ await perfAsync('deploy.image.sideload', async () =>
622
+ sideloadCompose({
623
+ tag: localImageTag,
624
+ sshTarget: `root@${serverIp}`,
625
+ sshKey: sshKeyPath,
626
+ }),
627
+ );
628
+ } else if (isDirectDeploy) {
629
+ // Direct path: build the image on the server via DOCKER_HOST=ssh://.
630
+ // BuildKit caches aggressively, so unchanged source rebuilds in 1-3s.
631
+ // Slower than local+sideload (no upStack overlap) but works when the
632
+ // operator has no local Docker — selected by --direct or by
633
+ // resolveBuildMode's docker-not-found fallback. VITE_* args plumbed
634
+ // through here for the same reason as the local-build path above.
635
+ const { collectComposeBuildArgs } = await import('./compose/build-args.js');
636
+ const directBuildArgs = collectComposeBuildArgs(process.cwd(), {
637
+ projectName: projectConfig.projectName,
638
+ domain,
639
+ });
640
+ const success = await perfAsync('deploy.image.directBuild', () =>
641
+ buildRemote(serverIp, sshKeyPath, localImageTag, process.cwd(), directBuildArgs),
642
+ );
643
+ if (!success) process.exit(1);
644
+ }
645
+
646
+ // --- Docker Hub login (skip if creds unchanged) ---
647
+ // Without this, the reconcile script's `docker compose pull` for
648
+ // Supabase service images (rest, auth, storage, kong, imgproxy, etc.)
649
+ // hits Docker Hub anonymously. A fresh Hetzner VPS shares a NAT range
650
+ // with many other deploys, so the per-IP unauthenticated pull quota
651
+ // is routinely exhausted — observed 2026-04-26 matrix run #3
652
+ // ("toomanyrequests: You have reached your unauthenticated pull rate
653
+ // limit"). Compose-HA + scale already log in; this brings the
654
+ // single-compose deploy path in line.
655
+ const dockerHubCreds = loadCredentials().dockerHub || null;
656
+ if (dockerHubCreds) {
657
+ const dhLoginInputs = {
658
+ serverIp,
659
+ registry: 'docker.io',
660
+ user: dockerHubCreds.username,
661
+ tokenFp: dockerHubCreds.token ? dockerHubCreds.token.slice(0, 8) : '',
662
+ };
663
+ if (!state.shouldSkip('compose-dockerhub-login', dhLoginInputs)) {
664
+ state.startStep('compose-dockerhub-login', dhLoginInputs);
665
+ await dockerLoginOnServer(serverIp, sshKeyPath, dockerHubCreds);
666
+ state.completeStep('compose-dockerhub-login', { serverIp });
667
+ }
668
+ }
669
+
670
+ // --- GHCR login (skip if creds unchanged) ---
671
+ if (ciReady.ghcrPullCreds) {
672
+ const loginInputs = {
673
+ serverIp,
674
+ registry: 'ghcr.io',
675
+ user: ciReady.ghcrPullCreds.owner,
676
+ // Hash the token's fingerprint, not the token itself — we don't
677
+ // want the token written to .vibecarbon/deploy-state-*.json.
678
+ tokenFp: ciReady.ghcrPullCreds.token ? ciReady.ghcrPullCreds.token.slice(0, 8) : '',
679
+ };
680
+ if (!state.shouldSkip('compose-ghcr-login', loginInputs)) {
681
+ state.startStep('compose-ghcr-login', loginInputs);
682
+ await dockerLoginOnServer(serverIp, sshKeyPath, {
683
+ username: ciReady.ghcrPullCreds.owner,
684
+ token: ciReady.ghcrPullCreds.token,
685
+ registry: 'ghcr.io',
686
+ });
687
+ state.completeStep('compose-ghcr-login', { serverIp });
688
+ }
689
+ }
690
+
691
+ // --- Bundle rsync (skip when bundle inputs unchanged) ---
692
+ const bundleInputs = {
693
+ serverIp,
694
+ projectName: projectConfig.projectName,
695
+ imageRef,
696
+ domain,
697
+ services,
698
+ };
699
+ if (!state.shouldSkip('compose-setup-files', bundleInputs)) {
700
+ state.startStep('compose-setup-files', bundleInputs);
701
+ // setupServerFiles tars the bundle locally + streams over ssh +
702
+ // extracts on the server in one pipeline. Bucket the whole upload
703
+ // here; the inner tar/upload split is captured in compose/index.js.
704
+ await perfAsync('deploy.bundle.upload', () =>
705
+ setupServerFiles(serverIp, sshKeyPath, projectConfig.projectName, {
706
+ ...services,
707
+ domain,
708
+ image: imageRef,
709
+ bundlePath,
710
+ }),
711
+ );
712
+ state.completeStep('compose-setup-files', { serverIp });
713
+ }
714
+
715
+ // --- DNS update + propagation wait BEFORE compose-up ---
716
+ //
717
+ // Traefik attempts ACME HTTP-01 challenges immediately on container
718
+ // start and bursts ~7 attempts in ~30s before giving up. If LE's
719
+ // DNS resolver sees stale or absent records on those attempts,
720
+ // every challenge fails with "no valid A records found" and
721
+ // acme.json ends the deploy with zero certs issued — browser
722
+ // sees TRAEFIK DEFAULT CERT and NET::ERR_CERT_AUTHORITY_INVALID.
723
+ //
724
+ // The warm-up that fires in Step 4 writes a 0.0.0.0 placeholder;
725
+ // LE specifically rejects 0.0.0.0 as not-a-valid-A-record. So even
726
+ // when warm-up "succeeds," it doesn't help ACME — it actively
727
+ // poisons the resolver state until the real IP overwrites it.
728
+ //
729
+ // Fix (RCA from vibecarbon.com cold-deploy 2026-05-19): swap the
730
+ // ordering. Write the real IP now, wait for it to propagate to
731
+ // public resolvers, then start compose. Traefik's first ACME
732
+ // attempt sees real DNS, succeeds on the first try.
733
+ //
734
+ // The compose-HA path already writes DNS before compose-up at
735
+ // src/lib/deploy/compose/ha.js — single-region compose was the
736
+ // outlier that ran startComposeStack first then updated DNS.
737
+ if (domain && dnsProvider && dnsProvider !== 'manual') {
738
+ await perfAsync('deploy.dns.warm', async () => {
739
+ await dnsWarmupPromise;
740
+ });
741
+ const dnsUpdateInputs = { domain, dnsProvider, serverIp };
742
+ if (!state.shouldSkip('compose-dns-update', dnsUpdateInputs)) {
743
+ state.startStep('compose-dns-update', dnsUpdateInputs);
744
+ const { setupSimple: updateDnsIp } =
745
+ dnsProvider === 'cloudflare'
746
+ ? await import('../cloudflare.js')
747
+ : await import('../hetzner-dns.js');
748
+ const token = dnsProvider === 'cloudflare' ? cloudflareApiToken : apiToken;
749
+ const zoneId = dnsProvider === 'cloudflare' ? cloudflareZoneId : hetznerDnsZoneId;
750
+ await perfAsync('deploy.dns.create', () => updateDnsIp(token, zoneId, domain, serverIp));
751
+ state.completeStep('compose-dns-update', { serverIp });
752
+ }
753
+ // Block compose-up until public resolvers see the real IP. 120s
754
+ // budget covers Cloudflare's typical edge propagation (under 60s)
755
+ // with margin. Returns false on timeout; we proceed anyway —
756
+ // Traefik can still acquire certs on its own retries, the
757
+ // customer just sees a brief self-signed-cert window. Hard-
758
+ // failing here would mask deploys that succeed eventually.
759
+ //
760
+ // Visible spinner so the operator isn't staring at a frozen
761
+ // "Yes" screen for up to 120s on a cold deploy where the DNS
762
+ // edge is slow. `timer` indicator forces the rendered string to
763
+ // change every second so progress remains visibly alive.
764
+ const dnsSpinner = p.spinner({ indicator: 'timer' });
765
+ dnsSpinner.start(`Waiting for ${domain} to resolve to ${serverIp}`);
766
+ const dnsPropagated = await perfAsync('deploy.dns.waitForPropagation', () =>
767
+ waitForDNSPropagation(domain, serverIp, 120_000),
768
+ );
769
+ dnsSpinner.stop(
770
+ dnsPropagated
771
+ ? `DNS resolves: ${domain} → ${serverIp}`
772
+ : `DNS propagation timed out — proceeding anyway`,
773
+ );
774
+ }
775
+
776
+ // `docker compose up -d` is idempotent and cheap — always run so a
777
+ // config tweak that didn't change the bundle hash (e.g. env var)
778
+ // still takes effect. Reconcile.sh on the server runs `docker compose
779
+ // pull` (when not local image) + `docker compose up -d` — see
780
+ // compose/index.js for the inner perf instrumentation.
781
+ await perfAsync('deploy.reconcile.run', () =>
782
+ startComposeStack(serverIp, sshKeyPath, projectConfig.projectName, services),
783
+ );
784
+
785
+ // --- Verifiable success gate ---
786
+ // Probe the app's own /api/health endpoint on the server itself (via
787
+ // localhost + Host header), bypassing DNS/TLS. A successful deploy
788
+ // must yield an HTTP 2xx here; otherwise fail the deploy loudly with
789
+ // container state + log tail.
790
+ //
791
+ // verifyAppHealth polls every 10s for up to 3 minutes while the app
792
+ // container restarts and binds port 3000. Without a visible spinner
793
+ // the operator just sees a frozen "Stack reconciled" line for that
794
+ // entire window — observed 2026-05-19 vibecarbon.com re-deploy
795
+ // where compose-up finished in ~5s but the app took ~3min to come
796
+ // back, leaving the screen looking hung. `timer` indicator so the
797
+ // elapsed-seconds counter ticks visibly even on terminals where
798
+ // the spinner frame-character animation gets throttled under load.
799
+ const { verifyAppHealth } = await import('./compose/index.js');
800
+ const healthSpinner = p.spinner({ indicator: 'timer' });
801
+ healthSpinner.start('Waiting for app to start serving requests');
802
+ let health;
803
+ try {
804
+ health = await perfAsync('deploy.health.probe', () =>
805
+ verifyAppHealth(serverIp, sshKeyPath, projectConfig.projectName, {
806
+ domain,
807
+ }),
808
+ );
809
+ } catch (err) {
810
+ healthSpinner.stop('App health probe errored', 1);
811
+ throw err;
812
+ }
813
+ if (!health.healthy) {
814
+ healthSpinner.stop(`App not serving requests (status: ${health.status})`, 1);
815
+ p.log.error(
816
+ `Deploy produced an unhealthy app (probe status: ${health.status}):\n${health.details}`,
817
+ );
818
+ process.exit(1);
819
+ }
820
+ healthSpinner.stop(`App is serving requests (HTTP ${health.status})`);
821
+
822
+ // --- ACME / TLS self-heal (compose only) ---
823
+ // The internal health probe above bypasses DNS/TLS and confirms the
824
+ // app container is up. It does NOT confirm Traefik has issued a real
825
+ // Let's Encrypt cert. Verify by reading acme.json; if Traefik is
826
+ // stuck on a stale failed-challenge state (typically because a
827
+ // previous deploy on v3.6.1 raced ahead of DNS propagation), restart
828
+ // Traefik in-line to retry ACME. RCA: vibecarbon.com customer hit
829
+ // this on 2026-05-19 — cold deploy "succeeded" with HTTP 200 from
830
+ // internal probe, but browsers saw NET::ERR_CERT_AUTHORITY_INVALID
831
+ // because Traefik served its self-signed default cert.
832
+ if (domain && dnsProvider && dnsProvider !== 'manual') {
833
+ const { ensureTraefikCert } = await import('./compose/acme-verify.js');
834
+ await perfAsync('deploy.acme.verify', () =>
835
+ ensureTraefikCert(serverIp, sshKeyPath, projectConfig.projectName, domain, p.log),
836
+ );
837
+ }
838
+
839
+ deployResult = {
840
+ masterIp: serverIp,
841
+ serverId: serverHetznerId || 'manual',
842
+ serverName: serverHetznerName,
843
+ };
844
+ }
845
+ } else {
846
+ // Kubernetes (k3s on Ubuntu)
847
+ const { deployK3s } = await import('./k8s/k3s.js');
848
+ const { deployK8sHA } = await import('./k8s/ha/index.js');
849
+
850
+ const deploymentConfig = {
851
+ projectName: projectConfig.projectName,
852
+ environment: config.environment,
853
+ provider: config.provider,
854
+ region,
855
+ secondaryRegion,
856
+ masterServerType,
857
+ supabaseServerType,
858
+ workerServerType,
859
+ serverType,
860
+ minWorkers,
861
+ maxWorkers,
862
+ domain,
863
+ ha: config.ha,
864
+ observability: config.observability,
865
+ services,
866
+ apiToken,
867
+ cloudflareApiToken,
868
+ cloudflareZoneId,
869
+ hetznerDnsZoneId,
870
+ dnsProvider,
871
+ s3Config,
872
+ backupConfig,
873
+ backupBucketName: backupS3Config?.bucket || null,
874
+ operatorCidrs: projectConfig.operatorCidrs ?? [],
875
+ imageReadyPromise,
876
+ tracker,
877
+ state,
878
+ };
879
+
880
+ if (config.ha) {
881
+ // K8s HA = 2 clusters (primary + standby) provisioned in parallel,
882
+ // each running k3s + Helm + sideload. Cold HA observed ~25-40 min.
883
+ deployResult = await perfAsync('deploy.ha.k8s.full', () => deployK8sHA(deploymentConfig));
884
+ } else {
885
+ deployResult = await perfAsync('deploy.k3s.full', () => deployK3s(deploymentConfig));
886
+ }
887
+
888
+ // K3s is local-first: deployK3s already built the image, sideloaded it
889
+ // to every node, and applied k8s manifests inline. Layer in Flux + GHA
890
+ // via `vibecarbon configure cicd <env>` (PR 7) after the cluster is up.
891
+ {
892
+ const kubeconfigPath = join(process.cwd(), '.vibecarbon', `kubeconfig-${environment}`);
893
+ const masterIp = deployResult.primary?.masterIp || deployResult.masterIp;
894
+ p.log.success('Local-first deploy complete. App is sideloaded + running on the cluster.');
895
+ p.note(
896
+ [
897
+ `Kubeconfig: ${kubeconfigPath}`,
898
+ `Master IP: ${masterIp ?? '(unknown — check Pulumi output)'}`,
899
+ '',
900
+ 'Next steps:',
901
+ ` vibecarbon shell ${environment} # bash with KUBECONFIG set`,
902
+ ` vibecarbon diagnose ${environment} # full cluster state dump`,
903
+ ` vibecarbon destroy ${environment} # tear down when done`,
904
+ '',
905
+ `To layer in Flux + GitHub Actions: vibecarbon configure cicd ${environment}`,
906
+ ].join('\n'),
907
+ 'Cluster + app ready',
908
+ );
909
+ // Fall through to Step 6 so .vibecarbon.json persists the serverlist —
910
+ // without it, `vibecarbon destroy` can't find the master/supabase IPs
911
+ // and the cluster leaks.
912
+ }
913
+
914
+ // --- Post-deploy DNS update ---
915
+ // The dnsWarmupPromise (started in Step 4) created records pointing at
916
+ // 0.0.0.0 so Cloudflare's edge has the zone record cached before traffic
917
+ // hits. Now we know the cluster's actual floating IP and need to update
918
+ // the records to point at it — without this, the verify-app-health probe
919
+ // below resolves to 0.0.0.0 and times out.
920
+ //
921
+ // Compose does the equivalent in its own branch above (line ~436).
922
+ // Local-first k3s manages DNS records inline here rather than via
923
+ // Flux annotations + external-dns.
924
+ {
925
+ const k8sFloatingIp =
926
+ deployResult.primary?.floatingIp ||
927
+ deployResult.floatingIp ||
928
+ deployResult.primary?.masterIp ||
929
+ deployResult.masterIp;
930
+ if (domain && dnsProvider && dnsProvider !== 'manual' && k8sFloatingIp) {
931
+ await perfAsync('deploy.dns.warm', async () => {
932
+ await dnsWarmupPromise;
933
+ });
934
+ const dnsUpdateInputs = { domain, dnsProvider, serverIp: k8sFloatingIp };
935
+ if (!state.shouldSkip('k8s-dns-update', dnsUpdateInputs)) {
936
+ state.startStep('k8s-dns-update', dnsUpdateInputs);
937
+ const { setupSimple: updateDnsIp } =
938
+ dnsProvider === 'cloudflare'
939
+ ? await import('../cloudflare.js')
940
+ : await import('../hetzner-dns.js');
941
+ const token = dnsProvider === 'cloudflare' ? cloudflareApiToken : apiToken;
942
+ const zoneId = dnsProvider === 'cloudflare' ? cloudflareZoneId : hetznerDnsZoneId;
943
+ await perfAsync('deploy.dns.create', () =>
944
+ updateDnsIp(token, zoneId, domain, k8sFloatingIp),
945
+ );
946
+ state.completeStep('k8s-dns-update', { serverIp: k8sFloatingIp });
947
+ }
948
+ }
949
+ }
950
+
951
+ // --- Verifiable success gate for k8s ---
952
+ // The GHA deploy workflow already waits for Flux reconciliation; this
953
+ // extra probe confirms the app is reachable from the public internet
954
+ // (not just healthy inside the cluster). DNS propagation, cert issuance,
955
+ // and ingress routing all need to land before a user can actually load
956
+ // the URL — any of those failing mid-deploy would leave us exiting 0
957
+ // while the URL returns 5xx.
958
+ //
959
+ // K8s first-deploy timing on a cold cluster:
960
+ // - DNS warmup record (0.0.0.0) → real IP swap propagates through
961
+ // Cloudflare's edge in 1-3 min (TTL=auto = 5 min worst case).
962
+ // - cert-manager HTTP-01 challenge: needs DNS to resolve to origin,
963
+ // then LE issues. Typical 1-3 min once DNS is correct, but
964
+ // orange-cloud + CF redirect quirks can push it to 8-12 min.
965
+ // - Traefik picks up the new cert: ~30s.
966
+ // Old 10-min budget tripped on the cold tail (observed 2026-04-25 batch
967
+ // run #1 — k8s standalone, k8s-ha both failed at the probe). 20 min
968
+ // covers the p99 cold path; warm rollouts still finish in <60s.
969
+ if (domain) {
970
+ const s = p.spinner();
971
+ s.start(`Probing https://${domain}/api/health ...`);
972
+ // Public health probe — DNS propagation + cert issuance + ingress
973
+ // routing all roll up here. p99 cold k8s-ha = 8-12 min; warm <60s.
974
+ const probeResult = await perfAsync('deploy.health.probe', () =>
975
+ probePublicHealth(domain, { timeoutMs: 1_200_000 }),
976
+ );
977
+ if (probeResult?.ok) {
978
+ s.stop(`App is serving requests (HTTP ${probeResult.status})`);
979
+ } else {
980
+ // Build a concise one-line failure summary as the spinner stop text
981
+ // AND as the first line of the diagnostic dump below. The runner
982
+ // captures the stderr tail as the step's error message; if this
983
+ // line is the most recent error-level emission, the runner's
984
+ // failure attribution will say WHAT went wrong (last status, last
985
+ // network error class) instead of just dumping curl HEAD response
986
+ // headers — which was the 2026-04-27 morning matrix's "[unknown]
987
+ // CSP header dump" failure mode.
988
+ const r = probeResult ?? {};
989
+ const reason = r.lastStatus
990
+ ? `last HTTP status ${r.lastStatus} after ${r.attempts} attempts over ${Math.round((r.timeoutMs ?? 0) / 1000)}s`
991
+ : r.lastErrorClass
992
+ ? `${r.lastErrorClass}: ${r.lastErrorMessage}${r.lastErrorCause ? ` (cause: ${r.lastErrorCause})` : ''} (${r.attempts} attempts)`
993
+ : 'never connected';
994
+ s.stop(`Public health probe failed against https://${domain}/api/health — ${reason}`);
995
+ // Capture diagnostics inline so a CI run failure surfaces the
996
+ // root cause without requiring a follow-up `vibecarbon diagnose`.
997
+ // The cluster is about to be destroyed by the test runner; once
998
+ // it's gone, kubectl probes return nothing. We spawn each tool via
999
+ // execFileSync (array argv, no shell) — domain and environment come
1000
+ // from validated config, but we still avoid shell-string interp.
1001
+ try {
1002
+ const { execFileSync } = await import('node:child_process');
1003
+ // HA deploys split into kubeconfig-<env>-primary / -standby; standalone
1004
+ // uses just kubeconfig-<env>. Prefer primary (the user-facing cluster
1005
+ // — Traefik/cert-manager state we want to inspect lives there) and
1006
+ // fall back to the standalone path. fs.existsSync check would race
1007
+ // against the FS, so prefer the file-not-found error in execFileSync.
1008
+ const { existsSync } = await import('node:fs');
1009
+ const kubeconfigCandidates = [
1010
+ join(process.cwd(), '.vibecarbon', `kubeconfig-${environment}-primary`),
1011
+ join(process.cwd(), '.vibecarbon', `kubeconfig-${environment}`),
1012
+ ];
1013
+ const kubeconfig =
1014
+ kubeconfigCandidates.find((c) => existsSync(c)) ?? kubeconfigCandidates[1];
1015
+ // Run each tool via execFileSync. Capture stderr alongside stdout
1016
+ // (kubectl prints errors to stderr; without merging we get the
1017
+ // useless first line of err.message — observed 2026-04-26 run #5
1018
+ // where every kubectl probe surfaced as "(kubectl failed: Command
1019
+ // failed: kubectl ...)" with no actual diagnostic).
1020
+ const safeRun = (cmd, argv) => {
1021
+ try {
1022
+ return execFileSync(cmd, argv, {
1023
+ encoding: 'utf8',
1024
+ timeout: 30_000,
1025
+ stdio: ['ignore', 'pipe', 'pipe'],
1026
+ }).trim();
1027
+ } catch (err) {
1028
+ const stdout = err?.stdout?.toString?.()?.trim() ?? '';
1029
+ const stderr = err?.stderr?.toString?.()?.trim() ?? '';
1030
+ const tail = [stdout, stderr].filter(Boolean).join('\n').slice(0, 2000);
1031
+ return `(${cmd} failed exit=${err?.status ?? '?'}):\n${tail || (err instanceof Error ? err.message : String(err))}`;
1032
+ }
1033
+ };
1034
+ const dig = safeRun('dig', ['+short', domain, '@1.1.1.1']);
1035
+ const kcExists = existsSync(kubeconfig);
1036
+ const certs = kcExists
1037
+ ? safeRun('kubectl', [
1038
+ '--kubeconfig',
1039
+ kubeconfig,
1040
+ 'get',
1041
+ 'certificate,certificaterequest,order,challenge',
1042
+ '-n',
1043
+ 'vibecarbon',
1044
+ '-o',
1045
+ 'wide',
1046
+ ])
1047
+ : `(kubeconfig not present at ${kubeconfig} — checked ${kubeconfigCandidates.join(', ')})`;
1048
+ const events = kcExists
1049
+ ? safeRun('kubectl', [
1050
+ '--kubeconfig',
1051
+ kubeconfig,
1052
+ 'get',
1053
+ 'events',
1054
+ '-n',
1055
+ 'vibecarbon',
1056
+ '--sort-by=.lastTimestamp',
1057
+ ])
1058
+ : '(skipped — no kubeconfig)';
1059
+ // Trim events to the most recent 30 lines.
1060
+ const recentEvents = events.split('\n').slice(-30).join('\n');
1061
+ // Direct curl probes — surface the actual TLS/HTTP error from the
1062
+ // public side (cert-manager not yet issued, traefik not routing,
1063
+ // kong 503, etc.). curl exits non-zero on connect/TLS errors so we
1064
+ // route through safeRun. -k skips cert validation — we want the
1065
+ // body even if origin's cert is staging or self-signed.
1066
+ const curlVerbose = safeRun('curl', [
1067
+ '-skvI',
1068
+ '--max-time',
1069
+ '10',
1070
+ `https://${domain}/api/health`,
1071
+ ]);
1072
+ const curlPodIp = safeRun('curl', [
1073
+ '-skv',
1074
+ '--max-time',
1075
+ '10',
1076
+ '--http1.1',
1077
+ `https://${domain}/api/health`,
1078
+ ]);
1079
+ // Pods + endpoints + app logs. When traefik returns a 19-byte
1080
+ // "404 page not found" but everything in cert-manager is green,
1081
+ // the cause is almost always "service `app` has zero endpoints
1082
+ // because all app pods are NotReady". Capturing pod state +
1083
+ // endpoints + the app pod's stdout (where the readiness probe
1084
+ // failure logs live — `Readiness check failed: { error: ... }`)
1085
+ // turns "deploy probe got 404" from a riddle into a directly
1086
+ // actionable error.
1087
+ const pods = kcExists
1088
+ ? safeRun('kubectl', [
1089
+ '--kubeconfig',
1090
+ kubeconfig,
1091
+ 'get',
1092
+ 'pods',
1093
+ '-n',
1094
+ 'vibecarbon',
1095
+ '-o',
1096
+ 'wide',
1097
+ ])
1098
+ : '(skipped — no kubeconfig)';
1099
+ const endpoints = kcExists
1100
+ ? safeRun('kubectl', [
1101
+ '--kubeconfig',
1102
+ kubeconfig,
1103
+ 'get',
1104
+ 'endpoints',
1105
+ '-n',
1106
+ 'vibecarbon',
1107
+ ])
1108
+ : '(skipped — no kubeconfig)';
1109
+ // App pod logs (last 100 lines). If multiple replicas, this picks
1110
+ // by deployment selector — same as `kubectl logs deploy/app`.
1111
+ const appLogs = kcExists
1112
+ ? safeRun('kubectl', [
1113
+ '--kubeconfig',
1114
+ kubeconfig,
1115
+ 'logs',
1116
+ 'deployment/app',
1117
+ '-n',
1118
+ 'vibecarbon',
1119
+ '--tail=100',
1120
+ '--all-containers',
1121
+ ])
1122
+ : '(skipped — no kubeconfig)';
1123
+ p.log.error(
1124
+ `Deploy completed but the app is not serving traffic.\n` +
1125
+ `\n--- DNS (dig +short ${domain}) ---\n${dig || '(empty)'}\n` +
1126
+ `\n--- cert-manager state (vibecarbon ns) ---\n${certs}\n` +
1127
+ `\n--- Pods (vibecarbon ns) ---\n${pods}\n` +
1128
+ `\n--- Service endpoints (vibecarbon ns) ---\n${endpoints}\n` +
1129
+ `\n--- App pod logs (last 100) ---\n${appLogs}\n` +
1130
+ `\n--- Recent events (last 30) ---\n${recentEvents}\n` +
1131
+ `\n--- curl -kvI https://${domain}/api/health (HEAD) ---\n${curlVerbose}\n` +
1132
+ `\n--- curl -kv https://${domain}/api/health (GET) ---\n${curlPodIp}\n`,
1133
+ );
1134
+ } catch (diagErr) {
1135
+ p.log.error(
1136
+ `Deploy completed but the app is not serving traffic. Check:\n` +
1137
+ ` - DNS: dig +short ${domain}\n` +
1138
+ ` - Pods: kubectl --kubeconfig ~/.vibecarbon/kubeconfig-${environment} get pods -A\n` +
1139
+ ` - Certs: kubectl --kubeconfig ~/.vibecarbon/kubeconfig-${environment} get certificate -n vibecarbon\n` +
1140
+ `(Auto-diagnostic capture failed: ${diagErr instanceof Error ? diagErr.message : String(diagErr)})`,
1141
+ );
1142
+ }
1143
+ // Emit the structured probe reason as the VERY LAST line on BOTH
1144
+ // stdout and stderr so the e2e runner's failure-tail capture
1145
+ // grabs it regardless of which stream wins. An earlier version
1146
+ // wrote it to stderr only, but the runner's `detail = stdoutClean
1147
+ // || stderrClean` prefers stdout — where @clack's p.log.error
1148
+ // wrote a CSP header dump from the curl -kvI diagnostic, masking
1149
+ // the structured reason in matrix attributions. Writing to both
1150
+ // streams costs nothing and guarantees the runner sees it as the
1151
+ // last line.
1152
+ const failLine = `\nFAIL: public health probe never reached 2xx — ${reason}\n`;
1153
+ console.error(failLine);
1154
+ console.log(failLine);
1155
+ // Throw rather than process.exit(1): process.exit aborts before
1156
+ // the deploy-logger's stream flushes, so the entire diagnostic
1157
+ // dump above is silently lost from the .vibecarbon/logs/ file.
1158
+ // (RCA from k8s-ha probe failure 2026-04-30: detail log ended
1159
+ // mid-spinner with no diagnostic preserved.) withDeployLog's
1160
+ // catch path writes the error trace, awaits the file close, then
1161
+ // re-throws — so the deploy still exits non-zero, but with a
1162
+ // self-contained log.
1163
+ throw new Error(`Public health probe failed: ${reason}`);
1164
+ }
1165
+ }
1166
+ }
1167
+
1168
+ // --- STEP 6: Finalize Config (Consolidated) ---
1169
+ // Persist supabaseIp alongside masterIp for k8s deploys. The failover flow
1170
+ // in src/failover.js needs the supabase node IP (where the postgres pod
1171
+ // runs) to pg_basebackup-reseed standby from primary — without it, the
1172
+ // failover uses whatever (potentially stale) state standby has. Compose
1173
+ // deploys only have a single masterIp so supabaseIp is omitted there.
1174
+ const servers = [];
1175
+ if (deployResult.primary) {
1176
+ // k8s-HA shape: { primary: { masterIp, supabaseIp }, standby: {...} }
1177
+ servers.push(
1178
+ {
1179
+ name: 'primary',
1180
+ ip: deployResult.primary.masterIp,
1181
+ supabaseIp: deployResult.primary.supabaseIp,
1182
+ region,
1183
+ },
1184
+ {
1185
+ name: 'standby',
1186
+ ip: deployResult.standby.masterIp,
1187
+ supabaseIp: deployResult.standby.supabaseIp,
1188
+ region: secondaryRegion,
1189
+ },
1190
+ );
1191
+ } else if (deployResult.primaryIp && deployResult.standbyIp) {
1192
+ // compose-HA shape: { primaryIp, standbyIp } — no supabaseIp because
1193
+ // compose-HA runs Supabase on the same VM as the app.
1194
+ servers.push(
1195
+ { name: 'primary', ip: deployResult.primaryIp, region, serverType },
1196
+ { name: 'standby', ip: deployResult.standbyIp, region: secondaryRegion, serverType },
1197
+ );
1198
+ } else if (deployResult.masterIp) {
1199
+ // Single-server fall-through: hit by compose (one VPS) and k8s
1200
+ // (master + supabase + worker[s]) deploys.
1201
+ //
1202
+ // Compose: persist the Hetzner-assigned `id` + literal name (e.g.
1203
+ // `${projectName}-${environment}`) so `vibecarbon destroy` can locate
1204
+ // the VPS via the API and `vibecarbon scale` can run its blue-green
1205
+ // migration through the Hetzner provider. The role-style
1206
+ // `name: 'master'` is kept because existing call-sites filter on it
1207
+ // (failover, etc.). Without `hetznerName`, destroy's name-fallback
1208
+ // searches Hetzner for the literal "master" — never matches — and the
1209
+ // next deploy collides on the unique server name. Without `id`, scale
1210
+ // treats the server as "deployed via --ssh, can't resize through the
1211
+ // API" and silently no-ops. scaleCompose replays the old server's full
1212
+ // `.env` into the new bundle as `envOverrides`, so the migration path
1213
+ // works end-to-end across both fields.
1214
+ //
1215
+ // K8s: in addition to the master entry, persist one entry per node
1216
+ // role (supabase + worker-N) so post-deploy commands (scale, destroy,
1217
+ // e2e verify-scale) can enumerate every IP without having to
1218
+ // re-derive worker IPs from Pulumi outputs. Master stays at index 0 —
1219
+ // a handful of compose-only paths read `envConfig.servers?.[0]`, and
1220
+ // preserving that contract is cheap. `supabaseIp` is also kept on the
1221
+ // master entry so failover.js can read `servers[0].supabaseIp` without
1222
+ // first iterating to find the supabase entry — redundant with the
1223
+ // dedicated supabase entry below, but keeps the existing failover
1224
+ // path working.
1225
+ servers.push({
1226
+ name: 'master',
1227
+ ...(deployResult.serverId &&
1228
+ deployResult.serverId !== 'manual' && { id: deployResult.serverId }),
1229
+ ...(deployResult.serverName && { hetznerName: deployResult.serverName }),
1230
+ ip: deployResult.masterIp,
1231
+ supabaseIp: deployResult.supabaseIp,
1232
+ region,
1233
+ serverType,
1234
+ ...(isComposeDeploy ? {} : { role: 'master' }),
1235
+ });
1236
+ // K8s only — compose has neither a separate supabase node nor worker
1237
+ // pool. Compose deploys leave servers as a single-element array.
1238
+ if (!isComposeDeploy) {
1239
+ if (deployResult.supabaseIp) {
1240
+ servers.push({
1241
+ name: 'supabase',
1242
+ ip: deployResult.supabaseIp,
1243
+ region,
1244
+ serverType,
1245
+ role: 'supabase',
1246
+ });
1247
+ }
1248
+ const workerIps = Array.isArray(deployResult.workerIps) ? deployResult.workerIps : [];
1249
+ workerIps.forEach((wIp, idx) => {
1250
+ if (!wIp) return;
1251
+ servers.push({
1252
+ name: `worker-${idx + 1}`,
1253
+ ip: wIp,
1254
+ region,
1255
+ serverType,
1256
+ role: 'worker',
1257
+ });
1258
+ });
1259
+ }
1260
+ }
1261
+
1262
+ let deployedCommit = null;
1263
+ try {
1264
+ deployedCommit = runCommand(['git', 'rev-parse', 'HEAD'], {
1265
+ encoding: 'utf-8',
1266
+ silent: true,
1267
+ }).trim();
1268
+ } catch {}
1269
+
1270
+ // Persist backupS3 alongside the storage-s3 block. The backup + restore
1271
+ // commands both read it via loadBackupS3Config() from .vibecarbon.json
1272
+ // — without it here, `vibecarbon restore` against a destroyed-then-redeployed
1273
+ // environment falls through to server-side `ls /opt/*/backups/*` on a
1274
+ // fresh VM with no files, so DR from a populated backup bucket is broken.
1275
+ // Keep the shape in sync with loadBackupS3Config (bucket/region/endpoint,
1276
+ // no credentials — those live in ~/.vibecarbon/credentials.json).
1277
+ const backupS3Persist = backupS3Config
1278
+ ? {
1279
+ bucket: backupS3Config.bucket,
1280
+ region: backupS3Config.region,
1281
+ endpoint: backupS3Config.endpoint,
1282
+ }
1283
+ : null;
1284
+
1285
+ // Persist HA flags + secondaryRegion so post-deploy commands can tell
1286
+ // a k8s-HA env (`deployMode='kubernetes' + ha.enabled + secondaryRegion`)
1287
+ // apart from a single-region k8s env (`deployMode='kubernetes'` only).
1288
+ // scale.js relies on this distinction to route Pulumi up against the
1289
+ // per-cluster `${env}-primary` / `${env}-standby` stacks instead of a
1290
+ // bare `${env}` stack that has no Pulumi state. Without these fields,
1291
+ // k8s-HA scale would try to create a fresh stack and Hetzner would
1292
+ // reject the duplicate SshKey post.
1293
+ const isHADeploy = !!config.ha && !!secondaryRegion;
1294
+
1295
+ // Re-read the on-disk project config before merging. Inner deploy paths
1296
+ // (compose/ha.js, k8s/ha/index.js) call saveProjectConfig themselves
1297
+ // mid-flight to persist server IDs / per-cluster details that this
1298
+ // outer save cannot reconstruct from `deployResult` alone (the
1299
+ // orchestrator's `servers` array drops `id`, for example). If we spread
1300
+ // the in-memory `projectConfig` (captured at function entry), we'd
1301
+ // silently overwrite those mid-flight saves — observed in compose-HA
1302
+ // matrix runs where destroyComposeHA found servers with no `id` and
1303
+ // skipped the VPS delete entirely, leaving Hetzner state to collide
1304
+ // on restore re-deploy.
1305
+ const persistedConfig = loadProjectConfig() || projectConfig;
1306
+ const persistedEnvConfig = persistedConfig.environments?.[environment] || envConfig;
1307
+ const finalConfig = {
1308
+ ...persistedConfig,
1309
+ environments: {
1310
+ ...persistedConfig.environments,
1311
+ [environment]: {
1312
+ ...persistedEnvConfig,
1313
+ status: 'deployed',
1314
+ deployMode,
1315
+ region,
1316
+ ...(isHADeploy && {
1317
+ secondaryRegion,
1318
+ // Persist per-cluster floatingIp + supabaseIp under ha.primary /
1319
+ // ha.standby so failover.identifyServers takes the structured
1320
+ // path that surfaces floatingIp. Without these, identifyServers
1321
+ // falls through to the flat `servers[]` lookup which has only
1322
+ // `ip` — failover then targets the standby's master IP for the
1323
+ // DNS A record instead of the floating IP, bypassing the
1324
+ // FIP-based design.
1325
+ ha: {
1326
+ enabled: true,
1327
+ failoverRegion: secondaryRegion,
1328
+ ...(deployResult.primary && {
1329
+ primary: {
1330
+ masterIp: deployResult.primary.masterIp,
1331
+ floatingIp: deployResult.primary.floatingIp,
1332
+ supabaseIp: deployResult.primary.supabaseIp,
1333
+ region,
1334
+ },
1335
+ standby: {
1336
+ masterIp: deployResult.standby.masterIp,
1337
+ floatingIp: deployResult.standby.floatingIp,
1338
+ supabaseIp: deployResult.standby.supabaseIp,
1339
+ region: secondaryRegion,
1340
+ },
1341
+ }),
1342
+ },
1343
+ }),
1344
+ domain,
1345
+ dnsProvider,
1346
+ // Persist the nested `dns: { provider, cloudflareZoneId | hetznerZoneId }`
1347
+ // shape too. failover.detectScenario reads `envConfig.dns?.provider`
1348
+ // (NOT the flat `dnsProvider`) — without this, k8s/k8s-ha failover
1349
+ // falls through to ha_manual, prints "Update DNS yourself" instead of
1350
+ // calling Cloudflare/Hetzner, and the public health probe in
1351
+ // verify-failover never points at the standby. Mirror the shape
1352
+ // compose-ha already persists in src/lib/deploy/compose/ha.js.
1353
+ dns:
1354
+ dnsProvider === 'cloudflare' && cloudflareZoneId
1355
+ ? { provider: 'cloudflare', cloudflareZoneId }
1356
+ : dnsProvider === 'hetzner' && hetznerDnsZoneId
1357
+ ? { provider: 'hetzner', hetznerZoneId: hetznerDnsZoneId }
1358
+ : null,
1359
+ // Prefer the inner deploy's richer `servers` (with `id`) when it
1360
+ // already wrote one. Compose-HA persists
1361
+ // [{ name, id, ip, region, serverType, role }, ...]
1362
+ // The outer `servers` we'd build here drops `id` (deployResult
1363
+ // for compose-HA only carries primaryIp/standbyIp). Falling back
1364
+ // to the inner save preserves the IDs that destroyComposeHA needs.
1365
+ servers:
1366
+ Array.isArray(persistedEnvConfig.servers) &&
1367
+ persistedEnvConfig.servers.length > 0 &&
1368
+ persistedEnvConfig.servers.some((s) => s.id != null)
1369
+ ? persistedEnvConfig.servers
1370
+ : servers,
1371
+ // Persist k8s sizing inputs so post-deploy `vibecarbon scale` can
1372
+ // rebuild the same Pulumi inputs without falling back to defaults.
1373
+ // Without `minWorkers/maxWorkers` here, scale.js would read
1374
+ // `envConfig.minWorkers/maxWorkers` as `undefined` and fall back to
1375
+ // built-in defaults — which on a clean type-bump made Pulumi plan a
1376
+ // delete of `worker-1`, replacing master+supabase along the way
1377
+ // (etcd loss). Master/supabase/worker types are also persisted so
1378
+ // the per-role split survives across deploy → scale.
1379
+ ...(deployMode === 'kubernetes' && {
1380
+ masterServerType,
1381
+ supabaseServerType,
1382
+ workerServerType,
1383
+ minWorkers,
1384
+ maxWorkers,
1385
+ }),
1386
+ deployedAt: new Date().toISOString(),
1387
+ deployedCommit,
1388
+ s3: { bucket: s3Config.bucket, region: s3Config.region, endpoint: s3Config.endpoint },
1389
+ ...(backupS3Persist && { backupS3: backupS3Persist }),
1390
+ backup: backupConfig,
1391
+ services,
1392
+ },
1393
+ },
1394
+ };
1395
+ saveProjectConfig(finalConfig);
1396
+ registerProject(projectConfig.projectName, process.cwd());
1397
+
1398
+ const { formatted: timeStr } = tracker.finish();
1399
+ p.log.success(`Deployment to ${c.bold(environment)} complete! ${c.dim(`(${timeStr})`)}`);
1400
+ if (domain) p.log.info(`URL: ${c.info(`https://${domain}`)}`);
1401
+ }