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,1294 @@
1
+ /**
2
+ * Docker Compose Production Deployment Module
3
+ *
4
+ * Deploys Vibecarbon projects to a single VPS using Docker Compose.
5
+ * Available to all tiers (Fast + Pro).
6
+ *
7
+ * Two provisioning modes:
8
+ * 1. Hetzner (automated): Uses HetznerProvider API to create VPS
9
+ * 2. Any VPS via SSH (--ssh user@ip): Skip provisioning, deploy to existing server
10
+ */
11
+
12
+ import { spawn } from 'node:child_process';
13
+ import { randomBytes } from 'node:crypto';
14
+ import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { dirname, join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import * as p from '@clack/prompts';
19
+ import { uploadS3Backup } from '../../backup-s3.js';
20
+ import { runCommand, runCommandAsync } from '../../command.js';
21
+ import { perfAsync, perfTimer } from '../../perf.js';
22
+ import { shEscape } from '../../shell.js';
23
+ import { scpDownload } from '../../ssh.js';
24
+
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = dirname(__filename);
27
+
28
+ // Path to the cloud-init YAML shipped with the CLI package. Referenced from
29
+ // carbon/cloud-init/ (one level up from src/ in the installed tree).
30
+ const CLOUD_INIT_PATH = join(__dirname, '../../../../carbon/cloud-init/docker-ce-setup.yaml');
31
+
32
+ /**
33
+ * Read the cloud-init user-data YAML that runs at VPS boot to front-load
34
+ * ufw + unattended-upgrades installation. Callers pass the returned string
35
+ * as `userData` to HetznerProvider.createServer().
36
+ *
37
+ * Kept as a thin wrapper so tests can stub the read and so `vibecarbon
38
+ * upgrade` has a single place to notice layout changes.
39
+ */
40
+ export function loadCloudInitScript() {
41
+ return readFileSync(CLOUD_INIT_PATH, 'utf-8');
42
+ }
43
+
44
+ // ServerAliveInterval+CountMax: force ssh to give up after 60s of no
45
+ // SSH-protocol traffic, so a freshly-provisioned VPS that accepts TCP on 22
46
+ // but never sends the SSH banner ("Connection timed out during banner
47
+ // exchange") fails fast instead of hanging the caller indefinitely. Same
48
+ // fix applied in lib/ssh.js — keep them in lockstep.
49
+ const SSH_OPTS =
50
+ '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4';
51
+
52
+ /**
53
+ * Run a command on a remote server via SSH
54
+ */
55
+ function sshRun(ip, sshKeyPath, command, options = {}) {
56
+ const { timeout = 120_000 } = options;
57
+ return runCommand(['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command], {
58
+ stdio: 'pipe',
59
+ timeout,
60
+ encoding: 'utf-8',
61
+ ...options,
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Async SSH runner — uses spawn (non-blocking) so spinners can animate.
67
+ * Retries on transient SSH connection errors (common on freshly provisioned servers
68
+ * where cloud-init may restart sshd).
69
+ */
70
+ export async function sshRunAsync(ip, sshKeyPath, command, options = {}) {
71
+ const { timeout = 120_000, retries = 3, ignoreError, ...rest } = options;
72
+ const sshArgs = ['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command];
73
+ // Strip ignoreError from inner call so connection errors always throw and
74
+ // the retry loop can catch them. We apply ignoreError ourselves after retries.
75
+ const runOpts = { silent: true, timeout, ...rest };
76
+
77
+ for (let attempt = 0; attempt < retries; attempt++) {
78
+ try {
79
+ return await runCommandAsync(sshArgs, runOpts);
80
+ } catch (err) {
81
+ const msg = err.message || '';
82
+ const isConnectionError =
83
+ msg.includes('Connection reset') ||
84
+ msg.includes('Connection refused') ||
85
+ msg.includes('Connection closed') ||
86
+ msg.includes('kex_exchange_identification') ||
87
+ msg.includes('ssh_exchange_identification') ||
88
+ msg.includes('No route to host');
89
+ // Timeouts are transient too: a freshly-provisioned Hetzner VPS can
90
+ // have unattended-upgrades chewing CPU/disk in the background after
91
+ // cloud-init's marker file lands — SSH responds, but each command
92
+ // takes longer. Observed in iter-validate4 compose-ha restore where
93
+ // dockerLogin (a tiny stdin-script SSH call with a 30s timeout)
94
+ // hit the timeout twice in a row on the primary VPS without
95
+ // retry. The retry budget here (3 attempts × 5s backoff) is small
96
+ // enough that real hangs still surface fast, but absorbs the
97
+ // background-work blip on a healthy server.
98
+ const isTimeout =
99
+ msg.includes('timed out') ||
100
+ msg.includes('Command was killed with SIGTERM') ||
101
+ msg.includes('ETIMEDOUT');
102
+ const isTransient = isConnectionError || isTimeout;
103
+
104
+ if (isTransient && attempt < retries - 1) {
105
+ console.error(
106
+ `[retry] ssh ${ip}: attempt ${attempt + 1}/${retries} failed (${msg.slice(0, 80).split('\n')[0]}); retrying in 5s`,
107
+ );
108
+ await new Promise((r) => setTimeout(r, 5000));
109
+ continue;
110
+ }
111
+
112
+ // All retries exhausted — respect ignoreError if set
113
+ if (ignoreError) return null;
114
+ throw err;
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Wait for SSH to become available on a newly provisioned server.
121
+ *
122
+ * Hetzner VPS typically accepts SSH ~20–35s after the create API returns.
123
+ * Early attempts use a 2s interval so we catch the edge quickly; after
124
+ * 10 tries (~20s) we fall back to 5s so we're not hammering the sshd
125
+ * during slow boots.
126
+ */
127
+ export async function waitForSSH(host, sshKeyPath, maxAttempts = 30) {
128
+ for (let i = 0; i < maxAttempts; i++) {
129
+ try {
130
+ runCommand(
131
+ [
132
+ 'ssh',
133
+ ...SSH_OPTS.split(' '),
134
+ '-i',
135
+ sshKeyPath,
136
+ '-o',
137
+ 'ConnectTimeout=5',
138
+ `root@${host}`,
139
+ 'echo',
140
+ 'ok',
141
+ ],
142
+ { stdio: 'pipe', timeout: 10_000 },
143
+ );
144
+ return true;
145
+ } catch {
146
+ if (i === maxAttempts - 1) return false;
147
+ // Tighter than the previous [2s ×10, 5s ×20]: SSH is usually live by
148
+ // attempt 3-5, so the fast leg pays off most often. Tail keeps 5s
149
+ // pacing for the genuinely slow boots.
150
+ const interval = i < 5 ? 1000 : i < 10 ? 2000 : 5000;
151
+ await new Promise((r) => setTimeout(r, interval));
152
+ }
153
+ }
154
+ return false;
155
+ }
156
+
157
+ /**
158
+ * Wait for the boot-time cloud-init script to finish.
159
+ *
160
+ * The Hetzner VPS is created with `user_data` that installs ufw + unattended-
161
+ * upgrades and touches `/var/lib/vibecarbon/ready`. `waitForSSH` only tells us
162
+ * sshd is accepting connections — cloud-init may still be mid-way through its
163
+ * runcmd list. Polling for the marker keeps deploy's critical path tight:
164
+ * by the time SSH is up, cloud-init is usually 10-30s away from finishing, and
165
+ * this returns without any apt-get work on our side.
166
+ *
167
+ * If cloud-init fails (marker never appears within the deadline), dump the
168
+ * tail of /var/log/cloud-init-output.log into the error so diagnosis is a
169
+ * one-line log grep for the operator.
170
+ */
171
+ export async function setupServer(ip, sshKeyPath) {
172
+ const deadline = Date.now() + 180_000; // 3 min — generous ceiling vs typical ~20-40s
173
+ let attempt = 0;
174
+ while (Date.now() < deadline) {
175
+ const result = await sshRunAsync(
176
+ ip,
177
+ sshKeyPath,
178
+ 'test -f /var/lib/vibecarbon/ready && echo READY || echo NOT_READY',
179
+ { timeout: 10_000, ignoreError: true },
180
+ );
181
+ if (result?.trim()?.endsWith('READY') && !result.includes('NOT_READY')) return;
182
+ // Early attempts poll fast — cloud-init typically finishes in 20-40s.
183
+ // First 15 tries at 750ms catch the typical case ~10s sooner; back off
184
+ // to 5s afterward to avoid hammering an actually-slow boot.
185
+ const interval = attempt < 15 ? 750 : 5000;
186
+ attempt++;
187
+ await new Promise((r) => setTimeout(r, interval));
188
+ }
189
+
190
+ // Marker never appeared — surface cloud-init output so the operator can see why.
191
+ let tail = '';
192
+ try {
193
+ tail =
194
+ (await sshRunAsync(
195
+ ip,
196
+ sshKeyPath,
197
+ 'tail -n 50 /var/log/cloud-init-output.log 2>/dev/null || echo "(cloud-init log unavailable)"',
198
+ { timeout: 15_000, ignoreError: true },
199
+ )) || '';
200
+ } catch {
201
+ // Best effort — don't mask the outer failure.
202
+ }
203
+ throw new Error(
204
+ `cloud-init never reached the ready marker on ${ip} within 180s.\n` +
205
+ `--- last 50 lines of /var/log/cloud-init-output.log ---\n${tail.trim()}\n` +
206
+ '--- end cloud-init tail ---',
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Authenticate with a container registry on a remote server.
212
+ *
213
+ * Writes credentials directly into `/root/.docker/config.json` rather than
214
+ * invoking `docker login --password-stdin`. `docker login` has two moving
215
+ * parts that have caused silent failures in e2e:
216
+ *
217
+ * 1. SSH stdin-input race — on a freshly-booted VM, the shell can start
218
+ * reading `docker login`'s prompt before our piped token arrives,
219
+ * causing docker to hang 30s then exit non-zero.
220
+ * 2. Any `credsStore` / `credsHelpers` entry in `/root/.docker/config.json`
221
+ * makes `docker login` try to delegate to a helper binary that doesn't
222
+ * exist on a bare Hetzner VM — login exits 0 but config.json never
223
+ * gets the new auth entry.
224
+ *
225
+ * The `auths` entry we write here is exactly what `docker login` produces
226
+ * internally (base64 of `username:token`, no encryption). Docker reads it
227
+ * on every pull/manifest-HEAD.
228
+ *
229
+ * Fails loud: if the write fails (unreachable server, permission denied,
230
+ * malformed JSON) we throw. A silent fallback here has burned us on
231
+ * standby GHCR 401s — a loud failure during login is much easier to debug
232
+ * than an `unauthorized` 20 minutes into reconcile.
233
+ */
234
+ export async function dockerLoginOnServer(ip, sshKeyPath, creds) {
235
+ if (!creds?.username || !creds?.token) return;
236
+ const { username, token, registry } = creds;
237
+ const registryHost = registry || 'https://index.docker.io/v1/';
238
+ const authB64 = Buffer.from(`${username}:${token}`).toString('base64');
239
+
240
+ // Merge into any existing config.json via jq (preserves other registries'
241
+ // auth when we log in to multiple). jq is guaranteed on Hetzner docker-ce
242
+ // images; if not present we fall back to writing a minimal file.
243
+ // Bash here-doc writes a minimal config.json directly. No jq dependency:
244
+ // bare Hetzner images don't ship with it, and installing via apt-get races
245
+ // cloud-init's unattended-upgrades apt lock (observed 04:08 UTC —
246
+ // "Could not get lock /var/lib/apt/lists/lock").
247
+ //
248
+ // Python3 is preinstalled on Ubuntu's docker-ce cloud image, so we use
249
+ // it to merge cleanly into any existing config.json (preserves other
250
+ // registries' auth when logging into Docker Hub + GHCR sequentially).
251
+ // Falls back to overwriting config.json if python3 is absent.
252
+ const script = `
253
+ set -e
254
+ mkdir -p /root/.docker
255
+ cfg=/root/.docker/config.json
256
+ if [ ! -s "$cfg" ]; then echo '{}' > "$cfg"; fi
257
+
258
+ if command -v python3 >/dev/null 2>&1; then
259
+ HOST=${shEscape(registryHost)} AUTH=${shEscape(authB64)} python3 - <<'PY'
260
+ import json, os
261
+ cfg_path = '/root/.docker/config.json'
262
+ try:
263
+ with open(cfg_path) as f:
264
+ cfg = json.load(f)
265
+ except Exception:
266
+ cfg = {}
267
+ if not isinstance(cfg, dict):
268
+ cfg = {}
269
+ cfg.setdefault('auths', {})[os.environ['HOST']] = {'auth': os.environ['AUTH']}
270
+ cfg.pop('credsStore', None)
271
+ cfg.pop('credHelpers', None)
272
+ with open(cfg_path, 'w') as f:
273
+ json.dump(cfg, f)
274
+ PY
275
+ else
276
+ # Minimal fallback: overwrite with single-registry config. Acceptable
277
+ # for bare images where we're the only thing writing this file.
278
+ printf '{"auths":{"%s":{"auth":"%s"}}}' ${shEscape(registryHost)} ${shEscape(authB64)} > "$cfg"
279
+ fi
280
+
281
+ chmod 600 "$cfg"
282
+ # Verify the auth entry landed. Deploys that silently lose auth here
283
+ # surface as 401 during reconcile 15+ min later — this grep catches it
284
+ # at login time so the failure is actionable.
285
+ if ! grep -q ${shEscape(authB64)} "$cfg"; then
286
+ echo "docker auth write verification failed — config.json:" >&2
287
+ cat "$cfg" >&2
288
+ exit 1
289
+ fi
290
+ `;
291
+ await sshRunAsync(ip, sshKeyPath, `/bin/bash -s`, {
292
+ silent: true,
293
+ timeout: 30_000,
294
+ input: script,
295
+ });
296
+ }
297
+
298
+ /**
299
+ * Collect deploy files and sync them to the server.
300
+ * Supports pre-rendered bundlePath (Phase 1 Optimization).
301
+ */
302
+ export async function setupServerFiles(ip, sshKeyPath, projectName, options = {}) {
303
+ const remoteDir = `/opt/${projectName}`;
304
+ const stageDir = options.bundlePath || mkdtempSync(join(tmpdir(), 'vc-deploy-'));
305
+
306
+ try {
307
+ // If not using a pre-rendered bundle, perform ad-hoc rendering (legacy path)
308
+ if (!options.bundlePath) {
309
+ // ... (rest of the manual rendering logic if needed, but we now use bundle.js)
310
+ // Actually, to keep it clean, we just import renderBundle if bundlePath is missing
311
+ const { renderBundle } = await import('../bundle.js');
312
+ const adhocBundle = renderBundle(projectName, options);
313
+ // Copy from adhoc to stageDir or just use adhoc
314
+ // For simplicity, let's just use the renderBundle result
315
+ options.bundlePath = adhocBundle;
316
+ }
317
+
318
+ // -- Single tar + SSH pipe to extract on server --
319
+ // Per-arm filename. HA-scale and HA-deploy fan two arms in parallel
320
+ // through this function, both with the same projectName. A shared
321
+ // /tmp path was racing — Arm A's stream of the bundle could mid-flight
322
+ // read a file Arm B had already begun overwriting, surfacing on the
323
+ // remote side as "gzip: stdin: unexpected end of file". The ip + pid +
324
+ // random suffix gives each arm its own file so the cleanup rmSync below
325
+ // can't yank Arm A's stream out from under it.
326
+ const safeIp = ip.replace(/[^0-9a-zA-Z]/g, '_');
327
+ const tarPath = join(
328
+ tmpdir(),
329
+ `vc-deploy-${projectName}-${safeIp}-${process.pid}-${randomBytes(6).toString('hex')}.tar.gz`,
330
+ );
331
+ // Inner pack vs upload+extract split — pack is local CPU-bound, upload
332
+ // is bandwidth-bound. Splitting them isolates whether a slow deploy is
333
+ // dev-laptop or network/server side. Clack spinners replace the older
334
+ // `console.log('[sync] ...')` triplet so the operator sees animation
335
+ // during the perfAsync wait instead of dead silence.
336
+ const packSpinner = p.spinner();
337
+ packSpinner.start(`Packing bundle from ${options.bundlePath}`);
338
+ try {
339
+ await perfAsync('deploy.bundle.tarPack', () =>
340
+ runCommandAsync(['tar', '-czf', tarPath, '-C', options.bundlePath, '.'], {
341
+ silent: true,
342
+ timeout: 30_000,
343
+ }),
344
+ );
345
+ packSpinner.stop('Bundle packed');
346
+ } catch (err) {
347
+ packSpinner.stop('Bundle pack failed', 1);
348
+ throw err;
349
+ }
350
+
351
+ const uploadSpinner = p.spinner();
352
+ uploadSpinner.start(`Uploading + extracting bundle on ${ip}`);
353
+ try {
354
+ await perfAsync('deploy.bundle.extract', () =>
355
+ runCommandAsync(
356
+ [
357
+ 'bash',
358
+ '-c',
359
+ 'cat "$1" | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -i "$2" "root@$3" "mkdir -p $4/backups && tar --no-xattrs --no-same-owner -xzf - -C $4 && cp $4/$5.service /etc/systemd/system/ && systemctl daemon-reload"',
360
+ '--',
361
+ tarPath,
362
+ sshKeyPath,
363
+ ip,
364
+ remoteDir,
365
+ projectName,
366
+ ],
367
+ { silent: true, timeout: 300_000 },
368
+ ),
369
+ );
370
+ uploadSpinner.stop('Bundle synced');
371
+ } catch (err) {
372
+ uploadSpinner.stop('Bundle upload failed', 1);
373
+ throw err;
374
+ }
375
+
376
+ // Clean up local tar
377
+ try {
378
+ rmSync(tarPath, { force: true });
379
+ } catch {
380
+ // Ignore cleanup errors
381
+ }
382
+ } finally {
383
+ // Clean up staging dir only if we created it (adhoc)
384
+ if (!options.bundlePath) {
385
+ try {
386
+ rmSync(stageDir, { recursive: true, force: true });
387
+ } catch {
388
+ // Ignore cleanup errors
389
+ }
390
+ }
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Heuristic: does this image tag refer to a registry path or a local-only tag?
396
+ *
397
+ * A registry path always contains either a `/` (e.g. `ghcr.io/owner/repo:tag`,
398
+ * `library/postgres`) or a host:port prefix. Local tags built by direct mode
399
+ * are bare names like `<project>-app:local`. Distinguishing the two lets
400
+ * callers decide whether to ship the image to peer servers (local-only)
401
+ * or rely on `docker pull` (registry path).
402
+ */
403
+ export function isLocalOnlyImageTag(imageRef) {
404
+ if (!imageRef || typeof imageRef !== 'string') return false;
405
+ const [namePart] = imageRef.split(':');
406
+ // A '/' anywhere in the name part means the tag carries a registry path
407
+ // (e.g. ghcr.io/owner/repo or owner/repo). Local docker tags built via
408
+ // `docker build -t <project>-app:local` have no slash.
409
+ return !namePart.includes('/');
410
+ }
411
+
412
+ /**
413
+ * Distribute a locally-built Docker image from one server's daemon to another
414
+ * via an SSH-piped `docker save | docker load`.
415
+ *
416
+ * Direct-mode deploys build the image into a single server's local Docker
417
+ * daemon (via `DOCKER_HOST=ssh://<source>`). Multi-server scenarios (HA
418
+ * standby, compose-scale's blue-green new VM) need that image on the other
419
+ * servers before reconcile.sh runs `docker compose up -d` — otherwise compose
420
+ * tries to `docker pull <local-tag>`, which is not a registry path, and fails
421
+ * with `pull access denied`.
422
+ *
423
+ * The pure SSH-pipe approach avoids any temp-file or scp intermediate: stdout
424
+ * of `docker save` on the source streams directly into `docker load` on the
425
+ * destination. For a 600MB image across hel1↔nbg1 this typically lands in
426
+ * 30-60s; we budget 10 minutes (large images + slow inter-DC links).
427
+ *
428
+ * No-op (returns without erroring) when imageRef looks like a registry path,
429
+ * since `docker compose pull` will already handle those.
430
+ *
431
+ * @param {string} sourceIp - Server that already has the image in its daemon
432
+ * @param {string} destIp - Server that needs the image
433
+ * @param {string} sshKeyPath
434
+ * @param {string} imageRef - Tag to transfer (e.g. `<project>-app:local`)
435
+ * @returns {Promise<void>}
436
+ */
437
+ export async function transferImageBetweenServers(sourceIp, destIp, sshKeyPath, imageRef) {
438
+ if (!isLocalOnlyImageTag(imageRef)) {
439
+ // Registry-path image — let `docker compose pull` handle it.
440
+ return;
441
+ }
442
+ if (sourceIp === destIp) return;
443
+ // shEscape the imageRef so a future tag with shell metacharacters (':',
444
+ // '@', '/' are all fine; we still escape for defense-in-depth) can't
445
+ // break out of `docker save`. The other tokens (ip, sshKeyPath) are
446
+ // controlled by the deploy code, not user input.
447
+ // gzip on the wire — same trick as sideloadCompose / sideloadK3s. Node
448
+ // images compress 3-5x; without gzip a ~600MB save streams uncompressed
449
+ // across two regions on top of the operator's residential upload, which
450
+ // historically tripped the 600s timeout under load.
451
+ const saveCmd = `ssh ${SSH_OPTS} -i ${shEscape(sshKeyPath)} root@${sourceIp} docker save ${shEscape(imageRef)}`;
452
+ const loadCmd = `ssh ${SSH_OPTS} -i ${shEscape(sshKeyPath)} root@${destIp} 'gunzip | docker load'`;
453
+ await runCommandAsync(['bash', '-c', `set -o pipefail && ${saveCmd} | gzip -1 | ${loadCmd}`], {
454
+ silent: true,
455
+ timeout: 600_000,
456
+ });
457
+ }
458
+
459
+ /**
460
+ * Wait for Docker daemon to be ready on a remote server.
461
+ *
462
+ * On Hetzner docker-ce VPSes Docker is already running once cloud-init
463
+ * releases the dpkg lock, so the first check usually succeeds immediately.
464
+ * Short initial interval keeps the latency tight on the rare miss.
465
+ */
466
+ export async function waitForDockerReady(ip, sshKeyPath, maxAttempts = 12) {
467
+ for (let i = 0; i < maxAttempts; i++) {
468
+ const result = await sshRunAsync(ip, sshKeyPath, 'docker info >/dev/null 2>&1 && echo READY', {
469
+ timeout: 10_000,
470
+ ignoreError: true,
471
+ });
472
+ if (result?.trim() === 'READY') return true;
473
+ if (i < maxAttempts - 1) {
474
+ const interval = i < 3 ? 1500 : 5000;
475
+ await new Promise((r) => setTimeout(r, interval));
476
+ }
477
+ }
478
+ return false;
479
+ }
480
+
481
+ /**
482
+ * Build the compose files flags string for a given set of service options.
483
+ */
484
+ function composeFileFlags(options = {}) {
485
+ const files = ['-f', 'docker-compose.yml', '-f', 'docker-compose.prod.yml'];
486
+ if (options.observability) {
487
+ files.push('-f', 'docker-compose.observability.yml');
488
+ files.push('-f', 'docker-compose.observability.prod.yml');
489
+ }
490
+ if (options.n8n) {
491
+ files.push('-f', 'docker-compose.n8n.yml', '-f', 'docker-compose.n8n.prod.yml');
492
+ }
493
+ if (options.metabase) {
494
+ files.push('-f', 'docker-compose.metabase.yml', '-f', 'docker-compose.metabase.prod.yml');
495
+ }
496
+ if (options.redis) files.push('-f', 'docker-compose.redis.yml');
497
+ return files.join(' ');
498
+ }
499
+
500
+ /**
501
+ * Start the Docker Compose stack on the remote server
502
+ */
503
+ export async function startComposeStack(ip, sshKeyPath, projectName, _options = {}) {
504
+ const remoteDir = `/opt/${projectName}`;
505
+ // Spinner over the long silent compose.up — typical 60-130s warm, longer
506
+ // cold while Supabase services pull. Replaces the previous
507
+ // `console.log('[reconcile] Running...')` + silence + `Reconcile
508
+ // complete.` pair.
509
+ const reconcileSpinner = p.spinner();
510
+ reconcileSpinner.start(`Reconciling stack on ${ip} (docker compose up)`);
511
+ try {
512
+ // reconcile.sh combines `docker compose pull` + `docker compose up -d`
513
+ // on the server. The orchestrator-level `deploy.reconcile.run` wraps the
514
+ // outer call; this inner timer captures the SSH-side cost separately so
515
+ // we can attribute slow deploys to the remote-bash phase vs. the SSH
516
+ // round-trip overhead.
517
+ //
518
+ // 1800s (30 min) timeout: iter-banner 2026-05-02 had compose-ha standby
519
+ // reconcile run past the previous 900s budget while Docker Hub / Hetzner
520
+ // egress was slow — SSH was making progress (cluster came up healthy
521
+ // after the wrapper timed out) but the timer ate the run. 30 min covers
522
+ // a slow-pull cold start on any of the three S3 regions plus margin
523
+ // without masking a true regression (a real hang would still surface
524
+ // via the 60s ServerAlive keepalives in lib/ssh.js + compose SSH_OPTS).
525
+ await perfAsync('deploy.compose.up', () =>
526
+ sshRunAsync(ip, sshKeyPath, `/bin/bash ${remoteDir}/reconcile.sh`, { timeout: 1_800_000 }),
527
+ );
528
+ reconcileSpinner.stop('Stack reconciled');
529
+ } catch (err) {
530
+ reconcileSpinner.stop('Stack reconcile failed', 1);
531
+ const stdout = err.stdout?.trim?.();
532
+ if (stdout) {
533
+ const lines = stdout.split('\n');
534
+ const tail = lines.slice(-30).join('\n');
535
+ err.message = `${err.message}\n--- reconcile output (last 30 lines) ---\n${tail}`;
536
+ }
537
+ // Capture the db container's actual stderr — `docker compose up` only
538
+ // surfaces "container is unhealthy" / "Error", not the postgres exit
539
+ // reason. Without these logs, debugging a failed re-deploy means
540
+ // "guess and re-run". Mirrors what compose/ha.js already does on
541
+ // failover. Best-effort — we re-throw the original failure regardless.
542
+ try {
543
+ const dbLogs = await sshRunAsync(
544
+ ip,
545
+ sshKeyPath,
546
+ `cd ${remoteDir} && docker compose logs db --tail=60 2>&1 || true`,
547
+ { timeout: 30_000 },
548
+ );
549
+ if (typeof dbLogs === 'string' && dbLogs.trim()) {
550
+ err.message = `${err.message}\n--- db container logs (last 60 lines) ---\n${dbLogs.trim()}`;
551
+ }
552
+ } catch (logErr) {
553
+ err.message = `${err.message}\n(failed to capture db logs: ${logErr.message})`;
554
+ }
555
+ throw err;
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Wait for all compose services with healthchecks to reach "healthy".
561
+ *
562
+ * `docker compose up -d` returns as soon as containers are *created*, not
563
+ * when their healthchecks pass. For Supabase, auth/storage/rest all need
564
+ * 10-60s after container-start to become healthy — verify-deploy's
565
+ * storage_upload / rest_api checks 502 when they run against a starting
566
+ * service. Polls every 5s up to 3min; returns early once every service
567
+ * with a declared healthcheck reports "healthy".
568
+ *
569
+ * Non-critical — on timeout or error we log but don't fail, since the
570
+ * caller's primary gate is the app-container probe which already passed.
571
+ */
572
+ async function waitForServicesHealthy(ip, sshKeyPath, projectName, maxWaitMs = 180_000) {
573
+ const remoteDir = `/opt/${projectName}`;
574
+ const cmd = `cd ${remoteDir} && docker compose ps --format '{{.Service}} {{.Health}}' 2>&1`;
575
+ const deadline = Date.now() + maxWaitMs;
576
+ let lastState = '';
577
+ while (Date.now() < deadline) {
578
+ try {
579
+ const out = await sshRunAsync(ip, sshKeyPath, cmd, {
580
+ timeout: 15_000,
581
+ ignoreError: true,
582
+ });
583
+ const lines = (out || '')
584
+ .trim()
585
+ .split('\n')
586
+ .map((l) => l.trim())
587
+ .filter(Boolean);
588
+ // Partition by health state. Services without a declared healthcheck
589
+ // report empty/no-health — skip those, they don't block readiness.
590
+ const stillWarming = lines.filter((l) => l.endsWith(' starting') || l.endsWith(' unhealthy'));
591
+ lastState = stillWarming.join(', ') || 'all healthy';
592
+ if (stillWarming.length === 0) return true;
593
+ } catch {
594
+ /* retry */
595
+ }
596
+ await new Promise((r) => setTimeout(r, 5000));
597
+ }
598
+ // Best-effort; don't fail the deploy here — the app probe already passed.
599
+ console.error(
600
+ `[verifyAppHealth] services still warming after ${maxWaitMs / 1000}s: ${lastState}`,
601
+ );
602
+ return false;
603
+ }
604
+
605
+ /**
606
+ * Verify the deployed app is actually serving requests.
607
+ *
608
+ * Probes `http://localhost/api/health` on the server itself with a Host header
609
+ * (if a domain is configured), bypassing public DNS and TLS to isolate app
610
+ * health from DNS propagation / cert issuance races. Polls up to `attempts`
611
+ * times with 3s between attempts.
612
+ *
613
+ * On failure, captures `docker compose ps` + the last 40 lines of the `app`
614
+ * container log so operators see WHY the probe failed, not just THAT it did.
615
+ *
616
+ * Returns `{ healthy, status, details }`:
617
+ * - healthy — true iff the probe returned HTTP 2xx within the budget
618
+ * - status — HTTP status of the last probe ('none' if we never got one)
619
+ * - details — diagnostic string (empty when healthy)
620
+ */
621
+ export async function verifyAppHealth(ip, sshKeyPath, projectName, options = {}) {
622
+ // Probe the app container directly on its internal port (3000), bypassing
623
+ // Traefik + TLS. Traefik's port 80 listener redirects to HTTPS (301) and
624
+ // HTTPS requires a valid cert which may still be issuing on a fresh
625
+ // deploy. Hitting the container directly tests app-layer health without
626
+ // entangling the ingress path — the e2e harness's own
627
+ // waitForHealthy check (which hits the public URL) verifies end-to-end.
628
+ //
629
+ // First-boot route registration takes longer than the initial `docker
630
+ // compose up -d` return: the app has to start + bind port 3000.
631
+ // Empirically 10-30s. 3 min tolerates worst-case cold starts.
632
+ const { path = '/api/health', attempts = 18, delayMs = 10_000 } = options;
633
+ const remoteDir = `/opt/${projectName}`;
634
+ // Use `node` for the probe — the app's runtime image is guaranteed to have
635
+ // it (it runs the server), whereas curl/wget may not be present in slim
636
+ // node base images. One-liner: HEAD the local port 3000 path; exit 0 on
637
+ // 2xx, 1 otherwise. The outer shell echoes "2xx" or "fail" so the caller
638
+ // sees a deterministic token on stdout regardless of node's exit code.
639
+ const nodeScript = `const r=require('http').request({host:'localhost',port:3000,path:${JSON.stringify(path)},method:'GET',timeout:4000},res=>{process.exit(res.statusCode<300?0:1)});r.on('error',()=>process.exit(2));r.on('timeout',()=>process.exit(3));r.end();`;
640
+ const nodeScriptB64 = Buffer.from(nodeScript).toString('base64');
641
+ const probeCmd = `cd ${remoteDir} && (echo ${nodeScriptB64} | base64 -d | docker compose exec -T app node - && echo 2xx || echo fail) 2>&1`;
642
+
643
+ let lastStatus = 'none';
644
+ for (let i = 0; i < attempts; i++) {
645
+ if (i > 0) await new Promise((r) => setTimeout(r, delayMs));
646
+ try {
647
+ const result = await sshRunAsync(ip, sshKeyPath, probeCmd, {
648
+ timeout: 15_000,
649
+ ignoreError: true,
650
+ });
651
+ const out = (result || '').trim();
652
+ lastStatus = out.slice(-8) || 'none';
653
+ if (out.endsWith('2xx')) {
654
+ // App is serving, but Supabase services may still be warming up
655
+ // their healthchecks. The e2e harness's verify-deploy probes
656
+ // auth/storage/rest endpoints immediately after deploy returns —
657
+ // a 502 from storage that takes 60s to become healthy would fail
658
+ // otherwise-valid deploys. Wait for all compose-managed services
659
+ // with healthchecks to reach "healthy". Bounded: 3 min budget
660
+ // (wasn't spent during the app probe since that succeeded quickly).
661
+ await waitForServicesHealthy(ip, sshKeyPath, projectName);
662
+ return { healthy: true, status: '200', details: '' };
663
+ }
664
+ } catch (err) {
665
+ lastStatus = err.message?.slice(0, 80) || 'ssh-error';
666
+ }
667
+ }
668
+
669
+ // Unhealthy — collect diagnostics.
670
+ let ps = '';
671
+ let logs = '';
672
+ try {
673
+ ps =
674
+ (await sshRunAsync(
675
+ ip,
676
+ sshKeyPath,
677
+ `cd ${remoteDir} && docker compose ps --format '{{.Name}}\\t{{.State}}\\t{{.Status}}' 2>&1 | head -20`,
678
+ { timeout: 15_000, ignoreError: true },
679
+ )) || '';
680
+ } catch {
681
+ /* best effort */
682
+ }
683
+ try {
684
+ logs =
685
+ (await sshRunAsync(
686
+ ip,
687
+ sshKeyPath,
688
+ `cd ${remoteDir} && docker compose logs --tail=40 app 2>&1 | tail -40`,
689
+ { timeout: 15_000, ignoreError: true },
690
+ )) || '';
691
+ } catch {
692
+ /* best effort */
693
+ }
694
+ const details =
695
+ `Health probe failed after ${attempts} attempts (last status: ${lastStatus})\n` +
696
+ `--- docker compose ps ---\n${ps.trim()}\n` +
697
+ `--- app container log tail ---\n${logs.trim()}`;
698
+ return { healthy: false, status: lastStatus, details };
699
+ }
700
+
701
+ /**
702
+ * Stop the Docker Compose stack on the remote server
703
+ */
704
+ export async function stopComposeStack(ip, sshKeyPath, projectName, options = {}) {
705
+ const remoteDir = `/opt/${projectName}`;
706
+ const flags = composeFileFlags(options);
707
+ const composeCmd = `cd ${remoteDir} && docker compose ${flags} down --timeout 30`;
708
+ await sshRunAsync(ip, sshKeyPath, composeCmd, { timeout: 120_000 });
709
+ }
710
+
711
+ /**
712
+ * Pre-pull container images on the remote server.
713
+ * Run this in parallel with the local Docker build to overlap I/O.
714
+ */
715
+ export async function pullComposeImages(ip, sshKeyPath, projectName, options = {}) {
716
+ const remoteDir = `/opt/${projectName}`;
717
+ const flags = composeFileFlags(options);
718
+ // `docker compose pull` over SSH — first deploys pull ~2GB of Supabase
719
+ // images and Hetzner ingress can spike to several minutes. Warm pulls
720
+ // (everything cached) finish in <5s.
721
+ await perfAsync('deploy.compose.imagesPull', () =>
722
+ sshRunAsync(
723
+ ip,
724
+ sshKeyPath,
725
+ `cd ${remoteDir} && (docker compose ${flags} pull --policy missing --ignore-buildable || docker compose ${flags} pull --ignore-buildable || docker compose ${flags} pull 2>&1 || true)`,
726
+ { timeout: 600_000, ignoreError: true },
727
+ ),
728
+ );
729
+ }
730
+
731
+ /**
732
+ * Run database migrations on the remote server
733
+ */
734
+ export async function runMigrations(ip, sshKeyPath, projectName) {
735
+ const remoteDir = `/opt/${projectName}`;
736
+
737
+ // Wait for PostgreSQL to be ready, then run every migration — all in one
738
+ // SSH round-trip. A `for f in *.sql; do ...; done` on the remote shell
739
+ // executes the migrations sequentially without N+1 SSH handshakes (one
740
+ // ls, one psql per file = N+1 trips before). `|| true` per-file keeps a
741
+ // bad migration from halting the rest (matches prior behaviour).
742
+ await sshRunAsync(
743
+ ip,
744
+ sshKeyPath,
745
+ `cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres && ` +
746
+ `for f in $(ls supabase/migrations/ 2>/dev/null | sort); do ` +
747
+ `cat "supabase/migrations/$f" | docker compose exec -T db psql -U supabase_admin -d postgres 2>/dev/null || true; ` +
748
+ `done`,
749
+ { timeout: 300_000 },
750
+ );
751
+ }
752
+
753
+ /**
754
+ * Main Compose deployment orchestrator
755
+ *
756
+ * @param {object} options
757
+ * @param {string} options.projectName - Project name
758
+ * @param {string} options.environment - Environment name (prod, staging, etc.)
759
+ * @param {string} options.ip - Server IP address
760
+ * @param {string} options.sshKeyPath - Path to SSH private key
761
+ * @param {string} [options.domain] - Custom domain
762
+ * @param {boolean} [options.isNewServer] - Whether server was just provisioned
763
+ * @param {object} [options.services] - Enabled services (observability, n8n, etc.)
764
+ * @param {Function} [options.onProgress] - Progress callback
765
+ * @returns {object} Deployment result
766
+ */
767
+ export async function deployCompose(options) {
768
+ const {
769
+ projectName,
770
+ ip,
771
+ sshKeyPath,
772
+ domain,
773
+ isNewServer = false,
774
+ services = {},
775
+ imageRef,
776
+ onProgress = () => {},
777
+ } = options;
778
+
779
+ if (!imageRef) {
780
+ throw new Error('deployCompose requires options.imageRef (ghcr.io/<owner>/<repo>:<tag>)');
781
+ }
782
+
783
+ // Step 1: Install Docker on new servers
784
+ if (isNewServer) {
785
+ onProgress('Configuring server...');
786
+ await setupServer(ip, sshKeyPath);
787
+ }
788
+
789
+ const serviceOpts = {
790
+ observability: services.observability,
791
+ n8n: services.n8n,
792
+ metabase: services.metabase,
793
+ redis: services.redis,
794
+ };
795
+
796
+ // Step 2: Copy project files + point APP_IMAGE at the CI-built ghcr.io tag
797
+ onProgress('Copying project files to server...');
798
+ await setupServerFiles(ip, sshKeyPath, projectName, {
799
+ domain,
800
+ image: imageRef,
801
+ ...serviceOpts,
802
+ });
803
+
804
+ // Step 3: Pull all images (app + Supabase) — docker compose pull handles it.
805
+ onProgress('Pulling images...');
806
+ await pullComposeImages(ip, sshKeyPath, projectName, serviceOpts);
807
+
808
+ // Step 4: Start Docker Compose stack
809
+ onProgress('Starting services...');
810
+ await startComposeStack(ip, sshKeyPath, projectName, serviceOpts);
811
+
812
+ // Step 5: Run migrations
813
+ onProgress('Running database migrations...');
814
+ const remoteDir = `/opt/${projectName}`;
815
+ try {
816
+ await runMigrations(ip, sshKeyPath, projectName);
817
+ // Reload PostgREST schema cache so it sees newly created tables
818
+ await sshRunAsync(
819
+ ip,
820
+ sshKeyPath,
821
+ `cd ${remoteDir} && docker compose exec -T rest kill -s SIGUSR1 1 2>/dev/null || true`,
822
+ );
823
+ } catch {
824
+ // Migrations may partially fail on first run, non-fatal
825
+ }
826
+
827
+ return {
828
+ success: true,
829
+ ip,
830
+ domain,
831
+ deployMode: 'compose',
832
+ };
833
+ }
834
+
835
+ /**
836
+ * Fast re-deploy: sync new config and reconcile.
837
+ */
838
+ export async function redeployCompose(options) {
839
+ const { projectName, ip, sshKeyPath, services = {}, imageRef, onProgress = () => {} } = options;
840
+
841
+ if (!imageRef) {
842
+ throw new Error('redeployCompose requires options.imageRef');
843
+ }
844
+
845
+ // Step 1: Sync new files (including .env with new image tag)
846
+ onProgress('Syncing configuration...');
847
+ await setupServerFiles(ip, sshKeyPath, projectName, {
848
+ image: imageRef,
849
+ observability: services.observability,
850
+ n8n: services.n8n,
851
+ metabase: services.metabase,
852
+ redis: services.redis,
853
+ domain: options.domain,
854
+ });
855
+
856
+ // Step 2: Reconcile (pulls images if needed and restarts)
857
+ onProgress('Reconciling services...');
858
+ await startComposeStack(ip, sshKeyPath, projectName, {
859
+ observability: services.observability,
860
+ n8n: services.n8n,
861
+ metabase: services.metabase,
862
+ redis: services.redis,
863
+ });
864
+
865
+ return { success: true };
866
+ }
867
+
868
+ /**
869
+ * Get status of Docker Compose services on the remote server
870
+ */
871
+ export function getComposeStatus(ip, sshKeyPath, projectName) {
872
+ const remoteDir = `/opt/${projectName}`;
873
+
874
+ try {
875
+ const output = sshRun(ip, sshKeyPath, `cd ${remoteDir} && docker compose ps --format json`, {
876
+ timeout: 15_000,
877
+ });
878
+
879
+ if (!output?.trim()) {
880
+ return { running: false, services: [] };
881
+ }
882
+
883
+ // docker compose ps --format json outputs one JSON object per line
884
+ const services = output
885
+ .trim()
886
+ .split('\n')
887
+ .filter(Boolean)
888
+ .map((line) => {
889
+ try {
890
+ return JSON.parse(line);
891
+ } catch {
892
+ return null;
893
+ }
894
+ })
895
+ .filter(Boolean);
896
+
897
+ return {
898
+ running: services.some((s) => s.State === 'running'),
899
+ services,
900
+ };
901
+ } catch {
902
+ return { running: false, services: [] };
903
+ }
904
+ }
905
+
906
+ /**
907
+ * Destroy a Docker Compose deployment
908
+ */
909
+ export function destroyCompose(ip, sshKeyPath, projectName) {
910
+ const remoteDir = `/opt/${projectName}`;
911
+
912
+ // Stop and remove containers + volumes
913
+ sshRun(
914
+ ip,
915
+ sshKeyPath,
916
+ `cd ${remoteDir} && docker compose down -v --remove-orphans 2>/dev/null; rm -rf ${remoteDir}`,
917
+ {
918
+ timeout: 120_000,
919
+ },
920
+ );
921
+ }
922
+
923
+ /**
924
+ * Create a full backup via Docker Compose.
925
+ * Produces a .tar.gz archive containing:
926
+ * - postgres.sql.gz (main Supabase database, always)
927
+ * - n8n.sql.gz (if n8n database exists)
928
+ * - metabase.sql.gz (if metabase database exists)
929
+ * - storage.tar.gz (if file-backend storage files exist)
930
+ */
931
+ export function backupCompose(ip, sshKeyPath, projectName, options = {}) {
932
+ const { clean = false } = options;
933
+ const remoteDir = `/opt/${projectName}`;
934
+ const timestamp = new Date().toISOString().replace(/T/, '_').replace(/[:.]/g, '').slice(0, 15);
935
+ const archiveName = `${projectName}_${timestamp}_full.tar.gz`;
936
+ const cleanFlags = clean ? '--clean --if-exists' : '';
937
+
938
+ // Step 1: Dump postgres database
939
+ const t1 = perfTimer('backup.pgDump.main');
940
+ sshRun(
941
+ ip,
942
+ sshKeyPath,
943
+ `cd ${remoteDir} && docker compose exec -T db pg_dump -U postgres ${cleanFlags} postgres | gzip > backups/_postgres.sql.gz`,
944
+ { timeout: 300_000 },
945
+ );
946
+ t1.end();
947
+
948
+ // Step 2: Dump optional service databases (n8n, metabase) if they exist
949
+ const t2 = perfTimer('backup.pgDump.features');
950
+ sshRun(
951
+ ip,
952
+ sshKeyPath,
953
+ `cd ${remoteDir} && for db in n8n metabase; do docker compose exec -T db psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='\${db}'" 2>/dev/null | grep -q 1 && docker compose exec -T db pg_dump -U postgres "\${db}" | gzip > "backups/_\${db}.sql.gz" || true; done`,
954
+ { timeout: 300_000 },
955
+ );
956
+ t2.end();
957
+
958
+ // Step 3: Backup storage files if using file backend
959
+ const t3 = perfTimer('backup.storageFiles');
960
+ sshRun(
961
+ ip,
962
+ sshKeyPath,
963
+ `cd ${remoteDir} && FCOUNT=$(docker compose exec -T storage sh -c 'find /var/lib/storage -type f 2>/dev/null | wc -l' 2>/dev/null || echo 0) && [ "$FCOUNT" -gt 0 ] 2>/dev/null && docker compose exec -T storage tar czf - -C / var/lib/storage > backups/_storage.tar.gz || true`,
964
+ { timeout: 300_000 },
965
+ );
966
+ t3.end();
967
+
968
+ // Step 4: Create combined archive from work files (prefixed with _)
969
+ const t4 = perfTimer('backup.combineArchive');
970
+ sshRun(
971
+ ip,
972
+ sshKeyPath,
973
+ `cd ${remoteDir}/backups && tar czf ${archiveName} $(ls _postgres.sql.gz _n8n.sql.gz _metabase.sql.gz _storage.tar.gz 2>/dev/null) && rm -f _postgres.sql.gz _n8n.sql.gz _metabase.sql.gz _storage.tar.gz`,
974
+ { timeout: 120_000 },
975
+ );
976
+ t4.end();
977
+
978
+ return archiveName;
979
+ }
980
+
981
+ /**
982
+ * Upload a compose backup to S3.
983
+ *
984
+ * Downloads the archive from the VPS to a local temp file via SCP, then uploads
985
+ * to S3 using the Node.js SDK. This replaces the previous approach of installing
986
+ * awscli on the VPS (which failed silently on many server images).
987
+ */
988
+ export async function uploadComposeBackupToS3(
989
+ ip,
990
+ sshKeyPath,
991
+ projectName,
992
+ archiveName,
993
+ backupS3Config,
994
+ ) {
995
+ const localTmpPath = join(tmpdir(), `vc-backup-${archiveName}`);
996
+ try {
997
+ await perfAsync('backup.scpFromServer', async () =>
998
+ scpDownload(ip, sshKeyPath, `/opt/${projectName}/backups/${archiveName}`, localTmpPath),
999
+ );
1000
+ await perfAsync('backup.uploadS3', async () =>
1001
+ uploadS3Backup(backupS3Config, localTmpPath, `backups/${archiveName}`),
1002
+ );
1003
+ } finally {
1004
+ try {
1005
+ unlinkSync(localTmpPath);
1006
+ } catch {
1007
+ // Ignore cleanup errors
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ /**
1013
+ * Set up automated backup cron on the VPS.
1014
+ */
1015
+ export function setupComposeBackupCron(ip, sshKeyPath, projectName, backupS3Config, backupConfig) {
1016
+ const remoteDir = `/opt/${projectName}`;
1017
+ const schedule = backupConfig?.schedule || '0 */6 * * *';
1018
+ const retentionDays = backupConfig?.retentionDays || 30;
1019
+
1020
+ // Build environment file for cron
1021
+ const envLines = [`PROJECT_NAME=${projectName}`, `BACKUP_RETENTION_DAYS=${retentionDays}`];
1022
+ if (backupS3Config) {
1023
+ envLines.push(
1024
+ `S3_BACKUP_BUCKET=${backupS3Config.bucket}`,
1025
+ `S3_ACCESS_KEY=${backupS3Config.accessKey}`,
1026
+ `S3_SECRET_KEY=${backupS3Config.secretKey}`,
1027
+ `S3_ENDPOINT=${backupS3Config.endpoint}`,
1028
+ `S3_REGION=${backupS3Config.region}`,
1029
+ );
1030
+ }
1031
+
1032
+ // Install awscli for automated S3 uploads (fail loudly if it can't be installed)
1033
+ if (backupS3Config) {
1034
+ sshRun(
1035
+ ip,
1036
+ sshKeyPath,
1037
+ 'command -v aws >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y awscli || pip3 install awscli)',
1038
+ { timeout: 120_000 },
1039
+ );
1040
+ }
1041
+
1042
+ // Write env file and install cron
1043
+ const envContent = envLines.join('\\n');
1044
+ sshRun(
1045
+ ip,
1046
+ sshKeyPath,
1047
+ [
1048
+ `printf '${envContent}\\n' > ${remoteDir}/backups/.backup-env`,
1049
+ `chmod 600 ${remoteDir}/backups/.backup-env`,
1050
+ `chmod +x ${remoteDir}/backup/compose-backup.sh`,
1051
+ `(crontab -l 2>/dev/null | grep -v 'compose-backup.sh' ; echo '${schedule} set -a && . ${remoteDir}/backups/.backup-env && set +a && ${remoteDir}/backup/compose-backup.sh >> ${remoteDir}/backups/backup.log 2>&1') | crontab -`,
1052
+ ].join(' && '),
1053
+ { timeout: 120_000 },
1054
+ );
1055
+ }
1056
+
1057
+ /**
1058
+ * Restore from a backup archive via Docker Compose.
1059
+ * Supports legacy .tar.gz (pg_dump) and new WAL-G point-in-time recovery.
1060
+ */
1061
+ export async function restoreCompose(ip, sshKeyPath, projectName, backupFile, options = {}) {
1062
+ const remoteDir = `/opt/${projectName}`;
1063
+ const isWalg = backupFile === 'latest' || backupFile === 'walg';
1064
+
1065
+ // Stop the app container to prevent connections
1066
+ await sshRunAsync(
1067
+ ip,
1068
+ sshKeyPath,
1069
+ `cd ${remoteDir} && docker compose stop app 2>/dev/null || true`,
1070
+ );
1071
+
1072
+ if (isWalg) {
1073
+ // --- BLAZING FAST WAL-G RESTORE ---
1074
+ // 1. Stop DB
1075
+ await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose stop db`);
1076
+ // 2. Clear data (WAL-G restore-fetch requires empty dir)
1077
+ await sshRunAsync(
1078
+ ip,
1079
+ sshKeyPath,
1080
+ `cd ${remoteDir} && docker compose run --rm -v db_data:/data alpine sh -c "rm -rf /data/*"`,
1081
+ );
1082
+ // 3. Run WAL-G restore
1083
+ const restoreCmd = `cd ${remoteDir} && docker compose run --rm \
1084
+ -e WALG_S3_PREFIX="${options.walgS3Prefix}" \
1085
+ -e AWS_ACCESS_KEY_ID="${options.s3AccessKey}" \
1086
+ -e AWS_SECRET_ACCESS_KEY="${options.s3SecretKey}" \
1087
+ -e AWS_ENDPOINT="${options.s3Endpoint}" \
1088
+ -e AWS_REGION="${options.s3Region}" \
1089
+ db wal-g backup-fetch /var/lib/postgresql/data LATEST`;
1090
+ await sshRunAsync(ip, sshKeyPath, restoreCmd, { timeout: 900_000 });
1091
+ // 4. Start DB
1092
+ await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start db`);
1093
+ } else {
1094
+ // --- LEGACY PG_DUMP RESTORE ---
1095
+ // Extract archive
1096
+ await sshRunAsync(
1097
+ ip,
1098
+ sshKeyPath,
1099
+ `mkdir -p /tmp/vb-restore && tar --no-xattrs --no-same-owner -xzf ${remoteDir}/backups/${backupFile} -C /tmp/vb-restore`,
1100
+ { timeout: 120_000 },
1101
+ );
1102
+
1103
+ // Restore postgres database
1104
+ await sshRunAsync(
1105
+ ip,
1106
+ sshKeyPath,
1107
+ `cd ${remoteDir} && gunzip -c /tmp/vb-restore/postgres.sql.gz | docker compose exec -T db psql -U postgres -d postgres`,
1108
+ { timeout: 300_000 },
1109
+ );
1110
+
1111
+ // Restore optional service databases if present
1112
+ for (const db of ['n8n', 'metabase']) {
1113
+ await sshRunAsync(
1114
+ ip,
1115
+ sshKeyPath,
1116
+ `test -f /tmp/vb-restore/${db}.sql.gz && cd ${remoteDir} && gunzip -c /tmp/vb-restore/${db}.sql.gz | docker compose exec -T db psql -U postgres -d ${db} || true`,
1117
+ { timeout: 300_000 },
1118
+ );
1119
+ }
1120
+
1121
+ // Restore storage files if present
1122
+ await sshRunAsync(
1123
+ ip,
1124
+ sshKeyPath,
1125
+ `test -f /tmp/vb-restore/storage.tar.gz && cd ${remoteDir} && docker compose exec -T storage sh -c 'tar --no-xattrs --no-same-owner -xzf - -C /' < /tmp/vb-restore/storage.tar.gz || true`,
1126
+ { timeout: 300_000 },
1127
+ );
1128
+
1129
+ // Cleanup
1130
+ await sshRunAsync(ip, sshKeyPath, 'rm -rf /tmp/vb-restore');
1131
+ }
1132
+
1133
+ // Restart app
1134
+ await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start app`);
1135
+ }
1136
+
1137
+ /**
1138
+ * Wait for an HTTPS endpoint to respond with a valid certificate.
1139
+ * Polls until a successful response (2xx/3xx) or timeout.
1140
+ *
1141
+ * @param {string} url - Full HTTPS URL to check
1142
+ * @param {object} [options]
1143
+ * @param {number} [options.timeout=90000] - Max wait time in ms
1144
+ * @param {number} [options.interval=5000] - Poll interval in ms
1145
+ * @returns {Promise<boolean>} true if SSL is ready, false if timed out
1146
+ */
1147
+ export async function waitForSSL(url, { timeout = 90_000, interval = 5_000 } = {}) {
1148
+ const deadline = Date.now() + timeout;
1149
+ while (Date.now() < deadline) {
1150
+ try {
1151
+ const result = runCommand(
1152
+ ['curl', '-sf', '--max-time', '5', '-o', '/dev/null', '-w', '%{http_code}', url],
1153
+ {
1154
+ stdio: 'pipe',
1155
+ encoding: 'utf-8',
1156
+ timeout: 10_000,
1157
+ },
1158
+ );
1159
+ if (result?.trim().match(/^[23]\d\d$/)) return true;
1160
+ } catch {
1161
+ // Not ready yet
1162
+ }
1163
+ await new Promise((r) => setTimeout(r, interval));
1164
+ }
1165
+ return false;
1166
+ }
1167
+
1168
+ /**
1169
+ * Create the admin user in production Supabase via the GoTrue admin API.
1170
+ *
1171
+ * The user created during `vibecarbon create` only exists in the local Docker
1172
+ * Compose Supabase. Production needs its own admin user. The API returns 422
1173
+ * if the user already exists, making this idempotent.
1174
+ *
1175
+ * @param {string} serverIp - IP of the server running Supabase
1176
+ * @param {string} sshKeyPath - Path to SSH private key
1177
+ * @param {string} projectName - Project name (for remote dir)
1178
+ * @returns {Promise<{success: boolean, message: string}>}
1179
+ */
1180
+ export async function createAdminUser(serverIp, sshKeyPath, projectName) {
1181
+ const envPath = join(process.cwd(), '.env');
1182
+ if (!existsSync(envPath)) {
1183
+ return { success: false, message: 'No .env file found' };
1184
+ }
1185
+
1186
+ const envContent = readFileSync(envPath, 'utf-8');
1187
+ const adminEmail = envContent.match(/^ADMIN_EMAIL="?([^"\n]+)"?/m)?.[1];
1188
+ const adminPassword = envContent.match(/^ADMIN_PASSWORD="?([^"\n]+)"?/m)?.[1];
1189
+ const serviceRoleKey = envContent.match(/^SUPABASE_SERVICE_ROLE_KEY="?([^"\n]+)"?/m)?.[1];
1190
+
1191
+ if (!adminEmail || !adminPassword || !serviceRoleKey) {
1192
+ return { success: false, message: 'Admin credentials not found in .env' };
1193
+ }
1194
+
1195
+ const remoteDir = `/opt/${projectName}`;
1196
+
1197
+ // Wait for auth service to become healthy. Once the stack starts, GoTrue
1198
+ // typically answers /health within 15-25s. Short initial interval catches
1199
+ // the fast path without penalising slower boots.
1200
+ let authReady = false;
1201
+ for (let i = 0; i < 30; i++) {
1202
+ const health = await sshRunAsync(
1203
+ serverIp,
1204
+ sshKeyPath,
1205
+ `cd ${remoteDir} && docker compose exec -T auth wget -qO- http://localhost:9999/health 2>/dev/null || true`,
1206
+ { timeout: 10_000, ignoreError: true },
1207
+ );
1208
+ if (health?.includes('GoTrue')) {
1209
+ authReady = true;
1210
+ break;
1211
+ }
1212
+ const interval = i < 5 ? 500 : 2000;
1213
+ await new Promise((r) => setTimeout(r, interval));
1214
+ }
1215
+
1216
+ if (!authReady) {
1217
+ return { success: false, message: 'Auth service not ready' };
1218
+ }
1219
+
1220
+ // Create admin user via GoTrue admin API through an SSH port-forward.
1221
+ // Uses native fetch to avoid shell escaping issues with special chars in passwords.
1222
+ const localPort = 19876;
1223
+ const pf = spawn(
1224
+ 'ssh',
1225
+ [
1226
+ '-o',
1227
+ 'StrictHostKeyChecking=no',
1228
+ '-o',
1229
+ 'UserKnownHostsFile=/dev/null',
1230
+ '-o',
1231
+ 'BatchMode=yes',
1232
+ '-i',
1233
+ sshKeyPath,
1234
+ '-N',
1235
+ '-L',
1236
+ `${localPort}:localhost:8000`,
1237
+ `root@${serverIp}`,
1238
+ ],
1239
+ { stdio: 'ignore' },
1240
+ );
1241
+
1242
+ try {
1243
+ // Wait for port-forward to be ready. ssh -L is usually listening on
1244
+ // localhost within 200-500ms; first few retries are at 200ms before
1245
+ // falling back to 1s.
1246
+ let connected = false;
1247
+ for (let i = 0; i < 15; i++) {
1248
+ try {
1249
+ await fetch(`http://localhost:${localPort}/auth/v1/health`, {
1250
+ signal: AbortSignal.timeout(2000),
1251
+ });
1252
+ connected = true;
1253
+ break;
1254
+ } catch {
1255
+ const interval = i < 5 ? 200 : 1000;
1256
+ await new Promise((r) => setTimeout(r, interval));
1257
+ }
1258
+ }
1259
+
1260
+ if (!connected) {
1261
+ return { success: false, message: 'Could not reach auth service via SSH tunnel' };
1262
+ }
1263
+
1264
+ const response = await fetch(`http://localhost:${localPort}/auth/v1/admin/users`, {
1265
+ method: 'POST',
1266
+ headers: {
1267
+ Authorization: `Bearer ${serviceRoleKey}`,
1268
+ apikey: serviceRoleKey,
1269
+ 'Content-Type': 'application/json',
1270
+ },
1271
+ body: JSON.stringify({
1272
+ email: adminEmail,
1273
+ password: adminPassword,
1274
+ email_confirm: true,
1275
+ app_metadata: { role: 'super_admin' },
1276
+ }),
1277
+ signal: AbortSignal.timeout(15000),
1278
+ });
1279
+
1280
+ if (response.ok) {
1281
+ return { success: true, message: `Admin user created: ${adminEmail}` };
1282
+ }
1283
+ if (response.status === 422) {
1284
+ return { success: true, message: `Admin user already exists: ${adminEmail}` };
1285
+ }
1286
+ const body = await response.text().catch(() => '');
1287
+ return {
1288
+ success: false,
1289
+ message: `Auth returned ${response.status}${body ? `: ${body}` : ''}`,
1290
+ };
1291
+ } finally {
1292
+ pf.kill();
1293
+ }
1294
+ }