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
package/src/backup.js ADDED
@@ -0,0 +1,786 @@
1
+ /**
2
+ * Vibecarbon Backup Command
3
+ *
4
+ * Interactive-by-default. Bare `vibecarbon backup` walks the operator
5
+ * through env → action prompts; positional and flag seeds skip the
6
+ * corresponding prompt. Off-TTY usage requires explicit flags up front
7
+ * (the TTY guard names exactly which ones).
8
+ *
9
+ * Form rule: vibecarbon uses single-dash flags only — see
10
+ * memory:feedback_cli_single_dash_flags.
11
+ */
12
+
13
+ import { existsSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import * as p from '@clack/prompts';
16
+ import { downloadS3Backup, listS3Backups } from './lib/backup-s3.js';
17
+ import { renderHelp } from './lib/cli/help.js';
18
+ import { parseFlags } from './lib/cli/parse-flags.js';
19
+ import { selectAction } from './lib/cli/select-action.js';
20
+ import { selectEnvironment } from './lib/cli/select-environment.js';
21
+ import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
22
+ import { c, printBanner } from './lib/colors.js';
23
+ import {
24
+ loadBackupS3Config,
25
+ loadCredentials,
26
+ loadProjectConfig,
27
+ loadS3Config,
28
+ } from './lib/config.js';
29
+ import { getS3Credentials } from './lib/hetzner-guided-setup.js';
30
+ import { requireLicense } from './lib/licensing/index.js';
31
+ import { ensureOperatorIpAccess } from './lib/operator-ip.js';
32
+ import { perfAsync } from './lib/perf.js';
33
+ import { assertInProjectDir } from './lib/project-guard.js';
34
+ import {
35
+ getPostgresPod,
36
+ getSSHKeyPath,
37
+ scpDownload,
38
+ sshKubectl,
39
+ sshRun,
40
+ sshRunScript,
41
+ } from './lib/ssh.js';
42
+ import { createTracker } from './lib/tracker.js';
43
+ import { validateBackupFilename } from './lib/validators.js';
44
+ import { VERSION } from './lib/version.js';
45
+
46
+ // ============================================================================
47
+ // COMMAND SPEC — single source of truth for argv parsing AND help output.
48
+ // ============================================================================
49
+
50
+ /** @type {import('./lib/cli/parse-flags.js').CommandSpec & { summary?: string, examples?: Array<{ command: string, description?: string }> }} */
51
+ const SPEC = {
52
+ name: 'backup',
53
+ summary: 'Create or manage database backups',
54
+ positional: [
55
+ {
56
+ name: 'env',
57
+ optional: true,
58
+ description: 'Environment to act on (skips the env prompt)',
59
+ },
60
+ ],
61
+ flags: [
62
+ { name: 'h', boolean: true, description: 'Show this help' },
63
+ { name: 'v', boolean: true, description: 'Show version' },
64
+ { name: 'y', boolean: true, description: 'Skip confirmation prompts' },
65
+ {
66
+ name: 'l',
67
+ boolean: true,
68
+ description: 'List backups (read-only; equivalent to -action list)',
69
+ },
70
+ { name: 'env', value: '<name>', description: 'Environment seed (alternative to positional)' },
71
+ {
72
+ name: 'action',
73
+ value: '<verb>',
74
+ enum: ['create', 'list', 'download'],
75
+ description: 'Action seed (skips the action prompt)',
76
+ },
77
+ {
78
+ name: 'source',
79
+ value: '<file>',
80
+ description: 'Backup filename (required for non-interactive download)',
81
+ },
82
+ ],
83
+ examples: [
84
+ { command: 'vibecarbon backup', description: 'prompts for env and action' },
85
+ { command: 'vibecarbon backup prod -l', description: 'list backups for prod' },
86
+ { command: 'vibecarbon backup prod', description: 'env seeded; prompts for action' },
87
+ {
88
+ command: 'vibecarbon backup -env prod -action download -source myapp_20260507.tar.gz',
89
+ description: 'scripted download',
90
+ },
91
+ ],
92
+ };
93
+
94
+ const ACTION_CHOICES = [
95
+ { value: 'create', label: 'Create a new backup', hint: 'triggers a fresh dump' },
96
+ { value: 'list', label: 'List existing backups', hint: 'read-only' },
97
+ { value: 'download', label: 'Download a backup', hint: 'fetch a file locally' },
98
+ ];
99
+
100
+ // ============================================================================
101
+ // LEGACY POD HELPERS (used when a k8s env has no S3 configured)
102
+ // ============================================================================
103
+
104
+ async function listPodBackups(ip, sshKeyPath) {
105
+ const pod = getPostgresPod(ip, sshKeyPath);
106
+
107
+ try {
108
+ const output = sshKubectl(ip, sshKeyPath, [
109
+ 'exec',
110
+ '-n',
111
+ 'vibecarbon',
112
+ pod,
113
+ '--',
114
+ 'sh',
115
+ '-c',
116
+ 'ls -lhS /backups/*_full.tar.gz /backups/*.sql.gz 2>/dev/null || echo NO_BACKUPS',
117
+ ]);
118
+
119
+ if (output === 'NO_BACKUPS' || !output) {
120
+ return [];
121
+ }
122
+
123
+ return output
124
+ .split('\n')
125
+ .filter((line) => line.includes('.tar.gz') || line.includes('.sql.gz'))
126
+ .map((line) => {
127
+ const parts = line.split(/\s+/);
128
+ return {
129
+ size: parts[4],
130
+ date: `${parts[5]} ${parts[6]} ${parts[7]}`,
131
+ name: parts[parts.length - 1].replace('/backups/', ''),
132
+ };
133
+ });
134
+ } catch {
135
+ return [];
136
+ }
137
+ }
138
+
139
+ async function downloadPodBackup(ip, sshKeyPath, filename) {
140
+ const pod = getPostgresPod(ip, sshKeyPath);
141
+
142
+ // Copy from pod to host
143
+ sshKubectl(ip, sshKeyPath, ['cp', `vibecarbon/${pod}:/backups/${filename}`, `/tmp/${filename}`]);
144
+
145
+ // SCP from host to local
146
+ const localPath = join(process.cwd(), filename);
147
+ scpDownload(ip, sshKeyPath, `/tmp/${filename}`, localPath);
148
+
149
+ // Clean up remote temp file
150
+ sshRun(ip, sshKeyPath, ['rm', '-f', `/tmp/${filename}`]);
151
+
152
+ return localPath;
153
+ }
154
+
155
+ // ============================================================================
156
+ // K8S JOB BACKUP (triggers CronJob-based backup)
157
+ // ============================================================================
158
+
159
+ function triggerBackupJob(ip, sshKeyPath) {
160
+ const timestamp = new Date()
161
+ .toISOString()
162
+ .replace(/[:.]/g, '-')
163
+ .replace('T', '-')
164
+ .slice(0, 19)
165
+ .toLowerCase();
166
+ const jobName = `backup-manual-${timestamp}`;
167
+
168
+ // Create a one-off Job from the CronJob template
169
+ sshKubectl(ip, sshKeyPath, [
170
+ 'create',
171
+ 'job',
172
+ `--from=cronjob/backup`,
173
+ jobName,
174
+ '-n',
175
+ 'vibecarbon',
176
+ ]);
177
+
178
+ // Poll the Job's conditions every 5s for either Complete=True or
179
+ // Failed=True. We avoid `kubectl wait --for=condition=Complete
180
+ // --timeout=300s` because the wait can only watch ONE condition, so on
181
+ // a CrashLoopBackOff that ends in Failed it would burn the FULL 5 min
182
+ // before timing out. By that point kubelet had GC'd the failed Pod
183
+ // (observed 2026-04-28 e2e — the Pod was visible in events but
184
+ // kubectl get returned nothing, so the diagnostic captured no logs).
185
+ // Polling for both conditions returns within ~5s of Failed, while
186
+ // the Pod is still alive and its logs are still kubectl-reachable.
187
+ // CRITICAL: capture pod logs and describe IN-LINE the moment Failed=True
188
+ // is detected. Doing this in a follow-up SSH session race-loses to Pod GC
189
+ // (observed iter-reliab2 2026-05-01: Job condition Failed but follow-up
190
+ // diagnostic returned "No pod matching" because controller-manager already
191
+ // GC'd the failed Pod within the ~1s window between SSH sessions).
192
+ try {
193
+ sshRunScript(
194
+ ip,
195
+ sshKeyPath,
196
+ `export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
197
+ DEADLINE=$(($(date +%s) + 300))
198
+ RESULT="timeout"
199
+ while [ $(date +%s) -lt $DEADLINE ]; do
200
+ CONDS=$(kubectl get job ${jobName} -n vibecarbon -o jsonpath='{range .status.conditions[*]}{.type}={.status};{end}' 2>/dev/null || echo "")
201
+ case "$CONDS" in
202
+ *"Complete=True"*) RESULT="complete"; break ;;
203
+ *"Failed=True"*) RESULT="failed"; break ;;
204
+ esac
205
+ sleep 5
206
+ done
207
+ echo "JOB_RESULT=$RESULT"
208
+ if [ "$RESULT" = "complete" ]; then
209
+ exit 0
210
+ fi
211
+ # Capture diagnostics WHILE pod is still alive — controller GCs failed
212
+ # Job pods quickly after the Failed condition fires.
213
+ echo ""
214
+ echo "=== INLINE FAILURE DIAGNOSTICS (captured before pod GC) ==="
215
+ echo "=== Job status ==="
216
+ kubectl get job ${jobName} -n vibecarbon -o yaml 2>&1 | grep -E "^ (active|conditions|failed|ready|succeeded|terminating)|^ " | head -30
217
+ echo ""
218
+ echo "=== All pods in namespace ==="
219
+ kubectl get pods -n vibecarbon -o wide 2>&1
220
+ echo ""
221
+ echo "=== Pod events (matching ${jobName} or backup-manual) ==="
222
+ kubectl get events -n vibecarbon --field-selector involvedObject.kind=Pod --sort-by=.lastTimestamp 2>&1 | grep -E "${jobName}|backup-manual" | tail -30
223
+ echo ""
224
+ PODS=$(kubectl get pods -n vibecarbon -o name 2>/dev/null | grep "${jobName}" || true)
225
+ if [ -z "\${PODS}" ]; then
226
+ echo "=== No pod matching ${jobName} found — already GC'd ==="
227
+ else
228
+ for pod in \${PODS}; do
229
+ echo "=== \${pod}: container statuses (exit code disambiguates failure) ==="
230
+ kubectl get \${pod} -n vibecarbon -o jsonpath='restartCount={.status.containerStatuses[*].restartCount}{"\\n"}lastTerminated={.status.containerStatuses[*].lastState.terminated}{"\\n"}currentState={.status.containerStatuses[*].state}{"\\n"}' 2>&1
231
+ echo ""
232
+ echo "=== \${pod}: kubectl describe ==="
233
+ kubectl describe \${pod} -n vibecarbon 2>&1 | tail -60
234
+ echo "=== \${pod}: current container logs ==="
235
+ kubectl logs \${pod} -n vibecarbon --tail=200 2>&1 || true
236
+ echo "=== \${pod}: --previous logs (the crashed-container output) ==="
237
+ kubectl logs \${pod} -n vibecarbon --previous --tail=200 2>&1 || true
238
+ done
239
+ fi
240
+ exit 1`,
241
+ { timeout: 420_000 },
242
+ );
243
+ } catch (waitErr) {
244
+ // Dump pod status, events, AND logs to help diagnose why the job didn't
245
+ // complete. An earlier version of this block gated the --previous log
246
+ // capture on a label-selector query that mysteriously returned "No
247
+ // resources found" while the same pod was visible by name in events
248
+ // (observed 2026-04-28 k8s e2e). Now we find pods by NAME
249
+ // pattern instead — Job-managed pods are guaranteed to be named
250
+ // `<jobName>-<5char>` so a `grep ${jobName}` over the unfiltered pod
251
+ // list always works. We also capture exit codes via jsonpath (works
252
+ // even when both current+previous logs are empty — exit code 126/127/
253
+ // SIGKILL alone disambiguates "image bad / OOMKilled / segfault") and
254
+ // run kubectl describe for runtime context that bare logs miss.
255
+ let podDiag = '';
256
+ try {
257
+ podDiag = sshRunScript(
258
+ ip,
259
+ sshKeyPath,
260
+ `export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
261
+ echo "=== CronJob image ==="
262
+ kubectl get cronjob backup -n vibecarbon -o jsonpath="{.spec.jobTemplate.spec.template.spec.containers[0].image}" 2>&1
263
+ echo ""
264
+ echo "=== Job status ==="
265
+ kubectl get job ${jobName} -n vibecarbon -o yaml 2>&1 | grep -E "^ (active|conditions|failed|ready|succeeded|terminating)|^ " | head -30
266
+ echo "=== All pods in namespace (no label filter) ==="
267
+ kubectl get pods -n vibecarbon -o wide 2>&1
268
+ echo "=== Pod events (last 30) ==="
269
+ kubectl get events -n vibecarbon --field-selector involvedObject.kind=Pod --sort-by=.lastTimestamp 2>&1 | grep -E "${jobName}|backup-manual" | tail -30
270
+ # Find pods by NAME pattern (not label) — labels on freshly-failed Job pods
271
+ # can return empty even when the pod exists; name pattern matches because
272
+ # Job pods are deterministically named <jobName>-<5char>.
273
+ PODS=$(kubectl get pods -n vibecarbon -o name 2>/dev/null | grep "${jobName}" || true)
274
+ if [ -z "\${PODS}" ]; then
275
+ echo "=== No pod matching ${jobName} found — already GC'd? ==="
276
+ else
277
+ for pod in \${PODS}; do
278
+ echo "=== \${pod}: container statuses (exit code disambiguates the failure) ==="
279
+ kubectl get \${pod} -n vibecarbon -o jsonpath='restartCount={.status.containerStatuses[*].restartCount}{"\\n"}lastTerminated={.status.containerStatuses[*].lastState.terminated}{"\\n"}currentState={.status.containerStatuses[*].state}{"\\n"}' 2>&1
280
+ echo ""
281
+ echo "=== \${pod}: kubectl describe ==="
282
+ kubectl describe \${pod} -n vibecarbon 2>&1 | tail -50
283
+ echo "=== \${pod}: current container logs ==="
284
+ kubectl logs \${pod} -n vibecarbon --tail=100 2>&1 || true
285
+ echo "=== \${pod}: --previous logs (the crashed-container output) ==="
286
+ kubectl logs \${pod} -n vibecarbon --previous --tail=100 2>&1 || true
287
+ done
288
+ fi`,
289
+ { timeout: 90_000 },
290
+ );
291
+ } catch {
292
+ podDiag = '(diagnostic SSH failed)';
293
+ }
294
+ throw new Error(
295
+ `Backup job ${jobName} did not complete:\n${podDiag}\n\nkubectl wait error: ${waitErr.message}`,
296
+ );
297
+ }
298
+
299
+ return jobName;
300
+ }
301
+
302
+ // ============================================================================
303
+ // MAIN
304
+ // ============================================================================
305
+
306
+ export async function run(args) {
307
+ const { values, positional, errors } = parseFlags(args, SPEC);
308
+
309
+ if (errors.length > 0) {
310
+ for (const e of errors) {
311
+ process.stderr.write(`${c.error('✗')} ${e}\n`);
312
+ }
313
+ process.stderr.write(`Run ${c.info('vibecarbon backup -h')} for usage.\n`);
314
+ process.exit(1);
315
+ }
316
+
317
+ if (values.h) {
318
+ process.stdout.write(renderHelp(SPEC));
319
+ return;
320
+ }
321
+ if (values.v) {
322
+ console.log(`vibecarbon v${VERSION}`);
323
+ return;
324
+ }
325
+
326
+ // Validate -source format up front — all downstream code trusts it.
327
+ if (values.source) {
328
+ const err = validateBackupFilename(/** @type {string} */ (values.source));
329
+ if (err) {
330
+ process.stderr.write(`${c.error('✗')} Invalid -source: ${err}\n`);
331
+ process.exit(1);
332
+ }
333
+ }
334
+
335
+ // Project guard runs before banner so an accidental `vibecarbon
336
+ // backup` from a parent directory emits the canonical message.
337
+ assertInProjectDir();
338
+
339
+ requireLicense('backup');
340
+ printBanner();
341
+ p.intro(`${c.bold('vibecarbon backup')} ${c.dim(`v${VERSION}`)}`);
342
+
343
+ const projectConfig = loadProjectConfig();
344
+ const envCount = Object.keys(projectConfig.environments || {}).length;
345
+ // Positional wins over flag when both are supplied — bare-form is the
346
+ // canonical surface, the flag is the scripting alternative.
347
+ const envSeed =
348
+ /** @type {string|undefined} */ (positional.env) ||
349
+ /** @type {string|null} */ (values.env) ||
350
+ null;
351
+ // -l is shorthand for -action list; if both supplied, -l wins.
352
+ const actionSeed = values.l ? 'list' : /** @type {string|null} */ (values.action);
353
+
354
+ // TTY guard for the env + action prompts. Action-specific prompts
355
+ // (confirm-on-create, source-on-download) get their own gate after
356
+ // the action is known, so the failure message names exactly the
357
+ // flag the operator missed for *this* invocation.
358
+ requireTTYOrFlags({
359
+ requirements: [
360
+ {
361
+ flag: 'env',
362
+ description: 'name an environment to back up',
363
+ satisfied: !!envSeed || envCount <= 1,
364
+ },
365
+ {
366
+ flag: 'action',
367
+ description: 'choose an action (create, list, download — or pass -l for list)',
368
+ satisfied: !!actionSeed,
369
+ },
370
+ ],
371
+ });
372
+
373
+ const { envName, envConfig } = await selectEnvironment(projectConfig, {
374
+ actionVerb: 'back up',
375
+ seed: envSeed,
376
+ });
377
+
378
+ const serverIp = envConfig.servers?.[0]?.ip;
379
+ if (!serverIp) {
380
+ p.log.error(`No server IP found for environment '${envName}'.`);
381
+ process.exit(1);
382
+ }
383
+
384
+ const action = await selectAction({
385
+ message: `What do you want to do for ${c.bold(envName)}?`,
386
+ choices: ACTION_CHOICES,
387
+ seed: actionSeed,
388
+ });
389
+
390
+ // Operator-IP whitelist refresh happens before any command that
391
+ // touches the live cluster. Listing is read-only via S3 only when
392
+ // S3 is configured; if we'd fall back to pod-listing it still hits
393
+ // the cluster. Run the firewall check for everything except the
394
+ // S3-only list path (handled inside the dispatch below).
395
+ await ensureFirewallAccessIfNeeded({
396
+ action,
397
+ projectConfig,
398
+ envName,
399
+ envConfig,
400
+ yes: !!values.y,
401
+ });
402
+
403
+ // SSH key is required for everything (compose path AND k8s pod-list
404
+ // fallback). Bail early with a clear message if it's missing.
405
+ const sshKeyPath = getSSHKeyPath(envName);
406
+ if (!existsSync(sshKeyPath)) {
407
+ p.log.error(`SSH key not found: ${sshKeyPath}`);
408
+ p.log.info(
409
+ 'The deployment key is created during deploy and is required for backup operations.',
410
+ );
411
+ process.exit(1);
412
+ }
413
+
414
+ // Action-specific TTY checks fire after we know the action so the
415
+ // missing-flag message is precise.
416
+ if (action === 'create' && !values.y) {
417
+ requireTTYOrFlags({
418
+ requirements: [{ flag: 'y', description: 'skip the confirmation prompt', satisfied: false }],
419
+ });
420
+ }
421
+ if (action === 'download' && !values.source) {
422
+ requireTTYOrFlags({
423
+ requirements: [
424
+ {
425
+ flag: 'source',
426
+ description: 'name the backup file to download',
427
+ satisfied: false,
428
+ },
429
+ ],
430
+ });
431
+ }
432
+
433
+ // Resolve mode + S3 config
434
+ const projectName = projectConfig.projectName || 'project';
435
+ const isCompose = envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha';
436
+ const s3Config = await resolveS3Config({ isCompose, envName, projectName });
437
+ const useS3 = Boolean(s3Config?.secretKey);
438
+
439
+ if (!isCompose) {
440
+ if (useS3) {
441
+ p.log.info(`S3 storage: ${c.dim(s3Config.bucket)} (${s3Config.region})`);
442
+ } else {
443
+ p.log.warn('No S3 storage configured — backups cannot be persisted.');
444
+ p.log.info(
445
+ `Configure S3 during deployment or with: ${c.info(`vibecarbon deploy ${envName}`)}`,
446
+ );
447
+ p.log.info('Without S3, backup data is lost when the Job pod terminates.');
448
+ }
449
+ }
450
+
451
+ const tracker = createTracker('backup', { environment: envName });
452
+ const s = tracker.spinner();
453
+
454
+ if (action === 'list') {
455
+ await runList({ s, isCompose, useS3, s3Config, serverIp, sshKeyPath, projectName, envName });
456
+ p.outro('');
457
+ return;
458
+ }
459
+
460
+ if (action === 'download') {
461
+ await runDownload({
462
+ s,
463
+ source: /** @type {string} */ (values.source),
464
+ isCompose,
465
+ useS3,
466
+ s3Config,
467
+ serverIp,
468
+ sshKeyPath,
469
+ projectName,
470
+ });
471
+ p.outro('');
472
+ return;
473
+ }
474
+
475
+ // action === 'create'
476
+ await runCreate({
477
+ s,
478
+ isCompose,
479
+ useS3,
480
+ s3Config,
481
+ serverIp,
482
+ sshKeyPath,
483
+ projectName,
484
+ envName,
485
+ yes: !!values.y,
486
+ });
487
+ tracker.finish();
488
+ p.outro(c.success('Done'));
489
+ }
490
+
491
+ // ============================================================================
492
+ // ORCHESTRATION HELPERS
493
+ // ============================================================================
494
+
495
+ /**
496
+ * Refresh operator-IP firewall allowlist when the action would hit
497
+ * the cluster. Listing via S3 alone doesn't need this; everything
498
+ * else does.
499
+ */
500
+ async function ensureFirewallAccessIfNeeded({ action, projectConfig, envName, envConfig, yes }) {
501
+ // Skip the firewall check for read-only list ops — they may hit
502
+ // S3 only (when configured), and even when they fall back to the
503
+ // pod, the existing key-based SSH already establishes access.
504
+ if (action === 'list') return;
505
+
506
+ const apiToken = loadCredentials()?.hetzner?.apiToken;
507
+ if (!apiToken) return;
508
+
509
+ try {
510
+ const isHA = envConfig.deployMode === 'compose-ha' || !!envConfig.ha?.enabled;
511
+ const result = await ensureOperatorIpAccess({
512
+ projectConfig,
513
+ environment: envName,
514
+ isHA,
515
+ apiToken,
516
+ yes,
517
+ onMessage: (msg) => p.log.info(msg),
518
+ });
519
+ if (result.added) {
520
+ p.log.success(`Firewall updated: SSH/k8s API now allow ${result.cidr}`);
521
+ }
522
+ } catch (err) {
523
+ p.log.error(`Operator-IP check failed: ${err.message}`);
524
+ process.exit(1);
525
+ }
526
+ }
527
+
528
+ /** Resolve the S3 config for the deploy mode, merging credentials when needed. */
529
+ async function resolveS3Config({ isCompose, envName, projectName }) {
530
+ if (isCompose) {
531
+ const backupS3 = loadBackupS3Config(envName);
532
+ if (!backupS3) return null;
533
+ const creds = await getS3Credentials(projectName, { save: false });
534
+ if (!creds) return null;
535
+ return { ...backupS3, accessKey: creds.accessKey, secretKey: creds.secretKey };
536
+ }
537
+
538
+ // K8s mode: prefer the dedicated backup bucket, fall back to
539
+ // storage. Either way, top up credentials from credentials.json
540
+ // when the config didn't carry them.
541
+ const backupS3 = loadBackupS3Config(envName);
542
+ const storageS3 = loadS3Config(envName);
543
+ let s3Config = backupS3 || storageS3;
544
+ if (s3Config && !s3Config.secretKey) {
545
+ const creds = await getS3Credentials(projectName, { save: false });
546
+ if (creds) {
547
+ s3Config = { ...s3Config, accessKey: creds.accessKey, secretKey: creds.secretKey };
548
+ }
549
+ }
550
+ return s3Config;
551
+ }
552
+
553
+ async function runList({
554
+ s,
555
+ isCompose,
556
+ useS3,
557
+ s3Config,
558
+ serverIp,
559
+ sshKeyPath,
560
+ projectName,
561
+ envName,
562
+ }) {
563
+ s.start('Listing backups');
564
+
565
+ if (isCompose) {
566
+ if (useS3) {
567
+ try {
568
+ const backups = await listS3Backups(s3Config);
569
+ s.stop('Backups retrieved from S3');
570
+ printBackupList(backups, envName);
571
+ } catch (error) {
572
+ s.stop('Failed to list S3 backups');
573
+ p.log.error(error.message);
574
+ }
575
+ } else {
576
+ try {
577
+ const output = sshRunScript(
578
+ serverIp,
579
+ sshKeyPath,
580
+ `ls -lhS /opt/${projectName}/backups/*_full.tar.gz 2>/dev/null || echo NO_BACKUPS`,
581
+ );
582
+ s.stop('Backups retrieved');
583
+
584
+ if (output === 'NO_BACKUPS' || !output.trim()) {
585
+ p.log.info('No backups found.');
586
+ p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
587
+ } else {
588
+ const lines = output.trim().split('\n').filter(Boolean);
589
+ p.log.info(`${c.bold(`Backups for ${envName}`)} (${lines.length} found):`);
590
+ for (const line of lines) {
591
+ p.log.message(` ${line}`);
592
+ }
593
+ }
594
+ } catch (error) {
595
+ s.stop('Failed to list backups');
596
+ p.log.error(error.message);
597
+ }
598
+ }
599
+ return;
600
+ }
601
+
602
+ // K8s
603
+ if (useS3) {
604
+ try {
605
+ const backups = await listS3Backups(s3Config);
606
+ s.stop('Backups retrieved from S3');
607
+ printBackupList(backups, envName);
608
+ } catch (error) {
609
+ s.stop('Failed to list S3 backups');
610
+ p.log.error(error.message);
611
+ process.exit(1);
612
+ }
613
+ return;
614
+ }
615
+
616
+ const backups = await listPodBackups(serverIp, sshKeyPath);
617
+ s.stop('Backups retrieved');
618
+ printBackupList(backups, envName);
619
+ }
620
+
621
+ function printBackupList(backups, envName) {
622
+ if (!backups || backups.length === 0) {
623
+ p.log.info('No backups found.');
624
+ p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
625
+ return;
626
+ }
627
+ p.log.info(`${c.bold(`Backups for ${envName}`)} (${backups.length} found):`);
628
+ for (const backup of backups) {
629
+ p.log.message(` ${c.dim(backup.date.padEnd(22))} ${backup.size.padStart(10)} ${backup.name}`);
630
+ }
631
+ }
632
+
633
+ async function runDownload({
634
+ s,
635
+ source,
636
+ isCompose,
637
+ useS3,
638
+ s3Config,
639
+ serverIp,
640
+ sshKeyPath,
641
+ projectName,
642
+ }) {
643
+ s.start(`Downloading ${source}`);
644
+ try {
645
+ if (isCompose) {
646
+ const localPath = join(process.cwd(), source);
647
+ if (useS3) {
648
+ await downloadS3Backup(s3Config, `backups/${source}`, localPath);
649
+ } else {
650
+ scpDownload(serverIp, sshKeyPath, `/opt/${projectName}/backups/${source}`, localPath);
651
+ }
652
+ s.stop('Download complete');
653
+ p.log.success(`Saved to ${c.bold(localPath)}`);
654
+ return;
655
+ }
656
+
657
+ // K8s
658
+ if (useS3) {
659
+ const localPath = join(process.cwd(), source);
660
+ await downloadS3Backup(s3Config, `backups/${source}`, localPath);
661
+ s.stop('Download complete');
662
+ p.log.success(`Saved to ${c.bold(localPath)}`);
663
+ } else {
664
+ const localPath = await downloadPodBackup(serverIp, sshKeyPath, source);
665
+ s.stop('Download complete');
666
+ p.log.success(`Saved to ${c.bold(localPath)}`);
667
+ }
668
+ } catch (error) {
669
+ s.stop('Download failed');
670
+ p.log.error(error.message);
671
+ process.exit(1);
672
+ }
673
+ }
674
+
675
+ async function runCreate({
676
+ s,
677
+ isCompose,
678
+ useS3,
679
+ s3Config,
680
+ serverIp,
681
+ sshKeyPath,
682
+ projectName,
683
+ envName,
684
+ yes,
685
+ }) {
686
+ if (!yes) {
687
+ const confirm = await p.confirm({
688
+ message: `Create a database backup for ${c.bold(envName)} (${serverIp})?`,
689
+ });
690
+ if (p.isCancel(confirm) || !confirm) {
691
+ p.cancel('Operation cancelled.');
692
+ process.exit(0);
693
+ }
694
+ }
695
+
696
+ if (isCompose) {
697
+ s.start('Creating backup via Docker Compose...');
698
+ try {
699
+ const { backupCompose, uploadComposeBackupToS3 } = await import(
700
+ './lib/deploy/compose/index.js'
701
+ );
702
+ const backupFile = backupCompose(serverIp, sshKeyPath, projectName);
703
+ s.stop('Backup created');
704
+ p.log.success(`Backup: ${c.bold(backupFile)}`);
705
+ p.log.info(`Location: /opt/${projectName}/backups/${backupFile}`);
706
+
707
+ if (useS3) {
708
+ s.start('Uploading backup to S3...');
709
+ try {
710
+ await uploadComposeBackupToS3(serverIp, sshKeyPath, projectName, backupFile, s3Config);
711
+ s.stop(`Uploaded to S3: ${s3Config.bucket}/backups/${backupFile}`);
712
+ } catch (uploadError) {
713
+ s.stop(`S3 upload failed: ${uploadError.message}`);
714
+ p.log.warn('Backup is available on the server but was not uploaded to S3.');
715
+ // Dump the raw error to stderr so automation captures the
716
+ // actual AWS SDK error code + HTTP status — the spinner
717
+ // message above hides the detail e2e/CI needs.
718
+ console.error(
719
+ `[backup] S3 upload failed — name=${uploadError.name} code=${uploadError.Code ?? uploadError.code ?? 'n/a'} status=${uploadError.$metadata?.httpStatusCode ?? 'n/a'} bucket=${s3Config.bucket} endpoint=${s3Config.endpoint} region=${s3Config.region}`,
720
+ );
721
+ if (uploadError.stack) {
722
+ console.error(uploadError.stack);
723
+ }
724
+ }
725
+ } else {
726
+ p.log.info(`Download: scp root@${serverIp}:/opt/${projectName}/backups/${backupFile} .`);
727
+ }
728
+ } catch (error) {
729
+ s.stop('Backup failed');
730
+ p.log.error(error.message);
731
+ process.exit(1);
732
+ }
733
+ return;
734
+ }
735
+
736
+ // K8s — trigger via CronJob template
737
+ s.start('Creating backup (triggering Kubernetes Job)');
738
+ try {
739
+ const jobName = await perfAsync('backup.triggerJob', async () =>
740
+ triggerBackupJob(serverIp, sshKeyPath),
741
+ );
742
+ s.stop('Backup created');
743
+
744
+ if (useS3) {
745
+ p.log.success(`Backup uploaded to S3: ${c.bold(s3Config.bucket)}/backups/`);
746
+ p.log.info(`Job: ${c.dim(jobName)}`);
747
+ p.log.info(`List backups: ${c.info(`vibecarbon backup ${envName} -l`)}`);
748
+ } else {
749
+ p.log.warn(`Backup Job ran: ${c.bold(jobName)}`);
750
+ p.log.warn('No S3 configured — backup was NOT persisted (lost when pod terminated).');
751
+ p.log.info('Configure S3 storage during deploy to enable persistent backups.');
752
+ }
753
+
754
+ // On a TTY, offer to download the freshly-created backup.
755
+ if (useS3 && !yes) {
756
+ const shouldDownload = await p.confirm({
757
+ message: 'Download the latest backup locally?',
758
+ initialValue: false,
759
+ });
760
+ if (shouldDownload && !p.isCancel(shouldDownload)) {
761
+ s.start('Fetching latest backup from S3');
762
+ const backups = await perfAsync('backup.listS3', () => listS3Backups(s3Config));
763
+ if (backups.length > 0) {
764
+ const latest = backups[0];
765
+ const localPath = join(process.cwd(), latest.name);
766
+ s.message(`Downloading ${latest.name} (${latest.size})`);
767
+ await perfAsync('backup.downloadS3', () =>
768
+ downloadS3Backup(s3Config, latest.key, localPath),
769
+ );
770
+ s.stop('Download complete');
771
+ p.log.success(`Saved to ${c.bold(localPath)}`);
772
+ } else {
773
+ s.stop('No backups found in S3 (backup may still be uploading).');
774
+ }
775
+ }
776
+ }
777
+ } catch (error) {
778
+ s.stop('Backup failed');
779
+ p.log.error(error.message);
780
+ process.exit(1);
781
+ }
782
+ }
783
+
784
+ // Re-export SPEC + parseFlags so existing parser tests can continue to
785
+ // import a `parseArgs` symbol if any do; the canonical form is parseFlags.
786
+ export { SPEC };