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,755 @@
1
+ /**
2
+ * Hetzner Cloud Provider
3
+ *
4
+ * Implementation of the BaseProvider interface for Hetzner Cloud.
5
+ * Provides server, SSH key, and firewall management through the Hetzner API.
6
+ *
7
+ * API Documentation: https://docs.hetzner.cloud/
8
+ */
9
+
10
+ import { fetchWithRetry } from '../fetch-retry.js';
11
+ import { BaseProvider } from './base.js';
12
+
13
+ export class HetznerProvider extends BaseProvider {
14
+ static NAME = 'Hetzner Cloud';
15
+ static API_BASE = 'https://api.hetzner.cloud/v1';
16
+
17
+ static REGIONS = {
18
+ fsn1: 'Falkenstein, Germany',
19
+ nbg1: 'Nuremberg, Germany',
20
+ hel1: 'Helsinki, Finland',
21
+ ash: 'Ashburn, Virginia, USA',
22
+ hil: 'Hillsboro, Oregon, USA',
23
+ };
24
+
25
+ // Offline fallback catalog — used when the API is unreachable or no token is available.
26
+ // Prices and availability are approximate; live data from fetchServerTypes() takes precedence.
27
+ static FALLBACK_SERVER_TYPES = {
28
+ cx23: { vcpu: 2, ram: 4, disk: 40, price: '€4.19/mo' },
29
+ cx33: { vcpu: 4, ram: 8, disk: 80, price: '€7.09/mo' },
30
+ cx43: { vcpu: 8, ram: 16, disk: 160, price: '€13.49/mo' },
31
+ cx53: { vcpu: 16, ram: 32, disk: 320, price: '€27.49/mo' },
32
+ cx22: { vcpu: 2, ram: 4, disk: 40, price: '€3.99/mo' },
33
+ cx32: { vcpu: 4, ram: 8, disk: 80, price: '€6.49/mo' },
34
+ cx42: { vcpu: 8, ram: 16, disk: 160, price: '€11.99/mo' },
35
+ cx52: { vcpu: 16, ram: 32, disk: 320, price: '€22.49/mo' },
36
+ cpx11: { vcpu: 2, ram: 2, disk: 40, price: '€7.99/mo' },
37
+ cpx21: { vcpu: 3, ram: 4, disk: 80, price: '€13.99/mo' },
38
+ cpx22: { vcpu: 3, ram: 4, disk: 80, price: '€13.99/mo' },
39
+ cpx31: { vcpu: 4, ram: 8, disk: 160, price: '€25.49/mo' },
40
+ cpx41: { vcpu: 8, ram: 16, disk: 240, price: '€36.49/mo' },
41
+ cpx51: { vcpu: 16, ram: 32, disk: 360, price: '€50.49/mo' },
42
+ cax11: { vcpu: 2, ram: 4, disk: 40, price: '€4.49/mo' },
43
+ cax21: { vcpu: 4, ram: 8, disk: 80, price: '€7.99/mo' },
44
+ cax31: { vcpu: 8, ram: 16, disk: 160, price: '€15.99/mo' },
45
+ cax41: { vcpu: 16, ram: 32, disk: 320, price: '€31.49/mo' },
46
+ ccx13: { vcpu: 2, ram: 8, disk: 80, price: '€15.99/mo' },
47
+ ccx23: { vcpu: 4, ram: 16, disk: 160, price: '€31.49/mo' },
48
+ ccx33: { vcpu: 8, ram: 32, disk: 240, price: '€62.49/mo' },
49
+ ccx43: { vcpu: 16, ram: 64, disk: 360, price: '€124.99/mo' },
50
+ ccx53: { vcpu: 32, ram: 128, disk: 600, price: '€249.99/mo' },
51
+ ccx63: { vcpu: 48, ram: 192, disk: 960, price: '€374.49/mo' },
52
+ };
53
+
54
+ // Live catalog populated by fetchServerTypes(). Falls back to FALLBACK_SERVER_TYPES.
55
+ static SERVER_TYPES = { ...HetznerProvider.FALLBACK_SERVER_TYPES };
56
+
57
+ // Per-location availability populated by fetchServerTypes().
58
+ // Maps location name → Set of available server type names.
59
+ static _locationTypes = null;
60
+
61
+ static DEFAULT_TYPE = 'cpx11';
62
+ static HA_REGIONS = ['fsn1', 'hel1'];
63
+
64
+ // Infrastructure costs (EUR/mo, as of Apr 2026)
65
+ static INFRA_COSTS = {
66
+ floatingIp: 3.57,
67
+ volumePerGb: 0.052, // Hetzner CSI volumes
68
+ s3MinBucket: 4.49, // 250 GB Object Storage minimum
69
+ firewall: 0, // Free
70
+ network: 0, // Free (first 1 TB)
71
+ };
72
+
73
+ // Known PVC sizes (Gi) per service
74
+ static VOLUME_SIZES = {
75
+ base: 30, // postgres 20Gi + storage 10Gi
76
+ observability: 25, // prometheus 10Gi + loki 10Gi + grafana 5Gi
77
+ n8n: 5,
78
+ metabase: 1,
79
+ redis: 1,
80
+ };
81
+
82
+ static EU_REGIONS = ['fsn1', 'nbg1', 'hel1'];
83
+ static US_REGIONS = ['ash', 'hil'];
84
+
85
+ // Prefix-based fallback for region filtering when live data is unavailable.
86
+ static REGION_TYPE_PREFIXES = {
87
+ us: ['cpx', 'ccx'],
88
+ eu: ['cx', 'cpx', 'cax', 'ccx'],
89
+ };
90
+
91
+ /**
92
+ * Fetch server types and per-location pricing from the Hetzner API.
93
+ * Populates SERVER_TYPES with live data and caches per-location availability.
94
+ * Safe to call multiple times — returns cached data after the first successful fetch.
95
+ * @param {string} apiToken - Hetzner API token
96
+ * @returns {Promise<boolean>} true if live data was loaded, false on failure (fallback used)
97
+ */
98
+ static async fetchServerTypes(apiToken) {
99
+ if (HetznerProvider._locationTypes) return true; // already fetched
100
+
101
+ try {
102
+ const response = await fetchWithRetry(
103
+ `${HetznerProvider.API_BASE}/server_types?per_page=50`,
104
+ {
105
+ headers: { Authorization: `Bearer ${apiToken}` },
106
+ },
107
+ );
108
+ if (!response.ok) return false;
109
+
110
+ const data = await response.json();
111
+ const types = {};
112
+ const locationTypes = {};
113
+
114
+ for (const t of data.server_types) {
115
+ if (t.deprecation) continue;
116
+
117
+ // Build per-location availability from the `locations` field (actual availability),
118
+ // not `prices` (which includes regions where the type is priced but not placeable).
119
+ for (const loc of t.locations || []) {
120
+ const locName = loc.name || loc;
121
+ if (loc.deprecation) continue;
122
+ if (!locationTypes[locName]) locationTypes[locName] = new Set();
123
+ locationTypes[locName].add(t.name);
124
+ }
125
+
126
+ // Build per-location price map
127
+ const locationPrices = {};
128
+ for (const p of t.prices || []) {
129
+ const loc = p.location;
130
+ if (loc) {
131
+ locationPrices[loc] = `€${parseFloat(p.price_monthly.gross).toFixed(2)}/mo`;
132
+ }
133
+ }
134
+
135
+ // Use first available location price as the default display price
136
+ const firstPrice = t.prices[0];
137
+ const defaultMonthly = firstPrice
138
+ ? `€${parseFloat(firstPrice.price_monthly.gross).toFixed(2)}/mo`
139
+ : '';
140
+
141
+ types[t.name] = {
142
+ vcpu: t.cores,
143
+ ram: t.memory,
144
+ disk: t.disk,
145
+ price: defaultMonthly,
146
+ locationPrices,
147
+ cpuType: t.cpu_type,
148
+ architecture: t.architecture,
149
+ };
150
+ }
151
+
152
+ HetznerProvider.SERVER_TYPES = types;
153
+ HetznerProvider._locationTypes = locationTypes;
154
+ return true;
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Returns region-appropriate default server types.
162
+ *
163
+ * Hetzner's SKU availability is split by geography:
164
+ * - EU (fsn1/nbg1/hel1): `cx*` (Intel shared) + `cax*` (ARM) + `ccx*` (dedicated)
165
+ * - US (ash/hil): `cpx*` (AMD shared) + `cax*` (ARM)
166
+ *
167
+ * We prefer x86 shared-vCPU for cluster nodes (ARM mixes poorly with
168
+ * amd64-only Helm charts like Supabase). Prefer tiers in this order for
169
+ * each role, falling back to the first available:
170
+ * - master/worker: 2 vCPU, 2–4 GB RAM
171
+ * - supabase: 2–4 vCPU, 4+ GB RAM
172
+ */
173
+ static getRegionDefaults(region) {
174
+ const available = HetznerProvider._locationTypes?.[region];
175
+
176
+ // Preference lists — order matters. First available in the region wins.
177
+ const smallShared = ['cpx11', 'cx23', 'cx22']; // ~2 vCPU, 2–4 GB
178
+ const mediumShared = ['cx23', 'cpx21', 'cx33']; // 2–4 vCPU, 4–8 GB
179
+
180
+ const pickAvailable = (prefs, fallback) => {
181
+ if (available) {
182
+ for (const t of prefs) if (available.has(t)) return t;
183
+ }
184
+ return fallback;
185
+ };
186
+
187
+ if (available) {
188
+ return {
189
+ masterType: pickAvailable(smallShared, 'cpx11'),
190
+ supabaseType: pickAvailable(mediumShared, 'cpx21'),
191
+ workerType: pickAvailable(smallShared, 'cpx11'),
192
+ };
193
+ }
194
+
195
+ // Offline fallback — assume US (cpx available) since EU cx SKUs rotate
196
+ // faster (cx11→cx21→cx22→cx23 in five years) and are more likely to be
197
+ // stale in a hard-coded fallback.
198
+ return { masterType: 'cpx11', supabaseType: 'cpx21', workerType: 'cpx11' };
199
+ }
200
+
201
+ /**
202
+ * Returns the equivalent server type available in a target region.
203
+ * Maps across architectures (e.g. cax11 → cpx11 when ARM unavailable).
204
+ * Returns the original type if it's available, or the best match otherwise.
205
+ */
206
+ static resolveServerTypeForRegion(serverType, targetRegion) {
207
+ const available = HetznerProvider._locationTypes?.[targetRegion];
208
+
209
+ // If live data available, check directly
210
+ if (available) {
211
+ if (available.has(serverType)) return serverType;
212
+
213
+ // Map cax→cpx (ARM→shared x86) or cpx→cax (x86→ARM) by numeric suffix
214
+ const suffix = serverType.replace(/^[a-z]+/, '');
215
+ if (serverType.startsWith('cax')) {
216
+ const alt = `cpx${suffix}`;
217
+ if (available.has(alt)) return alt;
218
+ } else if (serverType.startsWith('cpx')) {
219
+ const alt = `cax${suffix}`;
220
+ if (available.has(alt)) return alt;
221
+ }
222
+
223
+ // Fall back to region defaults at same tier
224
+ const defaults = HetznerProvider.getRegionDefaults(targetRegion);
225
+ if (suffix.startsWith('1')) return defaults.masterType;
226
+ if (suffix.startsWith('2')) return defaults.supabaseType;
227
+ return defaults.workerType;
228
+ }
229
+
230
+ // Offline fallback: EU regions have ARM, US regions don't.
231
+ // Only map to types that exist in the fallback catalog — blindly swapping
232
+ // the prefix can produce invalid types (e.g. cpx22 → cax22 which doesn't exist).
233
+ if (HetznerProvider.US_REGIONS.includes(targetRegion) && serverType.startsWith('cax')) {
234
+ const alt = `cpx${serverType.replace(/^cax/, '')}`;
235
+ if (HetznerProvider.FALLBACK_SERVER_TYPES[alt]) return alt;
236
+ }
237
+ if (HetznerProvider.EU_REGIONS.includes(targetRegion) && serverType.startsWith('cpx')) {
238
+ const alt = `cax${serverType.replace(/^cpx/, '')}`;
239
+ if (HetznerProvider.FALLBACK_SERVER_TYPES[alt]) return alt;
240
+ }
241
+ return serverType;
242
+ }
243
+
244
+ /**
245
+ * Returns server types available in the given region.
246
+ * Uses live per-location data when available, otherwise falls back to prefix filtering.
247
+ */
248
+ static getServerTypesForRegion(region) {
249
+ const available = HetznerProvider._locationTypes?.[region];
250
+
251
+ if (available) {
252
+ return [...available]
253
+ .filter((name) => name in HetznerProvider.SERVER_TYPES)
254
+ .map((name) => {
255
+ const spec = HetznerProvider.SERVER_TYPES[name];
256
+ const price = spec.locationPrices?.[region] || spec.price;
257
+ return { name, ...spec, price };
258
+ })
259
+ .sort((a, b) => a.vcpu - b.vcpu || a.ram - b.ram);
260
+ }
261
+
262
+ // Offline fallback — filter by prefix
263
+ const prefixes = HetznerProvider.US_REGIONS.includes(region)
264
+ ? HetznerProvider.REGION_TYPE_PREFIXES.us
265
+ : HetznerProvider.REGION_TYPE_PREFIXES.eu;
266
+ return Object.entries(HetznerProvider.SERVER_TYPES)
267
+ .filter(([name]) => prefixes.some((prefix) => name.startsWith(prefix)))
268
+ .map(([name, info]) => ({ name, ...info }));
269
+ }
270
+
271
+ /**
272
+ * Returns the monthly price for a server type in a specific location.
273
+ * Only available after fetchServerTypes() — returns null otherwise.
274
+ */
275
+ static getPrice(serverType, location) {
276
+ const spec = HetznerProvider.SERVER_TYPES[serverType];
277
+ if (!spec) return null;
278
+ return (location && spec.locationPrices?.[location]) || spec.price || null;
279
+ }
280
+
281
+ /**
282
+ * Create a new Hetzner server
283
+ * @param {object} config - Server configuration
284
+ * @returns {Promise<{id: number, server: object}>}
285
+ */
286
+ async createServer(config) {
287
+ const {
288
+ name,
289
+ serverType,
290
+ region,
291
+ sshKeyId,
292
+ sshKeys,
293
+ environment,
294
+ image,
295
+ firewalls,
296
+ labels,
297
+ userData,
298
+ } = config;
299
+
300
+ const body = {
301
+ name,
302
+ server_type: serverType,
303
+ location: region,
304
+ image: image || 'docker-ce',
305
+ ssh_keys: sshKeys || (sshKeyId ? [sshKeyId] : []),
306
+ public_net: {
307
+ enable_ipv4: true,
308
+ enable_ipv6: true,
309
+ },
310
+ labels: labels || {
311
+ 'managed-by': 'vibecarbon',
312
+ environment: environment || 'default',
313
+ },
314
+ };
315
+ if (firewalls?.length) {
316
+ body.firewalls = firewalls.map((id) => ({ firewall: id }));
317
+ }
318
+ // user_data runs at boot time via cloud-init. Hetzner accepts either a
319
+ // raw shell script or a #cloud-config YAML document up to 32KB. We pass
320
+ // whatever string the caller supplies; compose deploy uses a cloud-config
321
+ // YAML that front-loads ufw + unattended-upgrades installs so setupServer
322
+ // can shrink to a marker-file probe.
323
+ if (userData) {
324
+ body.user_data = userData;
325
+ }
326
+
327
+ // Retry on transient resource_limit_exceeded — observed when several
328
+ // scenarios concurrently provision in a quota-tight project (e.g.
329
+ // matrix-mode e2e + W1.5a parallel compose-ha scale doubled
330
+ // the in-flight Primary IP allocations and tripped Hetzner's project
331
+ // quota at iter-reliab 2026-05-01T14:13Z). Old servers being torn
332
+ // down free their Primary IPs within 30-90s, so backing off and
333
+ // retrying tends to succeed without operator intervention. If the
334
+ // quota is genuinely too low for the workload, we still surface the
335
+ // error after exhausting attempts.
336
+ const RESOURCE_LIMIT_DELAYS_MS = [15_000, 45_000, 90_000];
337
+ let response;
338
+ let attemptError = null;
339
+ for (let attempt = 0; attempt <= RESOURCE_LIMIT_DELAYS_MS.length; attempt++) {
340
+ response = await this.apiRequest('/servers', {
341
+ method: 'POST',
342
+ body: JSON.stringify(body),
343
+ });
344
+ if (response.ok) break;
345
+ // Body is read at most once per attempt; reuse `attemptError` rather
346
+ // than `response.clone()` so the WHATWG fetch shim and the test mock
347
+ // (which doesn't implement clone()) both work.
348
+ attemptError = await response.json();
349
+ const peekCode = attemptError.error?.code || '';
350
+ const peekMessage = attemptError.error?.message || '';
351
+ const isResourceLimit =
352
+ peekCode === 'resource_limit_exceeded' || /resource_limit_exceeded/i.test(peekMessage);
353
+ if (!isResourceLimit || attempt === RESOURCE_LIMIT_DELAYS_MS.length) {
354
+ break;
355
+ }
356
+ // Log to stderr so operator (or post-run analysis) can see we're
357
+ // queueing rather than dying on first quota bump.
358
+ console.warn(
359
+ `[hetzner] createServer(${name}): quota busy (${peekMessage}); ` +
360
+ `retrying in ${RESOURCE_LIMIT_DELAYS_MS[attempt] / 1000}s ` +
361
+ `(attempt ${attempt + 1}/${RESOURCE_LIMIT_DELAYS_MS.length + 1})`,
362
+ );
363
+ await new Promise((r) => setTimeout(r, RESOURCE_LIMIT_DELAYS_MS[attempt]));
364
+ }
365
+
366
+ if (!response.ok) {
367
+ const error = attemptError ?? { error: { message: 'Unknown error', code: 'unknown' } };
368
+ const message = error.error?.message || 'Unknown error';
369
+ const code = error.error?.code || 'unknown';
370
+
371
+ let hint = '';
372
+ if (code === 'invalid_input' && message.includes('unsupported location')) {
373
+ hint = `\nHint: Server type "${serverType}" is not available in region "${region}". Try a different server type or region.`;
374
+ } else if (code === 'uniqueness_error') {
375
+ // Recover: look up the existing server by name and return it
376
+ const listResponse = await this.apiRequest(`/servers?name=${encodeURIComponent(name)}`);
377
+ if (listResponse.ok) {
378
+ const listData = await listResponse.json();
379
+ const existing = listData.servers?.find((s) => s.name === name);
380
+ if (existing) {
381
+ return { id: existing.id, server: existing, reused: true };
382
+ }
383
+ }
384
+ hint = `\nHint: A server with this name already exists. Use --destroy first or choose a different environment name.`;
385
+ } else if (code === 'unauthorized') {
386
+ hint = '\nHint: Check that your API token is valid and has Read & Write permissions.';
387
+ } else if (code === 'resource_limit_exceeded') {
388
+ hint =
389
+ '\nHint: Project quota exhausted even after retries. Either wait for in-flight tear-downs to free resources, or raise the project quota in the Hetzner Console (Resources → Limits).';
390
+ }
391
+
392
+ throw new Error(`Hetzner API error: ${message} (${code})${hint}`);
393
+ }
394
+
395
+ const data = await response.json();
396
+ return { id: data.server.id, server: data.server };
397
+ }
398
+
399
+ /**
400
+ * Delete a server
401
+ * @param {number} serverId - Server ID
402
+ */
403
+ async deleteServer(serverId) {
404
+ const response = await this.apiRequest(`/servers/${serverId}`, {
405
+ method: 'DELETE',
406
+ });
407
+
408
+ if (!response.ok && response.status !== 404) {
409
+ const error = await response.json();
410
+ throw new Error(`Failed to delete server: ${error.error?.message || 'Unknown error'}`);
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Get server details
416
+ * @param {number} serverId - Server ID
417
+ * @returns {Promise<object>}
418
+ */
419
+ async getServer(serverId) {
420
+ const response = await this.apiRequest(`/servers/${serverId}`);
421
+
422
+ if (!response.ok) {
423
+ const error = await response.json();
424
+ throw new Error(`Failed to get server: ${error.error?.message || 'Unknown error'}`);
425
+ }
426
+
427
+ const data = await response.json();
428
+ return data.server;
429
+ }
430
+
431
+ /**
432
+ * Wait for a server to become ready
433
+ * @param {number} serverId - Server ID
434
+ * @param {number} [timeout=300000] - Timeout in ms (default 5 minutes)
435
+ * @returns {Promise<object>}
436
+ */
437
+ async waitForServer(serverId, timeout = 300000) {
438
+ const startTime = Date.now();
439
+ const pollInterval = 5000;
440
+
441
+ while (Date.now() - startTime < timeout) {
442
+ try {
443
+ const response = await this.apiRequest(`/servers/${serverId}`);
444
+ const { server } = await response.json();
445
+
446
+ if (server.status === 'running') {
447
+ return server;
448
+ }
449
+ } catch (error) {
450
+ // Network errors - continue retrying
451
+ if (Date.now() - startTime >= timeout - pollInterval) {
452
+ throw new Error(`Failed to check server status: ${error.message}`);
453
+ }
454
+ }
455
+
456
+ await new Promise((r) => setTimeout(r, pollInterval));
457
+ }
458
+
459
+ throw new Error('Server creation timed out');
460
+ }
461
+
462
+ /**
463
+ * Create or update an SSH key
464
+ * @param {string} name - SSH key name
465
+ * @param {string} publicKey - Public key content
466
+ * @returns {Promise<number>} SSH key ID
467
+ */
468
+ async createSSHKey(name, publicKey) {
469
+ // Hetzner paginates /ssh_keys at 25 entries/page by default. Long-lived
470
+ // Hetzner projects accumulate dozens of keys; with the default pagination
471
+ // the dedup scan below would silently miss keys on pages 2+, and we'd hit
472
+ // the very 409/"not unique" error the dedup is meant to prevent. Ask for
473
+ // the max allowed page size (50) and still walk pagination defensively.
474
+ const ssh_keys = [];
475
+ let page = 1;
476
+ for (let guard = 0; guard < 20; guard++) {
477
+ const resp = await this.apiRequest(`/ssh_keys?per_page=50&page=${page}`);
478
+ const body = await resp.json();
479
+ if (Array.isArray(body.ssh_keys)) ssh_keys.push(...body.ssh_keys);
480
+ const next = body.meta?.pagination?.next_page;
481
+ if (!next) break;
482
+ page = next;
483
+ }
484
+
485
+ const newKeyPart = publicKey.split(' ').slice(0, 2).join(' ');
486
+
487
+ // 1. If a key with the same name already exists, prefer it (by-name lookup).
488
+ const existingByName = ssh_keys.find((k) => k.name === name);
489
+ if (existingByName) {
490
+ const existingKeyPart = existingByName.public_key.split(' ').slice(0, 2).join(' ');
491
+ if (existingKeyPart === newKeyPart) {
492
+ return existingByName.id;
493
+ }
494
+ // Same name but different key — delete so we can recreate.
495
+ await this.apiRequest(`/ssh_keys/${existingByName.id}`, { method: 'DELETE' });
496
+ }
497
+
498
+ // 2. Hetzner also rejects `POST /ssh_keys` with 409/"not unique" if the
499
+ // public-key bytes are already registered under a DIFFERENT name (common
500
+ // in e2e test reruns that share a single dev key pair). Scan the
501
+ // list a second time by key content and reuse if found.
502
+ const existingByKey = ssh_keys.find((k) => {
503
+ const kPart = k.public_key.split(' ').slice(0, 2).join(' ');
504
+ return kPart === newKeyPart;
505
+ });
506
+ if (existingByKey) {
507
+ return existingByKey.id;
508
+ }
509
+
510
+ // 3. Truly new — create it. Always tag with `managed-by=vibecarbon` so
511
+ // post-run sweeps that filter by label_selector reap it on cleanup.
512
+ // Without the label, leaked keys only show up in the name-prefix
513
+ // preflight scan and silently block the next e2e run.
514
+ const createResponse = await this.apiRequest('/ssh_keys', {
515
+ method: 'POST',
516
+ body: JSON.stringify({
517
+ name,
518
+ public_key: publicKey,
519
+ labels: { 'managed-by': 'vibecarbon' },
520
+ }),
521
+ });
522
+
523
+ if (!createResponse.ok) {
524
+ const error = await createResponse.json();
525
+ throw new Error(`Failed to create SSH key: ${error.error?.message || 'Unknown error'}`);
526
+ }
527
+
528
+ const { ssh_key } = await createResponse.json();
529
+ return ssh_key.id;
530
+ }
531
+
532
+ /**
533
+ * Delete an SSH key
534
+ * @param {number} keyId - SSH key ID
535
+ */
536
+ async deleteSSHKey(keyId) {
537
+ const response = await this.apiRequest(`/ssh_keys/${keyId}`, {
538
+ method: 'DELETE',
539
+ });
540
+
541
+ if (!response.ok && response.status !== 404) {
542
+ const error = await response.json();
543
+ throw new Error(`Failed to delete SSH key: ${error.error?.message || 'Unknown error'}`);
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Create a firewall and attach to server
549
+ * @param {string} name - Firewall name
550
+ * @param {number} serverId - Server ID
551
+ * @param {number[]} [ports=[22, 80, 443, 3000]] - Ports to open
552
+ * @returns {Promise<object>}
553
+ */
554
+ async createFirewall(name, serverId, ports = [22, 80, 443, 3000]) {
555
+ // Reuse existing firewall with the same name
556
+ const allFirewalls = await this.listFirewalls();
557
+ const existing = allFirewalls.find((fw) => fw.name === name);
558
+ if (existing) {
559
+ return { firewall: existing };
560
+ }
561
+
562
+ const rules = ports.map((port) => ({
563
+ direction: 'in',
564
+ protocol: 'tcp',
565
+ port: String(port),
566
+ source_ips: ['0.0.0.0/0', '::/0'],
567
+ }));
568
+
569
+ const body = { name, rules };
570
+ if (serverId) {
571
+ body.apply_to = [{ type: 'server', server: { id: serverId } }];
572
+ }
573
+
574
+ const response = await this.apiRequest('/firewalls', {
575
+ method: 'POST',
576
+ body: JSON.stringify(body),
577
+ });
578
+
579
+ if (!response.ok) {
580
+ const error = await response.json();
581
+ throw new Error(`Failed to create firewall: ${error.error?.message || 'Unknown error'}`);
582
+ }
583
+
584
+ return response.json();
585
+ }
586
+
587
+ /**
588
+ * Delete a firewall
589
+ * @param {number} firewallId - Firewall ID
590
+ */
591
+ async deleteFirewall(firewallId) {
592
+ const response = await this.apiRequest(`/firewalls/${firewallId}`, {
593
+ method: 'DELETE',
594
+ });
595
+
596
+ if (!response.ok && response.status !== 404) {
597
+ const error = await response.json();
598
+ throw new Error(`Failed to delete firewall: ${error.error?.message || 'Unknown error'}`);
599
+ }
600
+ }
601
+
602
+ /**
603
+ * List servers with optional label filter
604
+ * @param {object} labels - Labels to filter by
605
+ * @returns {Promise<object[]>}
606
+ */
607
+ async listServers(labels = {}) {
608
+ let url = '/servers';
609
+
610
+ // Build label selector query
611
+ if (Object.keys(labels).length > 0) {
612
+ const selector = Object.entries(labels)
613
+ .map(([k, v]) => `${k}=${v}`)
614
+ .join(',');
615
+ url += `?label_selector=${encodeURIComponent(selector)}`;
616
+ }
617
+
618
+ const response = await this.apiRequest(url);
619
+ const data = await response.json();
620
+ return data.servers || [];
621
+ }
622
+
623
+ /**
624
+ * List firewalls with optional label filter
625
+ * @param {object} labels - Labels to filter by
626
+ * @returns {Promise<object[]>}
627
+ */
628
+ async listFirewalls(labels = {}) {
629
+ let url = '/firewalls';
630
+
631
+ if (Object.keys(labels).length > 0) {
632
+ const selector = Object.entries(labels)
633
+ .map(([k, v]) => `${k}=${v}`)
634
+ .join(',');
635
+ url += `?label_selector=${encodeURIComponent(selector)}`;
636
+ }
637
+
638
+ const response = await this.apiRequest(url);
639
+ const data = await response.json();
640
+ return data.firewalls || [];
641
+ }
642
+
643
+ /**
644
+ * List all SSH keys
645
+ * @returns {Promise<object[]>}
646
+ */
647
+ async listSSHKeys() {
648
+ const response = await this.apiRequest('/ssh_keys');
649
+ const data = await response.json();
650
+ return data.ssh_keys || [];
651
+ }
652
+
653
+ /**
654
+ * Power off a server
655
+ * @param {number} serverId - Server ID
656
+ * @returns {Promise<object>} Action object
657
+ */
658
+ async poweroffServer(serverId) {
659
+ const response = await this.apiRequest(`/servers/${serverId}/actions/poweroff`, {
660
+ method: 'POST',
661
+ });
662
+
663
+ if (!response.ok) {
664
+ const error = await response.json();
665
+ throw new Error(`Failed to power off server: ${error.error?.message || 'Unknown error'}`);
666
+ }
667
+
668
+ const data = await response.json();
669
+ return data.action;
670
+ }
671
+
672
+ /**
673
+ * Power on a server
674
+ * @param {number} serverId - Server ID
675
+ * @returns {Promise<object>} Action object
676
+ */
677
+ async poweronServer(serverId) {
678
+ const response = await this.apiRequest(`/servers/${serverId}/actions/poweron`, {
679
+ method: 'POST',
680
+ });
681
+
682
+ if (!response.ok) {
683
+ const error = await response.json();
684
+ throw new Error(`Failed to power on server: ${error.error?.message || 'Unknown error'}`);
685
+ }
686
+
687
+ const data = await response.json();
688
+ return data.action;
689
+ }
690
+
691
+ /**
692
+ * Change the type of a server (server must be powered off)
693
+ * @param {number} serverId - Server ID
694
+ * @param {string} serverType - New server type name (e.g. 'cpx31')
695
+ * @param {boolean} [upgradeDisk=true] - Whether to upgrade disk size (irreversible)
696
+ * @returns {Promise<object>} Action object
697
+ */
698
+ async changeServerType(serverId, serverType, upgradeDisk = true) {
699
+ const response = await this.apiRequest(`/servers/${serverId}/actions/change_type`, {
700
+ method: 'POST',
701
+ body: JSON.stringify({ server_type: serverType, upgrade_disk: upgradeDisk }),
702
+ });
703
+
704
+ if (!response.ok) {
705
+ const error = await response.json();
706
+ throw new Error(`Failed to change server type: ${error.error?.message || 'Unknown error'}`);
707
+ }
708
+
709
+ const data = await response.json();
710
+ return data.action;
711
+ }
712
+
713
+ /**
714
+ * Wait for an action to complete
715
+ * @param {number} actionId - Action ID
716
+ * @param {number} [timeout=300000] - Timeout in ms (default 5 minutes)
717
+ * @returns {Promise<object>} Completed action object
718
+ */
719
+ async waitForAction(actionId, timeout = 300000) {
720
+ const startTime = Date.now();
721
+ const pollInterval = 5000;
722
+
723
+ while (Date.now() - startTime < timeout) {
724
+ const response = await this.apiRequest(`/actions/${actionId}`);
725
+ const { action } = await response.json();
726
+
727
+ if (action.status === 'success') return action;
728
+ if (action.status === 'error') {
729
+ throw new Error(`Action failed: ${action.error?.message || 'Unknown error'}`);
730
+ }
731
+
732
+ await new Promise((r) => setTimeout(r, pollInterval));
733
+ }
734
+
735
+ throw new Error('Action timed out');
736
+ }
737
+
738
+ /**
739
+ * Get public IPv4 address of a server
740
+ * @param {object} server - Server object
741
+ * @returns {string|null}
742
+ */
743
+ static getPublicIP(server) {
744
+ return server?.public_net?.ipv4?.ip || null;
745
+ }
746
+
747
+ /**
748
+ * Get public IPv6 address of a server
749
+ * @param {object} server - Server object
750
+ * @returns {string|null}
751
+ */
752
+ static getPublicIPv6(server) {
753
+ return server?.public_net?.ipv6?.ip || null;
754
+ }
755
+ }