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/restore.js ADDED
@@ -0,0 +1,871 @@
1
+ /**
2
+ * Vibecarbon Restore Command
3
+ *
4
+ * Interactive-by-default. Bare `vibecarbon restore` walks the operator
5
+ * through env selection → source selection → confirm → restore.
6
+ * Positional and flag seeds skip the corresponding prompt. `-l` lists
7
+ * available backups for an env without restoring (the read-only path
8
+ * that was missing from the old CLI).
9
+ *
10
+ * `-source <name-or-path>` subsumes the old `--file` / `--from-s3` split:
11
+ * if the value resolves to an existing local file, treat it as a local
12
+ * upload-and-restore; otherwise validate as an S3 backup name and
13
+ * restore from S3.
14
+ *
15
+ * Form rule: vibecarbon uses single-dash flags only — see
16
+ * memory:feedback_cli_single_dash_flags.
17
+ */
18
+
19
+ import { existsSync, unlinkSync } from 'node:fs';
20
+ import { basename, join } from 'node:path';
21
+ import * as p from '@clack/prompts';
22
+ import { downloadS3Backup, listS3Backups } from './lib/backup-s3.js';
23
+ import { renderHelp } from './lib/cli/help.js';
24
+ import { parseFlags } from './lib/cli/parse-flags.js';
25
+ import { selectEnvironment } from './lib/cli/select-environment.js';
26
+ import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
27
+ import { c, printBanner } from './lib/colors.js';
28
+ import {
29
+ loadBackupS3Config,
30
+ loadCredentials,
31
+ loadProjectConfig,
32
+ loadS3Config,
33
+ } from './lib/config.js';
34
+ import { getS3Credentials } from './lib/hetzner-guided-setup.js';
35
+ import { requireLicense } from './lib/licensing/index.js';
36
+ import { ensureOperatorIpAccess } from './lib/operator-ip.js';
37
+ import { perfAsync } from './lib/perf.js';
38
+ import { assertInProjectDir } from './lib/project-guard.js';
39
+ import {
40
+ getPostgresPod,
41
+ getSSHKeyPath,
42
+ scpUpload,
43
+ sshKubectl,
44
+ sshRun,
45
+ sshRunScript,
46
+ } from './lib/ssh.js';
47
+ import { createTracker } from './lib/tracker.js';
48
+ import { validateBackupFilename } from './lib/validators.js';
49
+ import { VERSION } from './lib/version.js';
50
+
51
+ // ============================================================================
52
+ // COMMAND SPEC — single source of truth for argv parsing AND help output.
53
+ // ============================================================================
54
+
55
+ /** @type {import('./lib/cli/parse-flags.js').CommandSpec & { summary?: string, examples?: Array<{ command: string, description?: string }> }} */
56
+ const SPEC = {
57
+ name: 'restore',
58
+ summary: 'Restore a database from a backup',
59
+ positional: [
60
+ {
61
+ name: 'env',
62
+ optional: true,
63
+ description: 'Environment to restore (skips the env prompt)',
64
+ },
65
+ ],
66
+ flags: [
67
+ { name: 'h', boolean: true, description: 'Show this help' },
68
+ { name: 'v', boolean: true, description: 'Show version' },
69
+ { name: 'y', boolean: true, description: 'Skip confirmation prompts' },
70
+ {
71
+ name: 'l',
72
+ boolean: true,
73
+ description: 'List available backups (read-only; does not restore)',
74
+ },
75
+ { name: 'env', value: '<name>', description: 'Environment seed (alternative to positional)' },
76
+ {
77
+ name: 'source',
78
+ value: '<file-or-name-or-latest>',
79
+ description: 'Backup source: local file path, S3 backup name, or `latest`',
80
+ },
81
+ ],
82
+ examples: [
83
+ { command: 'vibecarbon restore', description: 'prompts for env and backup' },
84
+ { command: 'vibecarbon restore prod -l', description: 'list available backups for prod' },
85
+ {
86
+ command: 'vibecarbon restore prod -source myapp_20260507.tar.gz',
87
+ description: 'restore a specific backup from S3',
88
+ },
89
+ {
90
+ command: 'vibecarbon restore prod -source ./backup.tar.gz -y',
91
+ description: 'restore from a local file, skipping confirmation',
92
+ },
93
+ {
94
+ command: 'vibecarbon restore prod -y -source latest',
95
+ description: 'restore the most-recent S3 backup, non-interactively',
96
+ },
97
+ ],
98
+ };
99
+
100
+ // ============================================================================
101
+ // KUBECTL HELPERS (k8s mode)
102
+ // ============================================================================
103
+
104
+ function listRemoteBackups(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 -lt /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
+ // ============================================================================
140
+ // RESTORE OPERATIONS (k8s mode)
141
+ // ============================================================================
142
+
143
+ function scaleDownApp(ip, sshKeyPath) {
144
+ sshKubectl(ip, sshKeyPath, ['scale', 'deployment', 'app', '-n', 'vibecarbon', '--replicas=0']);
145
+
146
+ try {
147
+ sshKubectl(ip, sshKeyPath, [
148
+ 'rollout',
149
+ 'status',
150
+ 'deployment/app',
151
+ '-n',
152
+ 'vibecarbon',
153
+ '--timeout=60s',
154
+ ]);
155
+ } catch {
156
+ // Continue even if timeout
157
+ }
158
+ }
159
+
160
+ function scaleUpApp(ip, sshKeyPath) {
161
+ sshKubectl(ip, sshKeyPath, ['scale', 'deployment', 'app', '-n', 'vibecarbon', '--replicas=2']);
162
+ }
163
+
164
+ function verifyPostgres(ip, sshKeyPath) {
165
+ const pod = getPostgresPod(ip, sshKeyPath);
166
+ sshKubectl(ip, sshKeyPath, [
167
+ 'exec',
168
+ '-n',
169
+ 'vibecarbon',
170
+ pod,
171
+ '--',
172
+ 'pg_isready',
173
+ '-U',
174
+ 'postgres',
175
+ ]);
176
+ }
177
+
178
+ /**
179
+ * Restore from a full archive (.tar.gz containing postgres.sql.gz + optional storage.tar.gz)
180
+ */
181
+ function restoreFromArchive(ip, sshKeyPath, remotePath) {
182
+ const pod = getPostgresPod(ip, sshKeyPath);
183
+
184
+ // Extract the archive on the server — argv form; remotePath is never shell-interpolated.
185
+ sshRun(ip, sshKeyPath, ['mkdir', '-p', '/tmp/restore']);
186
+ // GNU tar strips leading `/` by default (no `-P` / `--absolute-names`),
187
+ // so absolute paths in a hostile archive never escape /tmp/restore.
188
+ sshRun(ip, sshKeyPath, [
189
+ 'tar',
190
+ '--no-xattrs',
191
+ '--no-same-owner',
192
+ '-xzf',
193
+ remotePath,
194
+ '-C',
195
+ '/tmp/restore',
196
+ ]);
197
+
198
+ // Check archive contents
199
+ const contents = sshRun(ip, sshKeyPath, ['ls', '/tmp/restore/']);
200
+
201
+ // Restore database
202
+ if (contents.includes('postgres.sql.gz')) {
203
+ sshKubectl(ip, sshKeyPath, [
204
+ 'cp',
205
+ '/tmp/restore/postgres.sql.gz',
206
+ `vibecarbon/${pod}:/tmp/postgres.sql.gz`,
207
+ ]);
208
+ sshKubectl(ip, sshKeyPath, [
209
+ 'exec',
210
+ '-n',
211
+ 'vibecarbon',
212
+ pod,
213
+ '--',
214
+ 'sh',
215
+ '-c',
216
+ 'gunzip -c /tmp/postgres.sql.gz | psql -U supabase_admin postgres',
217
+ ]);
218
+ sshKubectl(ip, sshKeyPath, [
219
+ 'exec',
220
+ '-n',
221
+ 'vibecarbon',
222
+ pod,
223
+ '--',
224
+ 'rm',
225
+ '-f',
226
+ '/tmp/postgres.sql.gz',
227
+ ]);
228
+ }
229
+
230
+ // Restore storage files if present and using file backend
231
+ if (contents.includes('storage.tar.gz')) {
232
+ // kubectl get returns empty string if STORAGE_BACKEND env var is unset;
233
+ // default to 'file' so the legacy single-backend path still works.
234
+ let storageBackend;
235
+ try {
236
+ storageBackend = sshKubectl(ip, sshKeyPath, [
237
+ 'get',
238
+ 'deployment',
239
+ 'storage',
240
+ '-n',
241
+ 'vibecarbon',
242
+ '-o',
243
+ 'jsonpath={.spec.template.spec.containers[0].env[?(@.name=="STORAGE_BACKEND")].value}',
244
+ ]);
245
+ } catch {
246
+ storageBackend = 'file';
247
+ }
248
+ if (!storageBackend) storageBackend = 'file';
249
+
250
+ if (storageBackend === 'file') {
251
+ const storagePod = sshKubectl(ip, sshKeyPath, [
252
+ 'get',
253
+ 'pods',
254
+ '-n',
255
+ 'vibecarbon',
256
+ '-l',
257
+ 'app.kubernetes.io/name=supabase-storage',
258
+ '-o',
259
+ 'jsonpath={.items[0].metadata.name}',
260
+ ]);
261
+
262
+ sshKubectl(ip, sshKeyPath, [
263
+ 'cp',
264
+ '/tmp/restore/storage.tar.gz',
265
+ `vibecarbon/${storagePod}:/tmp/storage.tar.gz`,
266
+ ]);
267
+ sshKubectl(ip, sshKeyPath, [
268
+ 'exec',
269
+ '-n',
270
+ 'vibecarbon',
271
+ storagePod,
272
+ '--',
273
+ 'sh',
274
+ '-c',
275
+ 'tar --no-xattrs --no-same-owner -xzf /tmp/storage.tar.gz -C / && rm -f /tmp/storage.tar.gz',
276
+ ]);
277
+ } else {
278
+ p.log.info('Storage backend is S3 — skipping file restore (files already in S3)');
279
+ }
280
+ }
281
+
282
+ // Cleanup
283
+ sshRun(ip, sshKeyPath, ['rm', '-rf', '/tmp/restore']);
284
+ }
285
+
286
+ /**
287
+ * Restore from a legacy .sql.gz file (database only)
288
+ */
289
+ function restoreFromSqlGz(ip, sshKeyPath, backupName) {
290
+ const pod = getPostgresPod(ip, sshKeyPath);
291
+
292
+ // backupName is validated via validateBackupFilename at entry; still pass it
293
+ // as a positional sh arg so the remote shell never expands it as code.
294
+ sshKubectl(ip, sshKeyPath, [
295
+ 'exec',
296
+ '-n',
297
+ 'vibecarbon',
298
+ pod,
299
+ '--',
300
+ 'sh',
301
+ '-c',
302
+ 'gunzip -c "/backups/$1" | psql -U supabase_admin postgres',
303
+ '_',
304
+ backupName,
305
+ ]);
306
+ }
307
+
308
+ async function restoreFromRemote(ip, sshKeyPath, backupName) {
309
+ scaleDownApp(ip, sshKeyPath);
310
+
311
+ if (backupName.endsWith('_full.tar.gz')) {
312
+ restoreFromArchive(ip, sshKeyPath, `/tmp/${backupName}`);
313
+ } else {
314
+ restoreFromSqlGz(ip, sshKeyPath, backupName);
315
+ }
316
+
317
+ scaleUpApp(ip, sshKeyPath);
318
+ verifyPostgres(ip, sshKeyPath);
319
+ }
320
+
321
+ async function uploadAndRestore(ip, sshKeyPath, localFile) {
322
+ const filename = basename(localFile);
323
+
324
+ // Upload local file to server
325
+ scpUpload(ip, sshKeyPath, localFile, `/tmp/${filename}`);
326
+
327
+ scaleDownApp(ip, sshKeyPath);
328
+
329
+ if (filename.endsWith('_full.tar.gz')) {
330
+ restoreFromArchive(ip, sshKeyPath, `/tmp/${filename}`);
331
+ } else if (filename.endsWith('.sql.gz')) {
332
+ const pod = getPostgresPod(ip, sshKeyPath);
333
+ sshKubectl(ip, sshKeyPath, [
334
+ 'cp',
335
+ `/tmp/${filename}`,
336
+ `vibecarbon/${pod}:/backups/${filename}`,
337
+ ]);
338
+ restoreFromSqlGz(ip, sshKeyPath, filename);
339
+ }
340
+
341
+ // Clean up temp file on host
342
+ sshRun(ip, sshKeyPath, ['rm', '-f', `/tmp/${filename}`]);
343
+
344
+ scaleUpApp(ip, sshKeyPath);
345
+ verifyPostgres(ip, sshKeyPath);
346
+ }
347
+
348
+ // ============================================================================
349
+ // SOURCE INTERPRETATION
350
+ // ============================================================================
351
+
352
+ /**
353
+ * The `-source` flag accepts either a local file path or an S3 backup
354
+ * name. Existence on disk is the signal: if the value resolves to a
355
+ * file we treat it as upload-and-restore; otherwise we validate as an
356
+ * S3 backup name and restore from S3.
357
+ *
358
+ * @param {string} source
359
+ * @returns {{ kind: 'local', path: string, name: string } | { kind: 's3', name: string }}
360
+ */
361
+ function classifySource(source) {
362
+ if (existsSync(source)) {
363
+ return { kind: 'local', path: source, name: basename(source) };
364
+ }
365
+ // Not a local path — assume S3 backup name. Validate format up front
366
+ // so downstream code can trust it.
367
+ const err = validateBackupFilename(source);
368
+ if (err) {
369
+ p.log.error(`Invalid -source: ${err}`);
370
+ p.log.info(
371
+ `Expected a local file path or S3 backup name (e.g. myapp_20260507_120000_full.tar.gz)`,
372
+ );
373
+ process.exit(1);
374
+ }
375
+ return { kind: 's3', name: source };
376
+ }
377
+
378
+ // ============================================================================
379
+ // MAIN
380
+ // ============================================================================
381
+
382
+ export async function run(args) {
383
+ const { values, positional, errors } = parseFlags(args, SPEC);
384
+
385
+ if (errors.length > 0) {
386
+ for (const e of errors) {
387
+ process.stderr.write(`${c.error('✗')} ${e}\n`);
388
+ }
389
+ process.stderr.write(`Run ${c.info('vibecarbon restore -h')} for usage.\n`);
390
+ process.exit(1);
391
+ }
392
+
393
+ if (values.h) {
394
+ process.stdout.write(renderHelp(SPEC));
395
+ return;
396
+ }
397
+ if (values.v) {
398
+ console.log(`vibecarbon v${VERSION}`);
399
+ return;
400
+ }
401
+
402
+ // Project guard runs before banner so an accidental `vibecarbon
403
+ // restore` from a parent directory emits the canonical message.
404
+ assertInProjectDir();
405
+
406
+ requireLicense('restore');
407
+ printBanner();
408
+ p.intro(`${c.bold('vibecarbon restore')} ${c.dim(`v${VERSION}`)}`);
409
+
410
+ const projectConfig = loadProjectConfig();
411
+ const envCount = Object.keys(projectConfig.environments || {}).length;
412
+ // Positional wins over flag when both are supplied — bare-form is the
413
+ // canonical surface, the flag is the scripting alternative.
414
+ const envSeed =
415
+ /** @type {string|undefined} */ (positional.env) ||
416
+ /** @type {string|null} */ (values.env) ||
417
+ null;
418
+
419
+ // Top-level TTY guard: env prompt fires if multi-env and no seed.
420
+ // Source-prompt requirements gate after the env is resolved (since
421
+ // the picker depends on which backups exist for the env).
422
+ requireTTYOrFlags({
423
+ requirements: [
424
+ {
425
+ flag: 'env',
426
+ description: 'name an environment to restore',
427
+ satisfied: !!envSeed || envCount <= 1,
428
+ },
429
+ ],
430
+ });
431
+
432
+ const { envName, envConfig } = await selectEnvironment(projectConfig, {
433
+ actionVerb: 'restore',
434
+ seed: envSeed,
435
+ });
436
+
437
+ const serverIp = envConfig.servers?.[0]?.ip;
438
+ if (!serverIp) {
439
+ p.log.error(`No server IP found for environment '${envName}'.`);
440
+ process.exit(1);
441
+ }
442
+
443
+ // Source TTY guard fires *before* infrastructure checks so the
444
+ // off-TTY error message is "needs -source" rather than "SSH key
445
+ // not found" — the latter would correctly diagnose a different
446
+ // problem but obscures what the operator actually needs to fix
447
+ // for a scripted invocation.
448
+ if (!values.l && !values.source) {
449
+ requireTTYOrFlags({
450
+ requirements: [
451
+ {
452
+ flag: 'source',
453
+ description: 'name the backup to restore (S3 name or local file path)',
454
+ satisfied: false,
455
+ },
456
+ ],
457
+ });
458
+ }
459
+
460
+ // Operator-IP firewall refresh — the listing path needs SSH/k8s
461
+ // reachability too (we may fall back to a pod-list when S3 isn't
462
+ // configured), so we run this regardless of -l.
463
+ const apiToken = loadCredentials()?.hetzner?.apiToken;
464
+ if (apiToken) {
465
+ try {
466
+ const isHA = envConfig.deployMode === 'compose-ha' || !!envConfig.ha?.enabled;
467
+ const result = await ensureOperatorIpAccess({
468
+ projectConfig,
469
+ environment: envName,
470
+ isHA,
471
+ apiToken,
472
+ yes: !!values.y,
473
+ onMessage: (msg) => p.log.info(msg),
474
+ });
475
+ if (result.added) {
476
+ p.log.success(`Firewall updated: SSH/k8s API now allow ${result.cidr}`);
477
+ }
478
+ } catch (err) {
479
+ p.log.error(`Operator-IP check failed: ${err.message}`);
480
+ process.exit(1);
481
+ }
482
+ }
483
+
484
+ // SSH key is required for everything restore touches.
485
+ const sshKeyPath = getSSHKeyPath(envName);
486
+ if (!existsSync(sshKeyPath)) {
487
+ p.log.error(`SSH key not found: ${sshKeyPath}`);
488
+ p.log.info(
489
+ 'The deployment key is created during deploy and is required for restore operations.',
490
+ );
491
+ process.exit(1);
492
+ }
493
+
494
+ const projectName = projectConfig.projectName || 'project';
495
+ const isCompose = envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha';
496
+
497
+ // -l: list-only path. Read-only; print and exit.
498
+ if (values.l) {
499
+ await runList({ isCompose, envName, projectName, serverIp, sshKeyPath });
500
+ p.outro('');
501
+ return;
502
+ }
503
+
504
+ // Resolve mode-specific S3 config + the chosen backup source.
505
+ const s3 = await resolveS3({ isCompose, envName, projectName });
506
+ const useS3 = Boolean(s3?.secretKey);
507
+
508
+ // `-source latest` is a non-interactive sentinel that routes through the
509
+ // same auto-pick path the interactive prompt uses under `-y` — the newest
510
+ // S3 backup wins. Lets scripted callers (e2e, post-incident
511
+ // restores) ask for "the most recent" without first listing + parsing
512
+ // names from `vibecarbon backup -l`.
513
+ const wantsLatest = values.source === 'latest';
514
+ const chosenSource =
515
+ !wantsLatest && values.source
516
+ ? classifySource(/** @type {string} */ (values.source))
517
+ : await pickInteractiveSource({
518
+ isCompose,
519
+ envName,
520
+ projectName,
521
+ serverIp,
522
+ sshKeyPath,
523
+ s3,
524
+ useS3,
525
+ yes: wantsLatest || !!values.y,
526
+ });
527
+
528
+ if (!chosenSource) {
529
+ p.log.error('No backup selected.');
530
+ process.exit(1);
531
+ }
532
+
533
+ // Confirmation gates
534
+ p.log.warn('This will overwrite the current database!');
535
+ p.log.message(` ${c.dim('Environment'.padEnd(14))} ${c.bold(envName)}`);
536
+ p.log.message(` ${c.dim('Server'.padEnd(14))} ${c.bold(serverIp)}`);
537
+ p.log.message(` ${c.dim('Backup'.padEnd(14))} ${c.bold(chosenSource.name)}`);
538
+
539
+ if (!values.y) {
540
+ const confirm = await p.confirm({
541
+ message: `Restore ${c.bold(chosenSource.name)} to ${c.bold(envName)}?`,
542
+ });
543
+ if (p.isCancel(confirm) || !confirm) {
544
+ p.cancel('Operation cancelled.');
545
+ process.exit(0);
546
+ }
547
+
548
+ if (envName === 'prod' || envName === 'production') {
549
+ const doubleConfirm = await p.text({
550
+ message: `Type "${envName}" to confirm restoration of production database:`,
551
+ validate: (v) => (v !== envName ? `Please type "${envName}" to confirm` : undefined),
552
+ });
553
+ if (p.isCancel(doubleConfirm)) {
554
+ p.cancel('Operation cancelled.');
555
+ process.exit(0);
556
+ }
557
+ }
558
+ }
559
+
560
+ // Dispatch by mode
561
+ const tracker = createTracker('restore', { environment: envName });
562
+ const s = tracker.spinner();
563
+
564
+ try {
565
+ if (isCompose) {
566
+ await runComposeRestore({
567
+ s,
568
+ chosenSource,
569
+ envName,
570
+ projectName,
571
+ serverIp,
572
+ sshKeyPath,
573
+ s3WithCreds: s3,
574
+ });
575
+ } else {
576
+ await runK8sRestore({
577
+ s,
578
+ chosenSource,
579
+ envName,
580
+ serverIp,
581
+ sshKeyPath,
582
+ s3,
583
+ useS3,
584
+ });
585
+ }
586
+ } catch (error) {
587
+ s.stop('Restore failed');
588
+ p.log.error(error.message);
589
+ if (!isCompose) {
590
+ p.log.warn('The app deployment may need to be manually scaled back up:');
591
+ p.log.info(
592
+ ` ssh root@${serverIp} "kubectl scale deployment app -n vibecarbon --replicas=2"`,
593
+ );
594
+ }
595
+ process.exit(1);
596
+ }
597
+
598
+ tracker.finish();
599
+ p.outro(c.success('Done'));
600
+ }
601
+
602
+ // ============================================================================
603
+ // ORCHESTRATION HELPERS
604
+ // ============================================================================
605
+
606
+ async function resolveS3({ isCompose, envName, projectName }) {
607
+ if (isCompose) {
608
+ const backupS3 = loadBackupS3Config(envName);
609
+ if (!backupS3) return null;
610
+ const creds = await getS3Credentials(projectName, { save: false });
611
+ if (!creds) return null;
612
+ return { ...backupS3, accessKey: creds.accessKey, secretKey: creds.secretKey };
613
+ }
614
+ const backupS3 = loadBackupS3Config(envName);
615
+ const storageS3 = loadS3Config(envName);
616
+ let s3 = backupS3 || storageS3;
617
+ if (s3 && !s3.secretKey) {
618
+ const creds = await getS3Credentials(projectName, { save: false });
619
+ if (creds) {
620
+ s3 = { ...s3, accessKey: creds.accessKey, secretKey: creds.secretKey };
621
+ }
622
+ }
623
+ return s3;
624
+ }
625
+
626
+ async function runList({ isCompose, envName, projectName, serverIp, sshKeyPath }) {
627
+ const s3 = await resolveS3({ isCompose, envName, projectName });
628
+ const useS3 = Boolean(s3?.secretKey);
629
+
630
+ if (useS3) {
631
+ try {
632
+ const backups = await listS3Backups(s3);
633
+ printBackupList(backups, envName);
634
+ } catch (error) {
635
+ p.log.error(`Failed to list S3 backups: ${error.message}`);
636
+ process.exit(1);
637
+ }
638
+ return;
639
+ }
640
+
641
+ if (isCompose) {
642
+ try {
643
+ const output = sshRunScript(
644
+ serverIp,
645
+ sshKeyPath,
646
+ `ls -lt /opt/${projectName}/backups/*_full.tar.gz 2>/dev/null || echo NO_BACKUPS`,
647
+ );
648
+ if (output === 'NO_BACKUPS' || !output.trim()) {
649
+ p.log.info('No backups found.');
650
+ p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
651
+ return;
652
+ }
653
+ const lines = output.trim().split('\n').filter(Boolean);
654
+ p.log.info(`${c.bold(`Backups for ${envName}`)} (${lines.length} found):`);
655
+ for (const line of lines) {
656
+ p.log.message(` ${line}`);
657
+ }
658
+ } catch (error) {
659
+ p.log.error(`Failed to list backups: ${error.message}`);
660
+ }
661
+ return;
662
+ }
663
+
664
+ // K8s pod fallback
665
+ const backups = listRemoteBackups(serverIp, sshKeyPath);
666
+ printBackupList(backups, envName);
667
+ }
668
+
669
+ function printBackupList(backups, envName) {
670
+ if (!backups || backups.length === 0) {
671
+ p.log.info('No backups found.');
672
+ p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
673
+ return;
674
+ }
675
+ p.log.info(`${c.bold(`Backups for ${envName}`)} (${backups.length} found):`);
676
+ for (const backup of backups) {
677
+ p.log.message(` ${c.dim(backup.date.padEnd(22))} ${backup.size.padStart(10)} ${backup.name}`);
678
+ }
679
+ }
680
+
681
+ async function pickInteractiveSource({
682
+ isCompose,
683
+ envName,
684
+ projectName,
685
+ serverIp,
686
+ sshKeyPath,
687
+ s3,
688
+ useS3,
689
+ yes,
690
+ }) {
691
+ const tracker = createTracker('restore.pickSource', { environment: envName });
692
+ const s = tracker.spinner();
693
+
694
+ if (useS3) {
695
+ s.start('Fetching available backups from S3');
696
+ const backups = await listS3Backups(s3);
697
+ s.stop('Backups retrieved');
698
+ if (backups.length === 0) {
699
+ p.log.error('No backups found in S3.');
700
+ p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
701
+ process.exit(1);
702
+ }
703
+ if (yes) {
704
+ const latest = backups[0].name;
705
+ p.log.info(`Auto-selected latest backup: ${c.bold(latest)}`);
706
+ return { kind: 's3', name: latest };
707
+ }
708
+ const selected = await p.select({
709
+ message: 'Select backup to restore:',
710
+ options: backups.map((b) => ({
711
+ value: b.name,
712
+ label: b.name,
713
+ hint: `${b.size} — ${b.date}`,
714
+ })),
715
+ });
716
+ if (p.isCancel(selected)) {
717
+ p.cancel('Operation cancelled.');
718
+ process.exit(0);
719
+ }
720
+ return { kind: 's3', name: /** @type {string} */ (selected) };
721
+ }
722
+
723
+ // Non-S3 fallback: list from server filesystem (compose) or pod (k8s).
724
+ if (isCompose) {
725
+ s.start('Listing available backups');
726
+ const output = sshRunScript(
727
+ serverIp,
728
+ sshKeyPath,
729
+ `ls -t /opt/${projectName}/backups/*_full.tar.gz 2>/dev/null || echo NO_BACKUPS`,
730
+ );
731
+ s.stop('');
732
+ if (output === 'NO_BACKUPS' || !output.trim()) {
733
+ p.log.error('No backups found on the server.');
734
+ p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
735
+ process.exit(1);
736
+ }
737
+ const names = output
738
+ .trim()
739
+ .split('\n')
740
+ .filter(Boolean)
741
+ .map((f) => basename(f));
742
+ if (yes) {
743
+ const latest = names[0];
744
+ p.log.info(`Auto-selected latest backup: ${c.bold(latest)}`);
745
+ return { kind: 's3', name: latest };
746
+ }
747
+ const selected = await p.select({
748
+ message: 'Select backup to restore:',
749
+ options: names.map((b) => ({ value: b, label: b })),
750
+ });
751
+ if (p.isCancel(selected)) {
752
+ p.cancel('Operation cancelled.');
753
+ process.exit(0);
754
+ }
755
+ return { kind: 's3', name: /** @type {string} */ (selected) };
756
+ }
757
+
758
+ // K8s pod fallback
759
+ s.start('Fetching available backups');
760
+ const backups = listRemoteBackups(serverIp, sshKeyPath);
761
+ s.stop('Backups retrieved');
762
+ if (backups.length === 0) {
763
+ p.log.error('No backups found on the server.');
764
+ p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
765
+ process.exit(1);
766
+ }
767
+ if (yes) {
768
+ const latest = backups[0].name;
769
+ p.log.info(`Auto-selected latest backup: ${c.bold(latest)}`);
770
+ return { kind: 's3', name: latest };
771
+ }
772
+ const selected = await p.select({
773
+ message: 'Select backup to restore:',
774
+ options: backups.map((b) => ({ value: b.name, label: b.name, hint: `${b.size} — ${b.date}` })),
775
+ });
776
+ if (p.isCancel(selected)) {
777
+ p.cancel('Operation cancelled.');
778
+ process.exit(0);
779
+ }
780
+ return { kind: 's3', name: /** @type {string} */ (selected) };
781
+ }
782
+
783
+ async function runComposeRestore({
784
+ s,
785
+ chosenSource,
786
+ envName,
787
+ projectName,
788
+ serverIp,
789
+ sshKeyPath,
790
+ s3WithCreds,
791
+ }) {
792
+ const backupFile = chosenSource.name;
793
+
794
+ if (chosenSource.kind === 'local') {
795
+ p.log.info(`Uploading local file: ${c.bold(chosenSource.path)}`);
796
+ scpUpload(serverIp, sshKeyPath, chosenSource.path, `/opt/${projectName}/backups/${backupFile}`);
797
+ } else if (s3WithCreds?.secretKey) {
798
+ s.start(`Downloading ${backupFile} from S3 to server`);
799
+ const tmpLocal = join(process.cwd(), `.restore-tmp-${backupFile}`);
800
+ try {
801
+ await downloadS3Backup(s3WithCreds, `backups/${backupFile}`, tmpLocal);
802
+ scpUpload(serverIp, sshKeyPath, tmpLocal, `/opt/${projectName}/backups/${backupFile}`);
803
+ try {
804
+ unlinkSync(tmpLocal);
805
+ } catch {
806
+ // ignore cleanup errors
807
+ }
808
+ s.stop('Backup transferred to server');
809
+ } catch (error) {
810
+ s.stop(`S3 download failed: ${error.message}`);
811
+ throw error;
812
+ }
813
+ }
814
+
815
+ s.start('Restoring database...');
816
+ const { restoreCompose } = await import('./lib/deploy/compose/index.js');
817
+ restoreCompose(serverIp, sshKeyPath, projectName, backupFile);
818
+ s.stop('Database restored');
819
+ p.log.success('Restore completed successfully');
820
+ // env name is unused by restoreCompose but kept in the closure for
821
+ // diagnostic logging consistency.
822
+ void envName;
823
+ }
824
+
825
+ async function runK8sRestore({ s, chosenSource, envName, serverIp, sshKeyPath, s3, useS3 }) {
826
+ s.start('Restoring database (app will be temporarily unavailable)');
827
+
828
+ if (chosenSource.kind === 'local') {
829
+ if (!chosenSource.path.endsWith('.sql.gz') && !chosenSource.path.endsWith('.tar.gz')) {
830
+ throw new Error('Backup file must be a .sql.gz or .tar.gz file');
831
+ }
832
+ await perfAsync('restore.uploadAndRestore', () =>
833
+ uploadAndRestore(serverIp, sshKeyPath, chosenSource.path),
834
+ );
835
+ s.stop('Database restored');
836
+ } else if (useS3) {
837
+ s.message('Downloading backup from S3');
838
+ const tempPath = join(process.cwd(), `.restore-tmp-${chosenSource.name}`);
839
+ await perfAsync('restore.downloadS3', () =>
840
+ downloadS3Backup(s3, `backups/${chosenSource.name}`, tempPath),
841
+ );
842
+ s.message('Uploading backup to server');
843
+ await perfAsync('restore.scpUpload', async () =>
844
+ scpUpload(serverIp, sshKeyPath, tempPath, `/tmp/${chosenSource.name}`),
845
+ );
846
+ try {
847
+ unlinkSync(tempPath);
848
+ } catch {
849
+ // ignore cleanup errors
850
+ }
851
+ s.message('Restoring database');
852
+ await perfAsync('restore.restoreFromRemote', () =>
853
+ restoreFromRemote(serverIp, sshKeyPath, chosenSource.name),
854
+ );
855
+ s.stop('Database restored');
856
+ } else {
857
+ // Pod-only fallback: backup name refers to a file already in the pod.
858
+ await restoreFromRemote(serverIp, sshKeyPath, chosenSource.name);
859
+ s.stop('Database restored');
860
+ }
861
+
862
+ p.log.success('Restore completed successfully');
863
+ p.log.info('The application has been scaled back up.');
864
+ p.log.info(`Verify with: ${c.info(`vibecarbon status ${envName}`)}`);
865
+ }
866
+
867
+ // ============================================================================
868
+ // EXPORTS FOR TESTING
869
+ // ============================================================================
870
+
871
+ export { SPEC };