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.
- package/LICENSE +663 -0
- package/README.md +188 -0
- package/carbon/.claude/hooks/task-completed-gate.sh +19 -0
- package/carbon/.claude/hooks/teammate-idle-gate.sh +29 -0
- package/carbon/.claude/settings.json +29 -0
- package/carbon/.cursor/rules/vibecarbon.mdc +6 -0
- package/carbon/.dockerignore +57 -0
- package/carbon/.env.example +219 -0
- package/carbon/.github/copilot-instructions.md +3 -0
- package/carbon/.github/workflows/deploy.yml +346 -0
- package/carbon/.github/workflows/vibecarbon-build.yml +81 -0
- package/carbon/.windsurfrules +74 -0
- package/carbon/AGENTS.md +422 -0
- package/carbon/CLAUDE.md +3 -0
- package/carbon/DEVELOPMENT.md +187 -0
- package/carbon/Dockerfile +98 -0
- package/carbon/LICENSE +21 -0
- package/carbon/PRODUCTION.md +364 -0
- package/carbon/README.md +193 -0
- package/carbon/TESTING.md +138 -0
- package/carbon/backup/Dockerfile +75 -0
- package/carbon/backup/backup.sh +95 -0
- package/carbon/backup/compose-backup.sh +140 -0
- package/carbon/biome.json +83 -0
- package/carbon/cloud-init/docker-ce-setup.yaml +55 -0
- package/carbon/cloud-init/k3s/master-init.sh +215 -0
- package/carbon/cloud-init/k3s/supabase-init.sh +167 -0
- package/carbon/cloud-init/k3s/worker-init.sh +147 -0
- package/carbon/components.json +24 -0
- package/carbon/content/blog/authentication-guide.mdx +34 -0
- package/carbon/content/blog/getting-started.mdx +41 -0
- package/carbon/content/changelog/v0-1-0.mdx +40 -0
- package/carbon/content/docs/analytics.mdx +90 -0
- package/carbon/content/docs/authentication.mdx +231 -0
- package/carbon/content/docs/background-jobs.mdx +116 -0
- package/carbon/content/docs/cli.mdx +630 -0
- package/carbon/content/docs/database.mdx +236 -0
- package/carbon/content/docs/deployment.mdx +227 -0
- package/carbon/content/docs/development.mdx +238 -0
- package/carbon/content/docs/environments.mdx +84 -0
- package/carbon/content/docs/getting-started.mdx +112 -0
- package/carbon/content/docs/legal/privacy-policy.mdx +89 -0
- package/carbon/content/docs/legal/terms-of-service.mdx +99 -0
- package/carbon/content/docs/optional-services.mdx +160 -0
- package/carbon/db/Dockerfile +23 -0
- package/carbon/docker-compose.dev-init.yml +5 -0
- package/carbon/docker-compose.metabase.override.yml +14 -0
- package/carbon/docker-compose.metabase.prod.yml +28 -0
- package/carbon/docker-compose.metabase.yml +84 -0
- package/carbon/docker-compose.n8n.override.yml +14 -0
- package/carbon/docker-compose.n8n.prod.yml +31 -0
- package/carbon/docker-compose.n8n.yml +97 -0
- package/carbon/docker-compose.observability.override.yml +18 -0
- package/carbon/docker-compose.observability.prod.yml +28 -0
- package/carbon/docker-compose.observability.yml +125 -0
- package/carbon/docker-compose.override.yml +28 -0
- package/carbon/docker-compose.prod.yml +294 -0
- package/carbon/docker-compose.yml +508 -0
- package/carbon/docker-entrypoint.sh +12 -0
- package/carbon/ha/activate-standby.sh +52 -0
- package/carbon/ha/primary-init.sql +36 -0
- package/carbon/ha/standby-init.sh +74 -0
- package/carbon/k8s/LICENSE +99 -0
- package/carbon/k8s/README.md +272 -0
- package/carbon/k8s/base/app/deployment.yaml +130 -0
- package/carbon/k8s/base/app/hpa.yaml +44 -0
- package/carbon/k8s/base/app/kustomization.yaml +10 -0
- package/carbon/k8s/base/app/network-policy.yaml +124 -0
- package/carbon/k8s/base/app/pdb.yaml +14 -0
- package/carbon/k8s/base/app/rbac.yaml +36 -0
- package/carbon/k8s/base/app/service.yaml +16 -0
- package/carbon/k8s/base/backup/cronjob.yaml +120 -0
- package/carbon/k8s/base/backup/kustomization.yaml +7 -0
- package/carbon/k8s/base/backup/network-policy.yaml +31 -0
- package/carbon/k8s/base/backup/rbac.yaml +7 -0
- package/carbon/k8s/base/cluster-autoscaler/deployment.yaml +103 -0
- package/carbon/k8s/base/cluster-autoscaler/kustomization.yaml +17 -0
- package/carbon/k8s/base/cluster-autoscaler/rbac.yaml +109 -0
- package/carbon/k8s/base/config/configmap.yaml +48 -0
- package/carbon/k8s/base/config/kustomization.yaml +8 -0
- package/carbon/k8s/base/hetzner-ccm/kustomization.yaml +9 -0
- package/carbon/k8s/base/hetzner-csi/kustomization.yaml +9 -0
- package/carbon/k8s/base/kustomization.yaml +43 -0
- package/carbon/k8s/base/namespace.yaml +7 -0
- package/carbon/k8s/base/network-policies.yaml +95 -0
- package/carbon/k8s/base/registry/kustomization.yaml +5 -0
- package/carbon/k8s/base/registry/local-registry.yaml +193 -0
- package/carbon/k8s/base/traefik/certificate.yaml +21 -0
- package/carbon/k8s/base/traefik/configmap.yaml +13 -0
- package/carbon/k8s/base/traefik/deployment.yaml +165 -0
- package/carbon/k8s/base/traefik/ingressroute.yaml +141 -0
- package/carbon/k8s/base/traefik/kustomization.yaml +11 -0
- package/carbon/k8s/base/traefik/middleware.yaml +109 -0
- package/carbon/k8s/base/traefik/network-policy.yaml +92 -0
- package/carbon/k8s/base/traefik/service.yaml +38 -0
- package/carbon/k8s/flux/README.md +49 -0
- package/carbon/k8s/flux/clusters/primary/vibecarbon.yaml +128 -0
- package/carbon/k8s/flux/clusters/standby/vibecarbon.yaml +83 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-release.yaml +38 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-repository.yaml +16 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/kustomization.yaml +10 -0
- package/carbon/k8s/gitops/supabase/helm-release.yaml +66 -0
- package/carbon/k8s/gitops/supabase/helm-repository.yaml +18 -0
- package/carbon/k8s/gitops/supabase/kustomization.yaml +33 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-cloudflare.yaml +51 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-hetzner.yaml +59 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-manual.yaml +43 -0
- package/carbon/k8s/infra/cert-manager-resources/kustomization.yaml +15 -0
- package/carbon/k8s/infra/kustomization.yaml +21 -0
- package/carbon/k8s/infra/traefik-crds/kustomization.yaml +10 -0
- package/carbon/k8s/overlays/local/configmap.yaml +23 -0
- package/carbon/k8s/overlays/local/ingressroute-studio.yaml +104 -0
- package/carbon/k8s/overlays/local/kustomization.yaml +215 -0
- package/carbon/k8s/overlays/local/namespace.yaml +8 -0
- package/carbon/k8s/overlays/local/secrets.yaml +32 -0
- package/carbon/k8s/overlays/production/kustomization.yaml +34 -0
- package/carbon/k8s/test-local.sh +318 -0
- package/carbon/k8s/values/supabase.values.yaml +133 -0
- package/carbon/package.json +154 -0
- package/carbon/runtime.Dockerfile +17 -0
- package/carbon/scripts/_dev-jwt.mjs +45 -0
- package/carbon/scripts/check-rls.ts +53 -0
- package/carbon/scripts/dev-init.js +191 -0
- package/carbon/scripts/dev.js +191 -0
- package/carbon/scripts/docker-down.js +63 -0
- package/carbon/scripts/docker-logs.js +59 -0
- package/carbon/scripts/docker-up.js +222 -0
- package/carbon/scripts/generate-dev-configs.sh +131 -0
- package/carbon/scripts/generate-rss.ts +116 -0
- package/carbon/scripts/generate-sitemap.ts +102 -0
- package/carbon/scripts/k8s-apply.js +71 -0
- package/carbon/scripts/k8s-delete.js +75 -0
- package/carbon/scripts/lib/manifest.js +176 -0
- package/carbon/scripts/secret-scan.mjs +278 -0
- package/carbon/scripts/validate-dev-configs.sh +101 -0
- package/carbon/src/client/App.tsx +202 -0
- package/carbon/src/client/assets/hyperformant-dark.svg +29 -0
- package/carbon/src/client/assets/hyperformant-light.svg +29 -0
- package/carbon/src/client/assets/logos/aider.svg +1 -0
- package/carbon/src/client/assets/logos/antigravity.svg +1 -0
- package/carbon/src/client/assets/logos/bolt.svg +1 -0
- package/carbon/src/client/assets/logos/claude-code.svg +1 -0
- package/carbon/src/client/assets/logos/copilot.svg +1 -0
- package/carbon/src/client/assets/logos/cursor.svg +1 -0
- package/carbon/src/client/assets/logos/gemini-cli.svg +1 -0
- package/carbon/src/client/assets/logos/lovable.svg +1 -0
- package/carbon/src/client/assets/logos/openai-codex.svg +1 -0
- package/carbon/src/client/assets/logos/v0.svg +1 -0
- package/carbon/src/client/assets/logos/windsurf.svg +1 -0
- package/carbon/src/client/assets/vibecarbon-icon.svg +19 -0
- package/carbon/src/client/assets/vibecarbon-logo-dark.svg +29 -0
- package/carbon/src/client/assets/vibecarbon-logo-light.svg +29 -0
- package/carbon/src/client/components/AIIntegrationSection.tsx +120 -0
- package/carbon/src/client/components/AppSidebar.tsx +760 -0
- package/carbon/src/client/components/ArchitectureSection.tsx +46 -0
- package/carbon/src/client/components/CTAFooter.tsx +59 -0
- package/carbon/src/client/components/ComparisonSection.tsx +132 -0
- package/carbon/src/client/components/ContentPanel.tsx +66 -0
- package/carbon/src/client/components/ContentSkeleton.tsx +21 -0
- package/carbon/src/client/components/ErrorBoundary.tsx +46 -0
- package/carbon/src/client/components/FAQSection.tsx +76 -0
- package/carbon/src/client/components/FileUpload.tsx +210 -0
- package/carbon/src/client/components/HeaderActions.tsx +17 -0
- package/carbon/src/client/components/Hero.tsx +608 -0
- package/carbon/src/client/components/ImpersonationBanner.tsx +31 -0
- package/carbon/src/client/components/LanguageSwitcher.tsx +67 -0
- package/carbon/src/client/components/Logo.tsx +87 -0
- package/carbon/src/client/components/LogoStrip.tsx +76 -0
- package/carbon/src/client/components/MetricsStrip.tsx +48 -0
- package/carbon/src/client/components/Nav.tsx +195 -0
- package/carbon/src/client/components/NewsletterSignup.tsx +147 -0
- package/carbon/src/client/components/NotificationDrawer.tsx +171 -0
- package/carbon/src/client/components/PageHeader.tsx +24 -0
- package/carbon/src/client/components/PlanGate.tsx +36 -0
- package/carbon/src/client/components/PricingSection.tsx +371 -0
- package/carbon/src/client/components/SEO.tsx +34 -0
- package/carbon/src/client/components/SmoothScroll.tsx +45 -0
- package/carbon/src/client/components/TechStackSection.tsx +165 -0
- package/carbon/src/client/components/WorkflowSection.tsx +604 -0
- package/carbon/src/client/components/admin/DockerLogs.tsx +276 -0
- package/carbon/src/client/components/admin/PerformanceMetrics.tsx +230 -0
- package/carbon/src/client/components/admin/ServiceCard.tsx +45 -0
- package/carbon/src/client/components/admin/ServicesStatus.tsx +296 -0
- package/carbon/src/client/components/architecture/ArchitectureDiagram.tsx +413 -0
- package/carbon/src/client/components/auth/AuthProvider.tsx +466 -0
- package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +35 -0
- package/carbon/src/client/components/effects/FresnelEdge.tsx +49 -0
- package/carbon/src/client/components/effects/GlowTracker.tsx +46 -0
- package/carbon/src/client/components/effects/SparkBurst.tsx +145 -0
- package/carbon/src/client/components/effects/VGlowEffect.tsx +83 -0
- package/carbon/src/client/components/effects/index.ts +2 -0
- package/carbon/src/client/components/layouts/SidebarLayout.tsx +20 -0
- package/carbon/src/client/components/scroll/ScrollSection.tsx +76 -0
- package/carbon/src/client/components/scroll/ScrollytellingProvider.tsx +81 -0
- package/carbon/src/client/components/scroll/index.ts +2 -0
- package/carbon/src/client/components/ui/accordion.tsx +71 -0
- package/carbon/src/client/components/ui/alert-dialog.tsx +162 -0
- package/carbon/src/client/components/ui/alert.tsx +73 -0
- package/carbon/src/client/components/ui/aspect-ratio.tsx +22 -0
- package/carbon/src/client/components/ui/avatar.tsx +91 -0
- package/carbon/src/client/components/ui/badge.tsx +50 -0
- package/carbon/src/client/components/ui/breadcrumb.tsx +100 -0
- package/carbon/src/client/components/ui/button-group.tsx +78 -0
- package/carbon/src/client/components/ui/button.tsx +120 -0
- package/carbon/src/client/components/ui/calendar.tsx +182 -0
- package/carbon/src/client/components/ui/card.tsx +85 -0
- package/carbon/src/client/components/ui/carousel.tsx +227 -0
- package/carbon/src/client/components/ui/chart.tsx +357 -0
- package/carbon/src/client/components/ui/checkbox.tsx +27 -0
- package/carbon/src/client/components/ui/collapsible.tsx +15 -0
- package/carbon/src/client/components/ui/command.tsx +178 -0
- package/carbon/src/client/components/ui/context-menu.tsx +233 -0
- package/carbon/src/client/components/ui/dialog.tsx +132 -0
- package/carbon/src/client/components/ui/drawer.tsx +118 -0
- package/carbon/src/client/components/ui/dropdown-menu.tsx +242 -0
- package/carbon/src/client/components/ui/empty.tsx +94 -0
- package/carbon/src/client/components/ui/field.tsx +226 -0
- package/carbon/src/client/components/ui/hover-card.tsx +44 -0
- package/carbon/src/client/components/ui/input-group.tsx +146 -0
- package/carbon/src/client/components/ui/input-otp.tsx +83 -0
- package/carbon/src/client/components/ui/input.tsx +20 -0
- package/carbon/src/client/components/ui/item.tsx +188 -0
- package/carbon/src/client/components/ui/kbd.tsx +26 -0
- package/carbon/src/client/components/ui/label.tsx +21 -0
- package/carbon/src/client/components/ui/menubar.tsx +250 -0
- package/carbon/src/client/components/ui/navigation-menu.tsx +155 -0
- package/carbon/src/client/components/ui/pagination.tsx +102 -0
- package/carbon/src/client/components/ui/popover.tsx +75 -0
- package/carbon/src/client/components/ui/progress.tsx +66 -0
- package/carbon/src/client/components/ui/radio-group.tsx +36 -0
- package/carbon/src/client/components/ui/resizable.tsx +46 -0
- package/carbon/src/client/components/ui/scroll-area.tsx +48 -0
- package/carbon/src/client/components/ui/select.tsx +186 -0
- package/carbon/src/client/components/ui/separator.tsx +21 -0
- package/carbon/src/client/components/ui/sheet.tsx +124 -0
- package/carbon/src/client/components/ui/sidebar.tsx +702 -0
- package/carbon/src/client/components/ui/skeleton.tsx +13 -0
- package/carbon/src/client/components/ui/slider.tsx +57 -0
- package/carbon/src/client/components/ui/sonner.tsx +45 -0
- package/carbon/src/client/components/ui/spinner.tsx +15 -0
- package/carbon/src/client/components/ui/switch.tsx +30 -0
- package/carbon/src/client/components/ui/table.tsx +89 -0
- package/carbon/src/client/components/ui/tabs.tsx +73 -0
- package/carbon/src/client/components/ui/textarea.tsx +18 -0
- package/carbon/src/client/components/ui/toggle-group.tsx +87 -0
- package/carbon/src/client/components/ui/toggle.tsx +44 -0
- package/carbon/src/client/components/ui/tooltip.tsx +56 -0
- package/carbon/src/client/hooks/api/index.ts +14 -0
- package/carbon/src/client/hooks/api/useAuthSettings.ts +80 -0
- package/carbon/src/client/hooks/api/useSubscription.ts +87 -0
- package/carbon/src/client/hooks/use-mobile.ts +21 -0
- package/carbon/src/client/hooks/useMousePosition.ts +91 -0
- package/carbon/src/client/hooks/useNotifications.tsx +124 -0
- package/carbon/src/client/hooks/useOrganizationMembers.ts +127 -0
- package/carbon/src/client/hooks/useOrganizations.tsx +230 -0
- package/carbon/src/client/hooks/usePackageManager.ts +69 -0
- package/carbon/src/client/hooks/useReducedMotion.ts +20 -0
- package/carbon/src/client/hooks/useRunningServices.ts +114 -0
- package/carbon/src/client/hooks/useScrollProgress.ts +56 -0
- package/carbon/src/client/index.css +467 -0
- package/carbon/src/client/index.html +56 -0
- package/carbon/src/client/lib/admin-services.ts +151 -0
- package/carbon/src/client/lib/api.ts +32 -0
- package/carbon/src/client/lib/blog.ts +35 -0
- package/carbon/src/client/lib/changelog.ts +33 -0
- package/carbon/src/client/lib/docs-search.ts +101 -0
- package/carbon/src/client/lib/docs.ts +37 -0
- package/carbon/src/client/lib/i18n.ts +32 -0
- package/carbon/src/client/lib/supabase.ts +72 -0
- package/carbon/src/client/lib/tailwind-colors.ts +357 -0
- package/carbon/src/client/lib/theme.ts +117 -0
- package/carbon/src/client/lib/utils.ts +22 -0
- package/carbon/src/client/locales/de.json +529 -0
- package/carbon/src/client/locales/en.json +461 -0
- package/carbon/src/client/locales/es.json +529 -0
- package/carbon/src/client/locales/fr.json +529 -0
- package/carbon/src/client/locales/pt.json +529 -0
- package/carbon/src/client/main.tsx +56 -0
- package/carbon/src/client/mdx.d.ts +13 -0
- package/carbon/src/client/pages/ApiDocs.tsx +76 -0
- package/carbon/src/client/pages/AuthCallback.tsx +34 -0
- package/carbon/src/client/pages/Blog.tsx +167 -0
- package/carbon/src/client/pages/Changelog.tsx +171 -0
- package/carbon/src/client/pages/Charts.tsx +388 -0
- package/carbon/src/client/pages/Checkout.tsx +227 -0
- package/carbon/src/client/pages/Contact.tsx +174 -0
- package/carbon/src/client/pages/Dashboard.tsx +368 -0
- package/carbon/src/client/pages/Docs.tsx +372 -0
- package/carbon/src/client/pages/ForgotPassword.tsx +111 -0
- package/carbon/src/client/pages/Home.tsx +187 -0
- package/carbon/src/client/pages/Legal.tsx +100 -0
- package/carbon/src/client/pages/Login.tsx +408 -0
- package/carbon/src/client/pages/MFAVerify.tsx +156 -0
- package/carbon/src/client/pages/NotFound.tsx +21 -0
- package/carbon/src/client/pages/Onboarding.tsx +246 -0
- package/carbon/src/client/pages/ResetPassword.tsx +200 -0
- package/carbon/src/client/pages/UIComponents.tsx +390 -0
- package/carbon/src/client/pages/admin/ContactSubmissions.tsx +220 -0
- package/carbon/src/client/pages/admin/Dashboard.tsx +24 -0
- package/carbon/src/client/pages/admin/Infrastructure.tsx +257 -0
- package/carbon/src/client/pages/admin/Jobs.tsx +225 -0
- package/carbon/src/client/pages/admin/Logs.tsx +18 -0
- package/carbon/src/client/pages/admin/Newsletter.tsx +300 -0
- package/carbon/src/client/pages/admin/Notifications.tsx +603 -0
- package/carbon/src/client/pages/admin/Organizations.tsx +306 -0
- package/carbon/src/client/pages/admin/Settings.tsx +314 -0
- package/carbon/src/client/pages/admin/Theme.tsx +465 -0
- package/carbon/src/client/pages/admin/Users.tsx +365 -0
- package/carbon/src/client/pages/api-docs.css +156 -0
- package/carbon/src/client/pages/organizations/Details.tsx +200 -0
- package/carbon/src/client/pages/organizations/Members.tsx +402 -0
- package/carbon/src/client/pages/settings/Billing.tsx +473 -0
- package/carbon/src/client/pages/settings/Profile.tsx +160 -0
- package/carbon/src/client/pages/settings/Security.tsx +341 -0
- package/carbon/src/client/public/favicon.svg +19 -0
- package/carbon/src/client/public/robots.txt +8 -0
- package/carbon/src/server/billing/index.ts +104 -0
- package/carbon/src/server/billing/provider.ts +81 -0
- package/carbon/src/server/billing/providers/paddle.ts +314 -0
- package/carbon/src/server/billing/providers/polar.ts +325 -0
- package/carbon/src/server/billing/providers/stripe.ts +233 -0
- package/carbon/src/server/emails/templates.ts +116 -0
- package/carbon/src/server/index.ts +554 -0
- package/carbon/src/server/lib/auth.ts +6 -0
- package/carbon/src/server/lib/email.ts +64 -0
- package/carbon/src/server/lib/env.ts +112 -0
- package/carbon/src/server/lib/errors.ts +21 -0
- package/carbon/src/server/lib/logger.ts +17 -0
- package/carbon/src/server/lib/rate-limiter.ts +288 -0
- package/carbon/src/server/lib/request.ts +34 -0
- package/carbon/src/server/lib/stripe.ts +42 -0
- package/carbon/src/server/lib/supabase.ts +65 -0
- package/carbon/src/server/middleware/requirePlan.ts +80 -0
- package/carbon/src/server/routes/_internal/services-status.ts +958 -0
- package/carbon/src/server/routes/_internal/verify-role.ts +185 -0
- package/carbon/src/server/routes/health.ts +48 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +128 -0
- package/carbon/src/server/routes/v1/admin/jobs.ts +171 -0
- package/carbon/src/server/routes/v1/admin/newsletter.ts +237 -0
- package/carbon/src/server/routes/v1/auth.ts +390 -0
- package/carbon/src/server/routes/v1/billing.ts +718 -0
- package/carbon/src/server/routes/v1/contact.ts +93 -0
- package/carbon/src/server/routes/v1/index.ts +1333 -0
- package/carbon/src/server/routes/v1/newsletter.ts +181 -0
- package/carbon/src/server/routes/v1/performance.ts +157 -0
- package/carbon/src/server/routes/v1/stats.ts +170 -0
- package/carbon/src/server/routes/v1/theme.ts +106 -0
- package/carbon/src/server/routes/webhooks/billing.ts +376 -0
- package/carbon/src/server/routes/webhooks/stripe.ts +276 -0
- package/carbon/src/server/types.ts +11 -0
- package/carbon/src/shared/pricing.ts +155 -0
- package/carbon/src/shared/types.ts +338 -0
- package/carbon/supabase/migrations/00001_init.sql +717 -0
- package/carbon/supabase/migrations/00002_theme_settings.sql +13 -0
- package/carbon/supabase/migrations/00003_pg_cron.sql +121 -0
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +81 -0
- package/carbon/supabase/migrations/00005_localization_languages.sql +22 -0
- package/carbon/supabase/seed.sql +16 -0
- package/carbon/tests/_helpers/app.ts +45 -0
- package/carbon/tests/_helpers/env.ts +37 -0
- package/carbon/tests/_helpers/factories.ts +69 -0
- package/carbon/tests/_helpers/jwt.ts +23 -0
- package/carbon/tests/_helpers/setup-integration.ts +20 -0
- package/carbon/tests/_helpers/setup-rtl.ts +12 -0
- package/carbon/tests/component/ErrorBoundary.test.tsx +53 -0
- package/carbon/tests/component/use-auth-settings.test.tsx +119 -0
- package/carbon/tests/integration/server/routes/contact.test.ts +162 -0
- package/carbon/tests/integration/server/routes/health.test.ts +61 -0
- package/carbon/tests/structural/i18n-parity.test.ts +42 -0
- package/carbon/tests/unit/client/utils.test.ts +49 -0
- package/carbon/tests/unit/shared/pricing.test.ts +93 -0
- package/carbon/tsconfig.json +27 -0
- package/carbon/tsconfig.server.json +27 -0
- package/carbon/tsconfig.test.json +9 -0
- package/carbon/vite.config.ts +110 -0
- package/carbon/vitest.config.ts +74 -0
- package/carbon/volumes/db/jwt.sql +57 -0
- package/carbon/volumes/db/metabase-init.sh +29 -0
- package/carbon/volumes/db/n8n-init.sh +25 -0
- package/carbon/volumes/db/realtime.sql +33 -0
- package/carbon/volumes/db/roles.sql +93 -0
- package/carbon/volumes/db/set-passwords.sh +12 -0
- package/carbon/volumes/db/super-admin.dev.sql +113 -0
- package/carbon/volumes/db/super-admin.generated.sql +113 -0
- package/carbon/volumes/db/super-admin.sql +114 -0
- package/carbon/volumes/grafana/dashboards/logs.json +179 -0
- package/carbon/volumes/grafana/dashboards/overview.json +523 -0
- package/carbon/volumes/grafana/dashboards/postgresql.json +337 -0
- package/carbon/volumes/grafana/dashboards.dev/logs.json +179 -0
- package/carbon/volumes/grafana/dashboards.dev/overview.json +156 -0
- package/carbon/volumes/grafana/dashboards.dev/postgresql.json +337 -0
- package/carbon/volumes/grafana/provisioning/dashboards/dashboards.dev.yml +16 -0
- package/carbon/volumes/grafana/provisioning/dashboards/dashboards.yml +16 -0
- package/carbon/volumes/grafana/provisioning/datasources/datasources.yml +43 -0
- package/carbon/volumes/kong/docker-entrypoint.sh +9 -0
- package/carbon/volumes/kong/kong.yml +208 -0
- package/carbon/volumes/loki/loki-config.yml +58 -0
- package/carbon/volumes/n8n/hooks.js +131 -0
- package/carbon/volumes/n8n/scripts/setup.sh +66 -0
- package/carbon/volumes/prometheus/prometheus.yml +43 -0
- package/carbon/volumes/promtail/promtail-config.yml +64 -0
- package/carbon/volumes/traefik/middlewares.dev.yml +83 -0
- package/carbon/volumes/traefik/middlewares.yml +95 -0
- package/carbon/volumes/traefik/vite-dev.yml +20 -0
- package/package.json +95 -0
- package/src/access.js +354 -0
- package/src/activate.js +187 -0
- package/src/add.js +718 -0
- package/src/backup.js +786 -0
- package/src/cli.js +350 -0
- package/src/configure.js +967 -0
- package/src/console.js +155 -0
- package/src/create.js +1499 -0
- package/src/deploy.js +311 -0
- package/src/destroy.js +2033 -0
- package/src/diagnose.js +735 -0
- package/src/down.js +80 -0
- package/src/failover.js +1032 -0
- package/src/lib/backup-s3.js +179 -0
- package/src/lib/build.js +33 -0
- package/src/lib/checksum.js +28 -0
- package/src/lib/ci-setup.js +666 -0
- package/src/lib/cli/help.js +129 -0
- package/src/lib/cli/parse-flags.js +160 -0
- package/src/lib/cli/select-action.js +65 -0
- package/src/lib/cli/select-environment.js +108 -0
- package/src/lib/cli/tty-guard.js +75 -0
- package/src/lib/cloudflare.js +447 -0
- package/src/lib/colors.js +52 -0
- package/src/lib/command.js +361 -0
- package/src/lib/config.js +359 -0
- package/src/lib/cost.js +103 -0
- package/src/lib/deploy/bundle.js +231 -0
- package/src/lib/deploy/compose/acme-verify.js +121 -0
- package/src/lib/deploy/compose/build-args.js +78 -0
- package/src/lib/deploy/compose/ha.js +1874 -0
- package/src/lib/deploy/compose/index.js +1294 -0
- package/src/lib/deploy/compose/reconcile.js +74 -0
- package/src/lib/deploy/github.js +382 -0
- package/src/lib/deploy/image.js +191 -0
- package/src/lib/deploy/index.js +119 -0
- package/src/lib/deploy/k8s/LICENSE +99 -0
- package/src/lib/deploy/k8s/gitops-deploy.js +298 -0
- package/src/lib/deploy/k8s/ha/index.js +1000 -0
- package/src/lib/deploy/k8s/index.js +62 -0
- package/src/lib/deploy/k8s/k3s.js +2515 -0
- package/src/lib/deploy/orchestrator.js +1401 -0
- package/src/lib/deploy/prompts.js +545 -0
- package/src/lib/deploy/remote-build.js +130 -0
- package/src/lib/deploy/state.js +120 -0
- package/src/lib/deploy/utils.js +328 -0
- package/src/lib/deploy-logger.js +93 -0
- package/src/lib/dns-propagation.js +48 -0
- package/src/lib/environment.js +58 -0
- package/src/lib/fetch-retry.js +103 -0
- package/src/lib/github-environments.js +335 -0
- package/src/lib/hetzner-dns.js +377 -0
- package/src/lib/hetzner-guided-setup.js +275 -0
- package/src/lib/host-keys.js +37 -0
- package/src/lib/iac/cloud-init.js +52 -0
- package/src/lib/iac/index.js +325 -0
- package/src/lib/iac/programs/hetzner-compose.js +130 -0
- package/src/lib/iac/programs/hetzner-k8s.js +320 -0
- package/src/lib/kubectl.js +81 -0
- package/src/lib/licensing/index.js +259 -0
- package/src/lib/licensing/tiers.js +181 -0
- package/src/lib/licensing/validator.js +171 -0
- package/src/lib/merge-package-json.js +90 -0
- package/src/lib/operator-ip.js +381 -0
- package/src/lib/package-manager.js +111 -0
- package/src/lib/perf.js +71 -0
- package/src/lib/project-guard.js +39 -0
- package/src/lib/project.js +334 -0
- package/src/lib/providers/base.js +276 -0
- package/src/lib/providers/hetzner-s3.js +656 -0
- package/src/lib/providers/hetzner.js +755 -0
- package/src/lib/providers/index.js +164 -0
- package/src/lib/s3.js +510 -0
- package/src/lib/secret-scan.js +583 -0
- package/src/lib/secrets.js +63 -0
- package/src/lib/server-types.js +195 -0
- package/src/lib/shell.js +91 -0
- package/src/lib/ssh.js +241 -0
- package/src/lib/tracker.js +242 -0
- package/src/lib/upgrade-policy.js +170 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/version.js +5 -0
- package/src/remove.js +292 -0
- package/src/reset.js +97 -0
- package/src/restore.js +871 -0
- package/src/scale.js +1734 -0
- package/src/shell.js +222 -0
- package/src/status.js +981 -0
- package/src/up.js +264 -0
- package/src/upgrade.js +721 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hetzner Cloud DNS Operations
|
|
3
|
+
*
|
|
4
|
+
* DNS management through the Hetzner Cloud API (api.hetzner.cloud/v1).
|
|
5
|
+
* Uses the same API token as server operations — no additional credentials needed.
|
|
6
|
+
*
|
|
7
|
+
* The Cloud API uses `/zones/{id}/rrsets` for record management (not `/records`).
|
|
8
|
+
* Updates are performed via delete + create (PUT on rrsets returns 422).
|
|
9
|
+
*
|
|
10
|
+
* API Documentation: https://docs.hetzner.cloud/
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as p from '@clack/prompts';
|
|
14
|
+
import { fetchWithRetry } from './fetch-retry.js';
|
|
15
|
+
import { HetznerProvider } from './providers/hetzner.js';
|
|
16
|
+
|
|
17
|
+
const API_BASE = HetznerProvider.API_BASE;
|
|
18
|
+
|
|
19
|
+
// Default TTL for HA records (low for fast failover propagation)
|
|
20
|
+
const HA_TTL = 60;
|
|
21
|
+
// Default TTL for standard records (low to prevent stale DNS between test runs)
|
|
22
|
+
const DEFAULT_TTL = 60;
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// HELPERS
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Convert an FQDN to a zone-relative name for the Hetzner DNS API.
|
|
30
|
+
*
|
|
31
|
+
* Hetzner DNS records use names relative to the zone:
|
|
32
|
+
* example.com (zone: example.com) → "@"
|
|
33
|
+
* *.example.com (zone: example.com) → "*"
|
|
34
|
+
* sub.example.com (zone: example.com) → "sub"
|
|
35
|
+
* a.b.example.com (zone: example.com) → "a.b"
|
|
36
|
+
*
|
|
37
|
+
* @param {string} fqdn - Fully qualified domain name
|
|
38
|
+
* @param {string} zoneName - Zone name (e.g. "example.com")
|
|
39
|
+
* @returns {string} Zone-relative record name
|
|
40
|
+
*/
|
|
41
|
+
export function fqdnToRelative(fqdn, zoneName) {
|
|
42
|
+
// Strip trailing dots
|
|
43
|
+
const clean = fqdn.replace(/\.$/, '');
|
|
44
|
+
const zone = zoneName.replace(/\.$/, '');
|
|
45
|
+
|
|
46
|
+
if (clean === zone) return '@';
|
|
47
|
+
if (clean === `*.${zone}`) return '*';
|
|
48
|
+
if (clean.endsWith(`.${zone}`)) {
|
|
49
|
+
return clean.slice(0, -(zone.length + 1));
|
|
50
|
+
}
|
|
51
|
+
// If no match, return as-is (caller may have already passed a relative name)
|
|
52
|
+
return clean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// LOW-LEVEL API FUNCTIONS
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get all DNS zones in the Hetzner Cloud project.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} apiToken - Hetzner Cloud API token
|
|
63
|
+
* @returns {Promise<Array>} Array of zone objects
|
|
64
|
+
*/
|
|
65
|
+
export async function getZones(apiToken) {
|
|
66
|
+
const zones = [];
|
|
67
|
+
let page = 1;
|
|
68
|
+
|
|
69
|
+
while (true) {
|
|
70
|
+
const response = await fetchWithRetry(`${API_BASE}/zones?page=${page}&per_page=50`, {
|
|
71
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const error = await response.json().catch(() => ({}));
|
|
76
|
+
throw new Error(`Hetzner DNS API error: ${error.error?.message || response.statusText}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const data = await response.json();
|
|
80
|
+
zones.push(...(data.zones || []));
|
|
81
|
+
|
|
82
|
+
if (!data.meta?.pagination?.next_page) break;
|
|
83
|
+
page = data.meta.pagination.next_page;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return zones;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get a single DNS zone by ID.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} apiToken - Hetzner Cloud API token
|
|
93
|
+
* @param {string} zoneId - Zone ID
|
|
94
|
+
* @returns {Promise<object>} Zone object (includes `name`)
|
|
95
|
+
*/
|
|
96
|
+
export async function getZone(apiToken, zoneId) {
|
|
97
|
+
const response = await fetchWithRetry(`${API_BASE}/zones/${zoneId}`, {
|
|
98
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
const error = await response.json().catch(() => ({}));
|
|
103
|
+
throw new Error(`Hetzner DNS API error: ${error.error?.message || response.statusText}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const data = await response.json();
|
|
107
|
+
return data.zone;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* List all rrsets (record sets) in a DNS zone.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} apiToken - Hetzner Cloud API token
|
|
114
|
+
* @param {string} zoneId - Zone ID
|
|
115
|
+
* @returns {Promise<Array>} Array of rrset objects
|
|
116
|
+
*/
|
|
117
|
+
export async function getRrsets(apiToken, zoneId) {
|
|
118
|
+
const rrsets = [];
|
|
119
|
+
let page = 1;
|
|
120
|
+
|
|
121
|
+
while (true) {
|
|
122
|
+
const response = await fetchWithRetry(
|
|
123
|
+
`${API_BASE}/zones/${zoneId}/rrsets?page=${page}&per_page=100`,
|
|
124
|
+
{
|
|
125
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
const error = await response.json().catch(() => ({}));
|
|
131
|
+
throw new Error(`Hetzner DNS API error: ${error.error?.message || response.statusText}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const data = await response.json();
|
|
135
|
+
rrsets.push(...(data.rrsets || []));
|
|
136
|
+
|
|
137
|
+
if (!data.meta?.pagination?.next_page) break;
|
|
138
|
+
page = data.meta.pagination.next_page;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return rrsets;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Create or update a DNS record.
|
|
146
|
+
*
|
|
147
|
+
* Uses the /rrsets endpoint. If a matching rrset (same type + name) exists,
|
|
148
|
+
* deletes it first then creates a new one (PUT is not supported on rrsets).
|
|
149
|
+
*
|
|
150
|
+
* @param {string} apiToken - Hetzner Cloud API token
|
|
151
|
+
* @param {string} zoneId - Zone ID
|
|
152
|
+
* @param {string} zoneName - Zone name (e.g. "example.com") for FQDN→relative conversion
|
|
153
|
+
* @param {object} config - Record config
|
|
154
|
+
* @param {string} config.type - Record type (A, AAAA, CNAME, etc.)
|
|
155
|
+
* @param {string} config.name - Record name (FQDN — converted to relative internally)
|
|
156
|
+
* @param {string} config.value - Record value (e.g. IP address)
|
|
157
|
+
* @param {number} [config.ttl] - TTL in seconds (default: 3600)
|
|
158
|
+
* @returns {Promise<object>} Created rrset object
|
|
159
|
+
*/
|
|
160
|
+
export async function createDNSRecord(apiToken, zoneId, zoneName, config) {
|
|
161
|
+
const { type, name, value, ttl = DEFAULT_TTL } = config;
|
|
162
|
+
const relativeName = fqdnToRelative(name, zoneName);
|
|
163
|
+
|
|
164
|
+
// Delete existing rrset if present (PUT is not supported, so we delete + create)
|
|
165
|
+
const existing = await getRrsets(apiToken, zoneId);
|
|
166
|
+
const match = existing.find((r) => r.type === type && r.name === relativeName);
|
|
167
|
+
|
|
168
|
+
if (match) {
|
|
169
|
+
const delResponse = await fetchWithRetry(
|
|
170
|
+
`${API_BASE}/zones/${zoneId}/rrsets/${relativeName}/${type}`,
|
|
171
|
+
{
|
|
172
|
+
method: 'DELETE',
|
|
173
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
if (!delResponse.ok) {
|
|
177
|
+
const error = await delResponse.json().catch(() => ({}));
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Failed to remove existing DNS record: ${error.error?.message || delResponse.statusText}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Create new rrset
|
|
185
|
+
const response = await fetchWithRetry(`${API_BASE}/zones/${zoneId}/rrsets`, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: {
|
|
188
|
+
Authorization: `Bearer ${apiToken}`,
|
|
189
|
+
'Content-Type': 'application/json',
|
|
190
|
+
},
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
name: relativeName,
|
|
193
|
+
type,
|
|
194
|
+
ttl,
|
|
195
|
+
records: [{ value }],
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
const error = await response.json().catch(() => ({}));
|
|
201
|
+
throw new Error(`Failed to create DNS record: ${error.error?.message || response.statusText}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const data = await response.json();
|
|
205
|
+
return data.rrset;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Delete a DNS rrset by FQDN and type — but only if every value in the
|
|
210
|
+
* rrset belongs to this caller's stack.
|
|
211
|
+
*
|
|
212
|
+
* Hetzner stores DNS records as rrsets (one row per name+type, with an
|
|
213
|
+
* array of values). The destroy path historically wiped the rrset
|
|
214
|
+
* unconditionally, which was fine for single-tenant zones but blasted
|
|
215
|
+
* shared zones when e2e scenarios reused the same root domain
|
|
216
|
+
* (the 2026-05-16 matrix saw compose-ha's destroy zero compose's
|
|
217
|
+
* `e1.carbonstack.dev` mid-verify).
|
|
218
|
+
*
|
|
219
|
+
* Ownership rule:
|
|
220
|
+
* - If every value in the rrset is in `ownedIps`, DELETE the rrset.
|
|
221
|
+
* - If any value is NOT owned, preserve the entire rrset (we don't
|
|
222
|
+
* PATCH out individual records — Hetzner's API forces delete+recreate
|
|
223
|
+
* anyway, and a stale recreate risks dropping the others' values).
|
|
224
|
+
* - If `ownedIps` is empty, preserve (safer default when envConfig
|
|
225
|
+
* has no IPs to verify against).
|
|
226
|
+
*
|
|
227
|
+
* @param {string} apiToken - Hetzner Cloud API token
|
|
228
|
+
* @param {string} zoneId - Zone ID
|
|
229
|
+
* @param {string} zoneName - Zone name for FQDN→relative conversion
|
|
230
|
+
* @param {string} name - Record name (FQDN)
|
|
231
|
+
* @param {string[]} ownedIps - IPs that belong to this caller's stack
|
|
232
|
+
* @param {string} [type='A'] - Record type
|
|
233
|
+
* @returns {Promise<{deleted: boolean, skipped: boolean, foundValues: string[], skippedTargets: string[]}>}
|
|
234
|
+
*/
|
|
235
|
+
export async function deleteDNSRecord(apiToken, zoneId, zoneName, name, ownedIps, type = 'A') {
|
|
236
|
+
const relativeName = fqdnToRelative(name, zoneName);
|
|
237
|
+
|
|
238
|
+
// Inspect the rrset before deleting. The list endpoint surfaces all
|
|
239
|
+
// values for a (name, type) tuple as a single rrset row.
|
|
240
|
+
const rrsets = await getRrsets(apiToken, zoneId);
|
|
241
|
+
const match = rrsets.find((r) => r.type === type && r.name === relativeName);
|
|
242
|
+
|
|
243
|
+
if (!match) {
|
|
244
|
+
return { deleted: false, skipped: false, foundValues: [], skippedTargets: [] };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const values = (match.records || []).map((r) => r.value);
|
|
248
|
+
const owned = new Set(ownedIps || []);
|
|
249
|
+
const unowned = values.filter((v) => !owned.has(v));
|
|
250
|
+
|
|
251
|
+
if (unowned.length > 0) {
|
|
252
|
+
// At least one value belongs to someone else — leave the whole rrset
|
|
253
|
+
// alone. Surfaces to the caller so they can log what was preserved.
|
|
254
|
+
return { deleted: false, skipped: true, foundValues: values, skippedTargets: unowned };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const response = await fetchWithRetry(
|
|
258
|
+
`${API_BASE}/zones/${zoneId}/rrsets/${relativeName}/${type}`,
|
|
259
|
+
{
|
|
260
|
+
method: 'DELETE',
|
|
261
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
deleted: response.ok,
|
|
267
|
+
skipped: false,
|
|
268
|
+
foundValues: values,
|
|
269
|
+
skippedTargets: [],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ============================================================================
|
|
274
|
+
// HIGH-LEVEL SETUP FUNCTIONS
|
|
275
|
+
// ============================================================================
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Set up Hetzner DNS for a simple single-server deployment.
|
|
279
|
+
* Creates root A and wildcard A records.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} apiToken - Hetzner Cloud API token
|
|
282
|
+
* @param {string} zoneId - DNS zone ID
|
|
283
|
+
* @param {string} domain - Domain name (e.g. "app.example.com")
|
|
284
|
+
* @param {string} serverIp - Server IP address
|
|
285
|
+
* @param {object} [options] - Options
|
|
286
|
+
* @param {Function} [options.onProgress] - Progress callback (skips internal spinners)
|
|
287
|
+
* @returns {Promise<object>} Setup result
|
|
288
|
+
*/
|
|
289
|
+
export async function setupSimple(apiToken, zoneId, domain, serverIp, options = {}) {
|
|
290
|
+
const { onProgress } = options;
|
|
291
|
+
const s = onProgress ? null : p.spinner();
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
s?.start(`Creating DNS records for ${domain}`);
|
|
295
|
+
onProgress?.(`Creating DNS records for ${domain}`);
|
|
296
|
+
|
|
297
|
+
// Fetch zone name for FQDN→relative conversion
|
|
298
|
+
const zone = await getZone(apiToken, zoneId);
|
|
299
|
+
const zoneName = zone.name;
|
|
300
|
+
|
|
301
|
+
// Root A record
|
|
302
|
+
await createDNSRecord(apiToken, zoneId, zoneName, {
|
|
303
|
+
type: 'A',
|
|
304
|
+
name: domain,
|
|
305
|
+
value: serverIp,
|
|
306
|
+
ttl: DEFAULT_TTL,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Wildcard A record for subdomains (registry, api, etc.)
|
|
310
|
+
await createDNSRecord(apiToken, zoneId, zoneName, {
|
|
311
|
+
type: 'A',
|
|
312
|
+
name: `*.${domain}`,
|
|
313
|
+
value: serverIp,
|
|
314
|
+
ttl: DEFAULT_TTL,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
s?.stop('DNS records created (root + wildcard)');
|
|
318
|
+
onProgress?.('DNS records created (root + wildcard)');
|
|
319
|
+
|
|
320
|
+
return { success: true, mode: 'simple' };
|
|
321
|
+
} catch (error) {
|
|
322
|
+
s?.stop(`Hetzner DNS setup failed: ${error.message}`);
|
|
323
|
+
return { success: false, error: error.message };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Set up Hetzner DNS for an HA deployment.
|
|
329
|
+
* Creates root A and wildcard A records pointing to the primary server,
|
|
330
|
+
* with a low TTL for fast failover propagation.
|
|
331
|
+
*
|
|
332
|
+
* Note: Hetzner DNS has no health checks or load balancers.
|
|
333
|
+
* Failover is triggered manually via `vibecarbon failover`.
|
|
334
|
+
*
|
|
335
|
+
* @param {string} apiToken - Hetzner Cloud API token
|
|
336
|
+
* @param {string} zoneId - DNS zone ID
|
|
337
|
+
* @param {string} domain - Domain name
|
|
338
|
+
* @param {Array} servers - Array of { name, ip } objects (primary first)
|
|
339
|
+
* @returns {Promise<object>} Setup result
|
|
340
|
+
*/
|
|
341
|
+
export async function setupHA(apiToken, zoneId, domain, servers) {
|
|
342
|
+
const s = p.spinner();
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
// Fetch zone name for FQDN→relative conversion
|
|
346
|
+
const zone = await getZone(apiToken, zoneId);
|
|
347
|
+
const zoneName = zone.name;
|
|
348
|
+
|
|
349
|
+
// Root A record (low TTL for fast failover)
|
|
350
|
+
s.start(`Creating DNS record for ${domain}`);
|
|
351
|
+
await createDNSRecord(apiToken, zoneId, zoneName, {
|
|
352
|
+
type: 'A',
|
|
353
|
+
name: domain,
|
|
354
|
+
value: servers[0].ip,
|
|
355
|
+
ttl: HA_TTL,
|
|
356
|
+
});
|
|
357
|
+
s.stop('Primary DNS record created');
|
|
358
|
+
|
|
359
|
+
// Wildcard A record (low TTL for fast failover)
|
|
360
|
+
await createDNSRecord(apiToken, zoneId, zoneName, {
|
|
361
|
+
type: 'A',
|
|
362
|
+
name: `*.${domain}`,
|
|
363
|
+
value: servers[0].ip,
|
|
364
|
+
ttl: HA_TTL,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
success: true,
|
|
369
|
+
mode: 'dns',
|
|
370
|
+
primaryIp: servers[0].ip,
|
|
371
|
+
standbyIp: servers[1]?.ip,
|
|
372
|
+
};
|
|
373
|
+
} catch (error) {
|
|
374
|
+
s.stop(`Hetzner DNS setup failed: ${error.message}`);
|
|
375
|
+
return { success: false, error: error.message };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hetzner Guided Setup - Interactive prompts for first-time setup
|
|
3
|
+
*
|
|
4
|
+
* This module provides improved DX for users setting up Hetzner credentials:
|
|
5
|
+
* 1. Creating a Hetzner API token
|
|
6
|
+
* 2. Creating S3 Object Storage credentials
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Clear visual guides with step-by-step instructions
|
|
10
|
+
* - Better validation and error messages
|
|
11
|
+
* - Lookup chain: env var → ~/.vibecarbon/credentials.json → interactive prompt
|
|
12
|
+
* - Single "Save keys for future deploys?" prompt applies to all credentials
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as p from '@clack/prompts';
|
|
16
|
+
import { c } from './colors.js';
|
|
17
|
+
import { loadCredentials, saveCredentials } from './config.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate a Hetzner API token by making a lightweight API call.
|
|
21
|
+
* @param {string} token
|
|
22
|
+
* @returns {Promise<{ valid: boolean, error?: string }>}
|
|
23
|
+
*/
|
|
24
|
+
async function validateHetznerToken(token) {
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch('https://api.hetzner.cloud/v1/locations?per_page=1', {
|
|
27
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
28
|
+
});
|
|
29
|
+
if (res.ok) return { valid: true };
|
|
30
|
+
if (res.status === 401) return { valid: false, error: 'Token is invalid or expired' };
|
|
31
|
+
return { valid: false, error: `Hetzner API returned status ${res.status}` };
|
|
32
|
+
} catch {
|
|
33
|
+
return { valid: true, unreachable: true };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Display improved visual guide for API token creation
|
|
39
|
+
*/
|
|
40
|
+
function displayApiTokenGuide(projectName) {
|
|
41
|
+
p.log.info('');
|
|
42
|
+
p.log.info(`${c.boldCyan('🔑 Hetzner API Token Setup')}`);
|
|
43
|
+
p.log.info('');
|
|
44
|
+
p.log.info(c.dim('─'.repeat(60)));
|
|
45
|
+
p.log.info('');
|
|
46
|
+
p.log.info(`${c.bold('1.')} Open: ${c.boldCyanUnderline('https://console.hetzner.cloud/')}`);
|
|
47
|
+
p.log.info(
|
|
48
|
+
`${c.dim(' └─')} Select ${c.info('+ New project')} and call it ${c.info(projectName || 'your-project')}`,
|
|
49
|
+
);
|
|
50
|
+
p.log.info('');
|
|
51
|
+
p.log.info(`${c.bold('2.')} In the sidebar: ${c.info('Security')}`);
|
|
52
|
+
p.log.info(`${c.dim(' └─')} Click the ${c.info('API tokens')} tab`);
|
|
53
|
+
p.log.info('');
|
|
54
|
+
p.log.info(`${c.bold('3.')} Click ${c.boldCyan('Generate API token')}`);
|
|
55
|
+
p.log.info(`${c.dim(' ├─')} Description: ${c.info(`"${projectName || 'vibecarbon'}-deploy"`)}`);
|
|
56
|
+
p.log.info(`${c.dim(' └─')} Permissions: ${c.info('Read & Write')} ${c.dim('(required)')}`);
|
|
57
|
+
p.log.info('');
|
|
58
|
+
p.log.info(`${c.bold('4.')} ${c.boldYellow('⚠️ Copy the token immediately')}`);
|
|
59
|
+
p.log.info(`${c.dim(' └─')} ${c.dim('It will only be shown once!')}`);
|
|
60
|
+
p.log.info('');
|
|
61
|
+
p.log.info(c.dim('─'.repeat(60)));
|
|
62
|
+
p.log.info('');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Display improved visual guide for S3 credentials creation
|
|
67
|
+
*/
|
|
68
|
+
function displayS3CredentialsGuide(projectName) {
|
|
69
|
+
p.log.info('');
|
|
70
|
+
p.log.info(`${c.boldCyan('🗄️ Hetzner Object Storage Setup')}`);
|
|
71
|
+
p.log.info('');
|
|
72
|
+
p.log.info(c.dim('─'.repeat(60)));
|
|
73
|
+
p.log.info('');
|
|
74
|
+
p.log.info(
|
|
75
|
+
`${c.bold('1.')} Click the ${c.info('S3 credentials')} tab ${c.dim('(you should already be on Security)')}`,
|
|
76
|
+
);
|
|
77
|
+
p.log.info('');
|
|
78
|
+
p.log.info(`${c.bold('2.')} Click ${c.boldCyan('Generate credentials')}`);
|
|
79
|
+
p.log.info(`${c.dim(' └─')} Description: ${c.info(`"${projectName || 'vibecarbon'}-s3"`)}`);
|
|
80
|
+
|
|
81
|
+
p.log.info('');
|
|
82
|
+
p.log.info(`${c.bold('3.')} ${c.boldYellow('⚠️ Copy BOTH credentials immediately:')}`);
|
|
83
|
+
p.log.info(`${c.dim(' ├─')} ${c.info('Access Key')} ${c.dim('(visible anytime)')}`);
|
|
84
|
+
p.log.info(`${c.dim(' └─')} ${c.info('Secret Key')} ${c.dim('(only shown once!)')}`);
|
|
85
|
+
p.log.info('');
|
|
86
|
+
p.log.info(c.dim('─'.repeat(60)));
|
|
87
|
+
p.log.info('');
|
|
88
|
+
p.note('Object Storage is required for database backups and file uploads', 'Why S3?');
|
|
89
|
+
p.log.info('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Session-level save preference: null = not asked yet, true/false = user's answer.
|
|
93
|
+
// Once the user answers "Save keys for future deploys?" the decision applies to all
|
|
94
|
+
// credentials prompted during this process.
|
|
95
|
+
let _savePreference = null;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Ask the user once whether to save credentials, then reuse the answer.
|
|
99
|
+
* @param {object} credentials - Credential object to merge-save
|
|
100
|
+
*/
|
|
101
|
+
export async function saveIfWanted(credentials) {
|
|
102
|
+
if (_savePreference === null) {
|
|
103
|
+
const shouldSave = await p.confirm({
|
|
104
|
+
message: 'Save keys for future deploys?',
|
|
105
|
+
initialValue: true,
|
|
106
|
+
});
|
|
107
|
+
_savePreference = shouldSave && !p.isCancel(shouldSave);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (_savePreference) {
|
|
111
|
+
saveCredentials(credentials);
|
|
112
|
+
p.log.info(c.dim('Saved to ~/.vibecarbon/credentials.json'));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Reset the session-level save preference (for testing)
|
|
118
|
+
*/
|
|
119
|
+
export function resetSavePreference() {
|
|
120
|
+
_savePreference = null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get API token with improved visual guidance
|
|
125
|
+
* Lookup order: env var → credentials file → interactive prompt
|
|
126
|
+
*
|
|
127
|
+
* @param {string} [projectName] - Project name for display in guide
|
|
128
|
+
* @param {{ save?: boolean }} [options] - Options (save: offer to save, default true)
|
|
129
|
+
* @returns {Promise<string|null>}
|
|
130
|
+
*/
|
|
131
|
+
export async function getApiToken(projectName, options = {}) {
|
|
132
|
+
const { save = true } = options;
|
|
133
|
+
|
|
134
|
+
// 1. Check environment variable first
|
|
135
|
+
const envToken = process.env.HETZNER_API_TOKEN;
|
|
136
|
+
if (envToken) {
|
|
137
|
+
const check = await validateHetznerToken(envToken);
|
|
138
|
+
if (!check.valid) {
|
|
139
|
+
p.log.warn(`HETZNER_API_TOKEN environment variable is set but invalid: ${check.error}`);
|
|
140
|
+
// Fall through to credentials file / interactive prompt
|
|
141
|
+
} else {
|
|
142
|
+
if (check.unreachable) {
|
|
143
|
+
p.log.warn('Could not reach Hetzner API to verify token — proceeding with saved token');
|
|
144
|
+
}
|
|
145
|
+
p.log.info('✓ Using Hetzner API token from HETZNER_API_TOKEN environment variable');
|
|
146
|
+
return envToken;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 2. Check credentials file
|
|
151
|
+
const creds = loadCredentials();
|
|
152
|
+
if (creds.hetzner?.apiToken) {
|
|
153
|
+
const check = await validateHetznerToken(creds.hetzner.apiToken);
|
|
154
|
+
if (!check.valid) {
|
|
155
|
+
p.log.warn(`Saved Hetzner API token is invalid: ${check.error}. Please enter a new one.`);
|
|
156
|
+
// Fall through to interactive prompt
|
|
157
|
+
} else {
|
|
158
|
+
if (check.unreachable) {
|
|
159
|
+
p.log.warn('Could not reach Hetzner API to verify token — proceeding with saved token');
|
|
160
|
+
}
|
|
161
|
+
p.log.info('✓ Using Hetzner API token from ~/.vibecarbon/credentials.json');
|
|
162
|
+
return creds.hetzner.apiToken;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 3. Interactive prompt (with retry on invalid token)
|
|
167
|
+
displayApiTokenGuide(projectName);
|
|
168
|
+
|
|
169
|
+
let token;
|
|
170
|
+
while (true) {
|
|
171
|
+
token = await p.password({
|
|
172
|
+
message: 'Paste your Hetzner API token here',
|
|
173
|
+
validate: (v) => {
|
|
174
|
+
if (!v || v.length < 10) return 'API token is required';
|
|
175
|
+
if (v.length !== 64)
|
|
176
|
+
return 'Token should be 64 characters - please check you copied it correctly';
|
|
177
|
+
return undefined;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (p.isCancel(token)) {
|
|
182
|
+
p.cancel('Operation cancelled.');
|
|
183
|
+
process.exit(0);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const s = p.spinner();
|
|
187
|
+
s.start('Verifying API token...');
|
|
188
|
+
const check = await validateHetznerToken(token);
|
|
189
|
+
if (check.valid) {
|
|
190
|
+
if (check.unreachable) {
|
|
191
|
+
s.stop('Could not reach Hetzner API — proceeding with unverified token');
|
|
192
|
+
} else {
|
|
193
|
+
s.stop('API token verified');
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
s.stop(`Token invalid: ${check.error}`);
|
|
198
|
+
p.log.warn('Please try again with a valid token.');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (save) {
|
|
202
|
+
await saveIfWanted({ hetzner: { apiToken: token } });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return token;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get S3 credentials with improved visual guidance
|
|
210
|
+
* Lookup order: env vars → credentials file → interactive prompt
|
|
211
|
+
*
|
|
212
|
+
* @param {string} [projectName] - Project name for display in guide
|
|
213
|
+
* @param {{ save?: boolean, force?: boolean }} [options] - Options
|
|
214
|
+
* save: offer to save credentials (default true)
|
|
215
|
+
* force: skip env/file lookup and go straight to interactive prompt (default false)
|
|
216
|
+
* @returns {Promise<{accessKey: string, secretKey: string}|null>}
|
|
217
|
+
*/
|
|
218
|
+
export async function getS3Credentials(projectName, options = {}) {
|
|
219
|
+
const { save = true, force = false } = options;
|
|
220
|
+
|
|
221
|
+
if (!force) {
|
|
222
|
+
// 1. Check environment variables first
|
|
223
|
+
const envAccessKey = process.env.S3_ACCESS_KEY;
|
|
224
|
+
const envSecretKey = process.env.S3_SECRET_KEY;
|
|
225
|
+
|
|
226
|
+
if (envAccessKey && envSecretKey) {
|
|
227
|
+
p.log.info('✓ Using S3 credentials from environment variables');
|
|
228
|
+
return { accessKey: envAccessKey, secretKey: envSecretKey };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 2. Check credentials file
|
|
232
|
+
const creds = loadCredentials();
|
|
233
|
+
if (creds.s3?.accessKey && creds.s3?.secretKey) {
|
|
234
|
+
p.log.info('✓ Using S3 credentials from ~/.vibecarbon/credentials.json');
|
|
235
|
+
return { accessKey: creds.s3.accessKey, secretKey: creds.s3.secretKey };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 3. Interactive prompt
|
|
240
|
+
displayS3CredentialsGuide(projectName);
|
|
241
|
+
|
|
242
|
+
const accessKey = await p.text({
|
|
243
|
+
message: 'Paste your S3 Access Key here',
|
|
244
|
+
validate: (v) => {
|
|
245
|
+
if (!v || v.length < 10) return 'Access Key is required';
|
|
246
|
+
return undefined;
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (p.isCancel(accessKey)) {
|
|
251
|
+
p.cancel('Operation cancelled.');
|
|
252
|
+
process.exit(0);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const secretKey = await p.password({
|
|
256
|
+
message: 'Paste your S3 Secret Key here',
|
|
257
|
+
validate: (v) => {
|
|
258
|
+
if (!v || v.length < 10) return 'Secret Key is required';
|
|
259
|
+
return undefined;
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (p.isCancel(secretKey)) {
|
|
264
|
+
p.cancel('Operation cancelled.');
|
|
265
|
+
process.exit(0);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
p.log.success('S3 credentials received!');
|
|
269
|
+
|
|
270
|
+
if (save) {
|
|
271
|
+
await saveIfWanted({ s3: { accessKey, secretKey } });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { accessKey, secretKey };
|
|
275
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Absolute path to the per-environment known_hosts file for SSH strict-checking.
|
|
6
|
+
*
|
|
7
|
+
* NOTE: Permission enforcement below relies on POSIX modes and is a no-op on
|
|
8
|
+
* Windows. This CLI targets Linux/macOS developer machines and Linux servers.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} env - environment name
|
|
11
|
+
* @param {string} [cwd=process.cwd()] - project root
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function knownHostsPath(env, cwd = process.cwd()) {
|
|
15
|
+
return join(cwd, '.vibecarbon', `known_hosts_${env}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Overwrite the per-environment known_hosts file with the given host-key lines.
|
|
20
|
+
*
|
|
21
|
+
* Enforces `.vibecarbon/` mode 0o700 and file mode 0o600 unconditionally —
|
|
22
|
+
* `mkdirSync`/`writeFileSync` only apply `mode` on initial creation, so we
|
|
23
|
+
* follow each with `chmodSync` to guarantee the mode even when the target
|
|
24
|
+
* already exists (which it will on every re-pin after first deploy).
|
|
25
|
+
*
|
|
26
|
+
* @param {string} env
|
|
27
|
+
* @param {string[]} hostKeyLines - one host-key entry per element
|
|
28
|
+
* @param {string} [cwd=process.cwd()]
|
|
29
|
+
*/
|
|
30
|
+
export function pinHostKey(env, hostKeyLines, cwd = process.cwd()) {
|
|
31
|
+
const path = knownHostsPath(env, cwd);
|
|
32
|
+
const dir = dirname(path);
|
|
33
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
chmodSync(dir, 0o700);
|
|
35
|
+
writeFileSync(path, `${hostKeyLines.join('\n')}\n`, { mode: 0o600 });
|
|
36
|
+
chmodSync(path, 0o600);
|
|
37
|
+
}
|