vibecarbon 0.5.0 → 0.6.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 (63) hide show
  1. package/README.md +1 -1
  2. package/carbon/.env.example +3 -0
  3. package/carbon/.github/workflows/deploy.yml +24 -21
  4. package/carbon/.github/workflows/vibecarbon-build.yml +14 -0
  5. package/carbon/Dockerfile +3 -0
  6. package/carbon/biome.json +1 -1
  7. package/carbon/cloud-init/k3s/master-init.sh +26 -2
  8. package/carbon/cloud-init/k3s/supabase-init.sh +22 -2
  9. package/carbon/cloud-init/k3s/worker-init.sh +22 -2
  10. package/carbon/content/docs/cli.mdx +1 -1
  11. package/carbon/docker-compose.yml +6 -0
  12. package/carbon/k8s/base/app/deployment.yaml +5 -0
  13. package/carbon/k8s/values/supabase.values.yaml +30 -0
  14. package/carbon/package.json +34 -36
  15. package/carbon/scripts/generate-sitemap.ts +18 -3
  16. package/carbon/src/client/App.tsx +2 -0
  17. package/carbon/src/client/components/NewsletterSignup.tsx +4 -3
  18. package/carbon/src/client/components/PricingSection.tsx +44 -1
  19. package/carbon/src/client/components/SEO.tsx +6 -3
  20. package/carbon/src/client/components/ui/calendar.tsx +1 -1
  21. package/carbon/src/client/index.css +9 -1
  22. package/carbon/src/client/index.html +3 -3
  23. package/carbon/src/client/locales/en.json +1 -1
  24. package/carbon/src/client/pages/Pricing.tsx +169 -0
  25. package/carbon/src/client/pages/settings/Billing.tsx +2 -6
  26. package/carbon/src/server/index.ts +23 -4
  27. package/carbon/src/shared/billing-catalog.ts +25 -0
  28. package/carbon/src/shared/billing-catalog.types.ts +35 -0
  29. package/carbon/src/shared/pricing.ts +56 -1
  30. package/package.json +13 -13
  31. package/src/activate.js +1 -1
  32. package/src/backup.js +33 -87
  33. package/src/configure.js +365 -137
  34. package/src/create.js +5 -2
  35. package/src/deploy.js +24 -7
  36. package/src/destroy.js +11 -4
  37. package/src/failover.js +64 -47
  38. package/src/lib/backup-format.js +84 -0
  39. package/src/lib/billing/stripe-catalog.js +152 -0
  40. package/src/lib/billing/write-catalog.js +123 -0
  41. package/src/lib/ci-setup.js +11 -1
  42. package/src/lib/config-registry.js +116 -0
  43. package/src/lib/deploy/compose/build-args.js +6 -0
  44. package/src/lib/deploy/compose/ha.js +25 -25
  45. package/src/lib/deploy/compose/index.js +29 -18
  46. package/src/lib/deploy/k8s/gitops-deploy.js +38 -14
  47. package/src/lib/deploy/k8s/k3s.js +20 -10
  48. package/src/lib/deploy/orchestrator.js +22 -1
  49. package/src/lib/deploy/prompts.js +21 -2
  50. package/src/lib/deploy/utils.js +56 -32
  51. package/src/lib/github-environments.js +29 -0
  52. package/src/lib/licensing/index.js +8 -3
  53. package/src/lib/licensing/tiers.js +0 -3
  54. package/src/lib/pod-backups.js +3 -3
  55. package/src/lib/providers/base.js +1 -1
  56. package/src/lib/providers/hetzner.js +74 -82
  57. package/src/lib/server-types.js +3 -31
  58. package/src/lib/ssh.js +19 -18
  59. package/src/lib/walg-backups.js +81 -0
  60. package/src/restore.js +122 -202
  61. package/src/scale.js +25 -29
  62. package/src/status.js +0 -69
  63. package/src/lib/cost.js +0 -103
package/README.md CHANGED
@@ -118,7 +118,7 @@ DEBUG COMMANDS
118
118
  - Auto backup & restore
119
119
  - Live monitoring & alerts (Grafana, Prometheus, Loki)
120
120
  - Zero vendor lock-in
121
- - Cost effective ($15-20/mo single region or compose HA, $30+/mo K8s multi-region HA)
121
+ - Cost effective runs on inexpensive VPS hosting, a fraction of hyperscaler pricing
122
122
 
123
123
  ### Developer Experience
124
124
  - AI-ready codebase (Claude Code, Cursor, Windsurf, Copilot rules included)
@@ -20,6 +20,9 @@ SUPABASE_SERVICE_ROLE_KEY="your-service-role-key-here"
20
20
 
21
21
  # Site URL (your public domain)
22
22
  SITE_URL="http://localhost:5173"
23
+ # Public canonical URL baked into the client (og: tags, sitemap). At deploy this
24
+ # is overridden with your real apex domain; localhost is correct for local dev.
25
+ VITE_PUBLIC_URL="http://localhost:5173"
23
26
 
24
27
  # =============================================================================
25
28
  # ADMIN CREDENTIALS
@@ -74,34 +74,37 @@ jobs:
74
74
 
75
75
  # Apply the vibecarbon-secrets Secret. Server-side apply with a
76
76
  # distinct field manager ensures Flux doesn't try to prune these
77
- # fields later, and that rotations simply overwrite. Secret values
78
- # are written via stdin heredoc using $ENV_VARS rather than
79
- # `${{ secrets.X }}` interpolation (defense-in-depth).
77
+ # fields later, and that rotations simply overwrite.
78
+ #
79
+ # Self-syncing: instead of enumerating every key (which silently drops
80
+ # any new `vibecarbon configure` secret that the CLI started seeding),
81
+ # we materialize ALL environment+repo secrets from `toJSON(secrets)` and
82
+ # build the Secret from them. Adding a feature key to the CLI's
83
+ # seedEnvironmentSecrets is enough — no edit here. The exclude list
84
+ # below is the only maintained piece, and it's an exclude (safe by
85
+ # default): drop infra/CI secrets that have their own Secret or that
86
+ # must never reach the app pod (the app envFrom's this whole Secret).
87
+ # `toJSON(secrets)` is bound to an env var (not interpolated inline in
88
+ # the script) for defense-in-depth; kubectl --from-literal passes values
89
+ # through argv with no shell interpretation.
80
90
  - name: Apply vibecarbon-secrets Secret
81
91
  env:
82
- DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
83
- JWT_SECRET: ${{ secrets.JWT_SECRET }}
84
- SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
85
- SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
86
- HETZNER_API_TOKEN: ${{ secrets.HETZNER_API_TOKEN }}
87
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
88
- SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
92
+ ALL_SECRETS: ${{ toJSON(secrets) }}
89
93
  SITE_URL: ${{ vars.SITE_URL }}
90
94
  run: |
91
- # `kubectl create secret --from-literal=KEY=VALUE` handles
92
- # arbitrary VALUEs correctly (passes through Unix argv, no
93
- # shell interpretation). `--dry-run -o yaml | kubectl apply`
94
- # is the idempotent create-or-update pattern.
95
+ mapfile -t ARGS < <(printf '%s' "$ALL_SECRETS" | jq -r '
96
+ del(
97
+ .KUBECONFIG_B64,
98
+ .GITHUB_TOKEN, .github_token,
99
+ .HETZNER_API_TOKEN, .CLOUDFLARE_API_TOKEN
100
+ )
101
+ | to_entries[]
102
+ | select(.value != null and .value != "")
103
+ | "--from-literal=\(.key)=\(.value)"')
95
104
  kubectl create secret generic vibecarbon-secrets \
96
105
  --namespace=vibecarbon \
97
- --from-literal=DB_PASSWORD="$DB_PASSWORD" \
98
- --from-literal=JWT_SECRET="$JWT_SECRET" \
99
- --from-literal=SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" \
100
- --from-literal=SUPABASE_SERVICE_ROLE_KEY="$SUPABASE_SERVICE_ROLE_KEY" \
101
- --from-literal=HETZNER_API_TOKEN="$HETZNER_API_TOKEN" \
102
- --from-literal=CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN" \
103
- --from-literal=SMTP_PASSWORD="$SMTP_PASSWORD" \
104
106
  --from-literal=SITE_URL="$SITE_URL" \
107
+ "${ARGS[@]}" \
105
108
  --dry-run=client -o yaml \
106
109
  | kubectl apply --server-side --force-conflicts \
107
110
  --field-manager=github-actions-deploy -f -
@@ -74,6 +74,20 @@ jobs:
74
74
  push: true
75
75
  tags: ${{ steps.meta.outputs.tags }}
76
76
  labels: ${{ steps.meta.outputs.labels }}
77
+ # Vite inlines import.meta.env.VITE_* at build time, so the browser
78
+ # bundle bakes whatever these resolve to. They come from repo-level
79
+ # Actions variables seeded by `vibecarbon deploy` (push mode) via
80
+ # seedBuildVars. Without them the bundle ships empty VITE_SUPABASE_*
81
+ # and the SPA throws "Missing Supabase environment variables" on
82
+ # load. Empty/unset vars resolve to "" — same as a fresh project
83
+ # before its first push-mode deploy.
84
+ build-args: |
85
+ VITE_PROJECT_NAME=${{ vars.VITE_PROJECT_NAME }}
86
+ VITE_SUPABASE_URL=${{ vars.VITE_SUPABASE_URL }}
87
+ VITE_SUPABASE_ANON_KEY=${{ vars.VITE_SUPABASE_ANON_KEY }}
88
+ VITE_PUBLIC_URL=${{ vars.VITE_PUBLIC_URL }}
89
+ VITE_PLAUSIBLE_DOMAIN=${{ vars.VITE_PLAUSIBLE_DOMAIN }}
90
+ VITE_PLAUSIBLE_SCRIPT_URL=${{ vars.VITE_PLAUSIBLE_SCRIPT_URL }}
77
91
  # GHA cache keyed on Dockerfile + lockfile. `mode=max` caches every
78
92
  # layer including intermediate ones, so re-builds with an unchanged
79
93
  # install step reuse the pnpm-store layer (biggest single layer).
package/carbon/Dockerfile CHANGED
@@ -21,6 +21,9 @@ ARG VITE_ROUTING_MODE
21
21
  # Analytics (optional — empty by default, no tracking unless configured)
22
22
  ARG VITE_PLAUSIBLE_DOMAIN
23
23
  ARG VITE_PLAUSIBLE_SCRIPT_URL
24
+ # Public canonical URL (apex domain) for og: tags + sitemap. Set at deploy from
25
+ # the real domain; defaults to localhost for local builds.
26
+ ARG VITE_PUBLIC_URL
24
27
 
25
28
  # Ensure pnpm is available (runtime base image already has it, standard node image doesn't)
26
29
  RUN command -v pnpm >/dev/null 2>&1 || npm install -g pnpm@9
package/carbon/biome.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
3
3
  "linter": {
4
4
  "enabled": true,
5
5
  "rules": {
@@ -79,8 +79,32 @@ FIPEOF
79
79
  systemctl daemon-reload
80
80
  systemctl enable floating-ip.service
81
81
 
82
- # Get node IPs for k3s configuration
83
- PUBLIC_IP=$(curl -s --max-time 10 http://169.254.169.254/hetzner/v1/metadata/public-ipv4)
82
+ # Get node IPs for k3s configuration.
83
+ # Fetch the public IPv4 robustly: k3s installs with --node-ip "$PUBLIC_IP" below,
84
+ # and the Hetzner CCM matches the node against the API by this IP to clear the
85
+ # node.cloudprovider.kubernetes.io/uninitialized taint. The metadata service can
86
+ # be slow/empty on first boot — a single curl that returns "" silently installs
87
+ # k3s with `--node-ip ""`, leaving the node InternalIP=<none> and breaking
88
+ # control-plane<->kubelet comms (RCA 2026-06-23, ash/US: cert-manager-webhook
89
+ # never became Ready because its node was unreachable). Retry the metadata
90
+ # endpoint, fall back to the default-route source address, and FAIL the provision
91
+ # rather than install a node with an empty node-ip.
92
+ PUBLIC_IP=""
93
+ for i in $(seq 1 30); do
94
+ PUBLIC_IP=$(curl -sf --max-time 5 http://169.254.169.254/hetzner/v1/metadata/public-ipv4 || true)
95
+ if echo "$PUBLIC_IP" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then break; fi
96
+ PUBLIC_IP=""
97
+ sleep 2
98
+ done
99
+ if [ -z "$PUBLIC_IP" ]; then
100
+ echo "Metadata public-ipv4 unavailable after retries — deriving from default route"
101
+ PUBLIC_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' || true)
102
+ fi
103
+ if ! echo "$PUBLIC_IP" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
104
+ echo "FATAL: could not determine public IPv4 for --node-ip; refusing to install k3s with an empty node-ip"
105
+ exit 1
106
+ fi
107
+ echo "Public IP: $PUBLIC_IP"
84
108
 
85
109
  # Wait for the private network interface (10.0.x.x) to be assigned.
86
110
  # Hetzner attaches the private network after the server boots, so the interface
@@ -50,8 +50,28 @@ mirrors:
50
50
  - "http://10.0.1.1:5000"
51
51
  REGEOF
52
52
 
53
- # Get node IPs for k3s configuration
54
- PUBLIC_IP=$(curl -s --max-time 10 http://169.254.169.254/hetzner/v1/metadata/public-ipv4)
53
+ # Get node IPs for k3s configuration.
54
+ # Robust public-IPv4 fetch — see master-init.sh for the full RCA. A single curl
55
+ # that returns "" installs k3s with `--node-ip ""`, leaving the node
56
+ # InternalIP=<none> and unreachable from the control plane. Retry the metadata
57
+ # endpoint, fall back to the default-route source address, and FAIL rather than
58
+ # install an empty node-ip.
59
+ PUBLIC_IP=""
60
+ for i in $(seq 1 30); do
61
+ PUBLIC_IP=$(curl -sf --max-time 5 http://169.254.169.254/hetzner/v1/metadata/public-ipv4 || true)
62
+ if echo "$PUBLIC_IP" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then break; fi
63
+ PUBLIC_IP=""
64
+ sleep 2
65
+ done
66
+ if [ -z "$PUBLIC_IP" ]; then
67
+ echo "Metadata public-ipv4 unavailable after retries — deriving from default route"
68
+ PUBLIC_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' || true)
69
+ fi
70
+ if ! echo "$PUBLIC_IP" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
71
+ echo "FATAL: could not determine public IPv4 for --node-ip; refusing to install k3s with an empty node-ip"
72
+ exit 1
73
+ fi
74
+ echo "Public IP: $PUBLIC_IP"
55
75
 
56
76
  # Wait for the private network interface (10.0.x.x) to be assigned.
57
77
  # Hetzner attaches the private network after the server boots, so the interface
@@ -49,8 +49,28 @@ mirrors:
49
49
  - "http://10.0.1.1:5000"
50
50
  REGEOF
51
51
 
52
- # Get node IPs for k3s configuration
53
- PUBLIC_IP=$(curl -s --max-time 10 http://169.254.169.254/hetzner/v1/metadata/public-ipv4)
52
+ # Get node IPs for k3s configuration.
53
+ # Robust public-IPv4 fetch — see master-init.sh for the full RCA. A single curl
54
+ # that returns "" installs k3s with `--node-ip ""`, leaving the node
55
+ # InternalIP=<none> and unreachable from the control plane. Retry the metadata
56
+ # endpoint, fall back to the default-route source address, and FAIL rather than
57
+ # install an empty node-ip.
58
+ PUBLIC_IP=""
59
+ for i in $(seq 1 30); do
60
+ PUBLIC_IP=$(curl -sf --max-time 5 http://169.254.169.254/hetzner/v1/metadata/public-ipv4 || true)
61
+ if echo "$PUBLIC_IP" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then break; fi
62
+ PUBLIC_IP=""
63
+ sleep 2
64
+ done
65
+ if [ -z "$PUBLIC_IP" ]; then
66
+ echo "Metadata public-ipv4 unavailable after retries — deriving from default route"
67
+ PUBLIC_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' || true)
68
+ fi
69
+ if ! echo "$PUBLIC_IP" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
70
+ echo "FATAL: could not determine public IPv4 for --node-ip; refusing to install k3s with an empty node-ip"
71
+ exit 1
72
+ fi
73
+ echo "Public IP: $PUBLIC_IP"
54
74
 
55
75
  # Wait for the private network interface (10.0.x.x) to be assigned.
56
76
  # Hetzner attaches the private network after the server boots, so the interface
@@ -599,7 +599,7 @@ npx vibecarbon activate vc-xxxxxxxx-signature...
599
599
  ```
600
600
 
601
601
  License keys start with `vc-` or `CARBON-`. Purchase a license at
602
- [vibecarbon.dev/pricing](https://vibecarbon.dev/pricing).
602
+ [vibecarbon.com/#pricing](https://vibecarbon.com/#pricing).
603
603
 
604
604
  ---
605
605
 
@@ -55,6 +55,12 @@ services:
55
55
  VITE_PLAUSIBLE_SCRIPT_URL: ${VITE_PLAUSIBLE_SCRIPT_URL:-}
56
56
  container_name: ${PROJECT_NAME}-app
57
57
  restart: unless-stopped
58
+ # Make every configure-managed runtime key (billing/OAuth/SMTP) available to
59
+ # the app container. `create` writes .env and the deploy bundle stages it
60
+ # alongside this file; the `environment:` overrides below still take
61
+ # precedence over anything in .env.
62
+ env_file:
63
+ - .env
58
64
  security_opt:
59
65
  - no-new-privileges:true
60
66
  cap_drop:
@@ -51,6 +51,11 @@ spec:
51
51
  envFrom:
52
52
  - configMapRef:
53
53
  name: vibecarbon-config
54
+ # All configure-managed runtime keys (billing/OAuth/SMTP) live in
55
+ # vibecarbon-secrets; envFrom makes them available to the app without a
56
+ # per-key list here. Explicit env entries below still win on conflict.
57
+ - secretRef:
58
+ name: vibecarbon-secrets
54
59
  env:
55
60
  - name: NODE_ENV
56
61
  value: production
@@ -140,6 +140,36 @@ deployment:
140
140
  # Uses the same wal-g-equipped db image. RESTORE_TARGET is an OPTIONAL
141
141
  # secretKeyRef so deploys without the key still schedule.
142
142
  extraInitContainers:
143
+ # ROBUSTNESS FIX (RCA 2026-06-23): the chart's init-pgsodium copies the
144
+ # /etc/postgresql-custom confs into the pgsodium PVC ONLY when it sees the
145
+ # volume as empty (`[ -z "$(ls -A /mnt/pgsodium)" ]`). A freshly-formatted
146
+ # Hetzner CSI ext4 volume contains a `lost+found` dir, so init-pgsodium
147
+ # treats it as "already initialized" and SKIPS the copy — postgres then
148
+ # FATALs on the missing wal-g.conf / read-replica.conf / supautils.conf
149
+ # `include` directives and CrashLoopBackOffs (intermittent, gated on the
150
+ # CSI mke2fs format race; failed k8s + k8s-ha in the 2026-06-22 matrix).
151
+ # This runs after init-pgsodium and idempotently restores the image's
152
+ # default confs whenever any required conf is absent — checking the confs
153
+ # themselves, not dir-emptiness. Same db image (its /etc/postgresql-custom
154
+ # holds the wal-g-equipped defaults); cp -a only overlays, never deletes.
155
+ - name: ensure-postgresql-custom
156
+ image: "{{DB_IMAGE}}:{{DB_IMAGE_TAG}}"
157
+ imagePullPolicy: IfNotPresent
158
+ command:
159
+ - /bin/sh
160
+ - -c
161
+ - |
162
+ for f in supautils.conf wal-g.conf read-replica.conf; do
163
+ if [ ! -f "/mnt/pgsodium/$f" ]; then
164
+ echo "postgresql-custom: $f missing (init-pgsodium skipped a non-empty volume, e.g. lost+found) — restoring image defaults"
165
+ cp -a /etc/postgresql-custom/. /mnt/pgsodium/
166
+ break
167
+ fi
168
+ done
169
+ echo "postgresql-custom verified: $(ls -A /mnt/pgsodium 2>/dev/null | tr '\n' ' ')"
170
+ volumeMounts:
171
+ - name: pgsodium
172
+ mountPath: /mnt/pgsodium
143
173
  - name: walg-restore
144
174
  image: "{{DB_IMAGE}}:{{DB_IMAGE_TAG}}"
145
175
  imagePullPolicy: IfNotPresent
@@ -53,48 +53,47 @@
53
53
  "@base-ui/react": "^1.5.0",
54
54
  "@fontsource-variable/noto-sans": "^5.2.10",
55
55
  "@fontsource/jetbrains-mono": "^5.2.8",
56
- "@hono/node-server": "^2.0.4",
56
+ "@hono/node-server": "^2.0.6",
57
57
  "@hono/zod-openapi": "^1.4.0",
58
58
  "@hookform/resolvers": "^5.4.0",
59
59
  "@mdx-js/react": "^3.1.1",
60
60
  "@mdx-js/rollup": "^3.1.1",
61
- "@scalar/api-reference-react": "^0.9.41",
62
- "@supabase/ssr": "^0.9.0",
61
+ "@scalar/api-reference-react": "^0.9.49",
63
62
  "@supabase/supabase-js": "^2.106.2",
64
- "@tanstack/react-query": "^5.95.2",
63
+ "@tanstack/react-query": "^5.100.14",
65
64
  "class-variance-authority": "^0.7.1",
66
65
  "clsx": "^2.1.1",
67
66
  "cmdk": "^1.1.1",
68
- "date-fns": "^4.1.0",
67
+ "date-fns": "^4.4.0",
69
68
  "embla-carousel-react": "^8.6.0",
70
69
  "framer-motion": "^12.40.0",
71
- "hono": "^4.12.23",
72
- "i18next": "^25.10.9",
70
+ "hono": "^4.12.27",
71
+ "i18next": "^26.3.3",
73
72
  "i18next-browser-languagedetector": "^8.2.1",
74
73
  "input-otp": "^1.4.2",
75
- "ioredis": "^5.10.1",
76
- "lenis": "^1.3.23",
77
- "lucide-react": "^1.7.0",
74
+ "ioredis": "^5.11.1",
75
+ "lenis": "^1.3.25",
76
+ "lucide-react": "^1.17.0",
78
77
  "next-themes": "^0.4.6",
79
- "nodemailer": "^8.0.8",
78
+ "nodemailer": "^8.0.10",
80
79
  "pino": "^10.3.1",
81
- "react": "^19.2.6",
82
- "react-day-picker": "^9.14.0",
83
- "react-dom": "^19.2.6",
84
- "react-hook-form": "^7.72.0",
85
- "react-i18next": "^16.6.6",
80
+ "react": "^19.2.7",
81
+ "react-day-picker": "^10.0.1",
82
+ "react-dom": "^19.2.7",
83
+ "react-hook-form": "^7.77.0",
84
+ "react-i18next": "^17.0.8",
86
85
  "react-resizable-panels": "^4.11.2",
87
- "react-router-dom": "^7.15.1",
86
+ "react-router-dom": "^7.16.0",
88
87
  "recharts": "^3.8.1",
89
88
  "rehype-autolink-headings": "^7.1.0",
90
89
  "rehype-slug": "^6.0.0",
91
90
  "remark-frontmatter": "^5.0.0",
92
91
  "remark-mdx-frontmatter": "^5.2.0",
93
92
  "sonner": "^2.0.7",
94
- "stripe": "^20.4.1",
95
- "tailwind-merge": "^3.5.0",
93
+ "stripe": "^22.2.0",
94
+ "tailwind-merge": "^3.6.0",
96
95
  "vaul": "^1.1.2",
97
- "zod": "^4.3.6"
96
+ "zod": "^4.4.3"
98
97
  },
99
98
  "pnpm": {
100
99
  "onlyBuiltDependencies": [
@@ -123,33 +122,32 @@
123
122
  }
124
123
  },
125
124
  "devDependencies": {
126
- "@biomejs/biome": "^2.4.15",
125
+ "@biomejs/biome": "^2.4.16",
127
126
  "@hono/swagger-ui": "^0.6.1",
128
127
  "@scalar/hono-api-reference": "^0.10.19",
129
- "@tailwindcss/typography": "^0.5.19",
130
- "@tailwindcss/vite": "^4.3.0",
131
- "@testing-library/jest-dom": "^6.6.3",
132
- "@testing-library/react": "^16.3.0",
128
+ "@tailwindcss/typography": "^0.5.20",
129
+ "@tailwindcss/vite": "^4.3.2",
130
+ "@testing-library/jest-dom": "^6.9.1",
131
+ "@testing-library/react": "^16.3.2",
133
132
  "@testing-library/user-event": "^14.6.1",
134
133
  "@types/node": "^25.9.1",
135
134
  "@types/nodemailer": "^8.0.0",
136
135
  "@types/pg": "^8.20.0",
137
- "@types/react": "^19.2.15",
136
+ "@types/react": "^19.2.17",
138
137
  "@types/react-dom": "^19.2.3",
139
- "@vitejs/plugin-react": "^6.0.2",
140
- "@vitest/coverage-v8": "^4.1.7",
141
- "concurrently": "^9.2.1",
142
- "esbuild": "^0.28.0",
143
- "jsdom": "^26.1.0",
138
+ "@vitejs/plugin-react": "^6.0.3",
139
+ "@vitest/coverage-v8": "^4.1.9",
140
+ "esbuild": "^0.28.1",
141
+ "jsdom": "^29.1.1",
144
142
  "pg": "^8.21.0",
145
143
  "pino-pretty": "^13.1.3",
146
144
  "remark-gfm": "^4.0.1",
147
- "shadcn": "^4.8.1",
148
- "tailwindcss": "^4.3.0",
149
- "tsx": "^4.22.3",
145
+ "shadcn": "^4.10.0",
146
+ "tailwindcss": "^4.3.2",
147
+ "tsx": "^4.22.4",
150
148
  "tw-animate-css": "^1.4.0",
151
149
  "typescript": "^6.0.3",
152
- "vite": "^8.0.14",
153
- "vitest": "^4.1.7"
150
+ "vite": "^8.0.16",
151
+ "vitest": "^4.1.9"
154
152
  }
155
153
  }
@@ -17,14 +17,17 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
17
17
  const PUBLIC_ROUTES = ['/', '/login', '/signup', '/blog', '/changelog', '/docs'];
18
18
 
19
19
  function loadSiteUrl(): string {
20
+ // Production builds receive the apex URL as VITE_PUBLIC_URL (a build arg);
21
+ // .env.local is not present in the image. Locally, fall back to .env.local.
22
+ if (process.env.VITE_PUBLIC_URL) return process.env.VITE_PUBLIC_URL.replace(/\/$/, '');
20
23
  try {
21
24
  const envContent = readFileSync(resolve(__dirname, '../.env.local'), 'utf-8');
22
- const match = envContent.match(/^SITE_URL=["']?(.+?)["']?\s*$/m);
23
- if (match) return match[1];
25
+ const match = envContent.match(/^(?:VITE_PUBLIC_URL|SITE_URL)=["']?(.+?)["']?\s*$/m);
26
+ if (match) return match[1].replace(/\/$/, '');
24
27
  } catch {
25
28
  // .env.local may not exist in CI
26
29
  }
27
- return process.env.SITE_URL || 'http://localhost:5173';
30
+ return (process.env.SITE_URL || 'http://localhost:5173').replace(/\/$/, '');
28
31
  }
29
32
 
30
33
  function getBlogSlugs(): string[] {
@@ -95,6 +98,18 @@ const outDir = resolve(__dirname, '../dist/client');
95
98
  mkdirSync(outDir, { recursive: true });
96
99
  writeFileSync(resolve(outDir, 'sitemap.xml'), sitemap);
97
100
 
101
+ // robots.txt is a static public/ file (Vite copies it verbatim, so its
102
+ // `Sitemap:` line still carries the create-time {{SITE_URL}} → localhost).
103
+ // Rewrite it to the real base URL now that we know it.
104
+ try {
105
+ const robotsPath = resolve(outDir, 'robots.txt');
106
+ const robots = readFileSync(robotsPath, 'utf-8');
107
+ const fixed = robots.replace(/^Sitemap:.*$/m, `Sitemap: ${siteUrl}/sitemap.xml`);
108
+ if (fixed !== robots) writeFileSync(robotsPath, fixed);
109
+ } catch {
110
+ // No robots.txt in dist — nothing to fix.
111
+ }
112
+
98
113
  const blogCount = getBlogSlugs().length;
99
114
  const changelogCount = getChangelogSlugs().length;
100
115
  const docsCount = getDocsSlugs().length;
@@ -27,6 +27,7 @@ const Changelog = lazy(() => import('./pages/Changelog'));
27
27
  const Docs = lazy(() => import('./pages/Docs'));
28
28
  const Legal = lazy(() => import('./pages/Legal'));
29
29
  const Checkout = lazy(() => import('./pages/Checkout'));
30
+ const Pricing = lazy(() => import('./pages/Pricing'));
30
31
 
31
32
  // Settings
32
33
  const Profile = lazy(() => import('./pages/settings/Profile'));
@@ -135,6 +136,7 @@ export default function App() {
135
136
  <Route path="/changelog" element={<Changelog />} />
136
137
  <Route path="/changelog/:slug" element={<Changelog />} />
137
138
  <Route path="/checkout" element={<Checkout />} />
139
+ <Route path="/pricing" element={<Pricing />} />
138
140
  <Route path="/docs" element={<Docs />} />
139
141
  <Route path="/docs/:slug" element={<Docs />} />
140
142
  <Route path="/contact" element={<Contact />} />
@@ -111,14 +111,14 @@ export function NewsletterSignup({
111
111
  }
112
112
 
113
113
  return (
114
- <div className={`flex flex-col gap-2 ${className || ''}`}>
115
- <div className="flex items-center gap-2">
114
+ <div className={`flex w-full flex-col gap-2 sm:w-auto ${className || ''}`}>
115
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
116
116
  <Input
117
117
  type="email"
118
118
  value={email}
119
119
  onChange={handleEmailChange}
120
120
  placeholder={t('landing.newsletter.placeholder')}
121
- className="bg-background min-w-[280px] sm:min-w-[340px] h-9"
121
+ className="bg-background h-9 w-full min-w-0 sm:w-[300px]"
122
122
  aria-invalid={hasError}
123
123
  onKeyDown={(e) => {
124
124
  if (e.key === 'Enter') {
@@ -132,6 +132,7 @@ export function NewsletterSignup({
132
132
  disabled={subscribeMutation.isPending}
133
133
  size="default"
134
134
  sparkle
135
+ className="w-full sm:w-auto"
135
136
  >
136
137
  {subscribeMutation.isPending && <Loader2 className="size-3 mr-1 animate-spin" />}
137
138
  {resolvedButtonText}
@@ -1,3 +1,4 @@
1
+ import { catalogTiers } from '@shared/pricing';
1
2
  import { motion } from 'framer-motion';
2
3
  import { Atom, Check, Diamond, Hexagon, type LucideIcon, X } from 'lucide-react';
3
4
  import { useEffect, useRef, useState } from 'react';
@@ -8,6 +9,19 @@ import vibecarbonIcon from '../assets/vibecarbon-icon.svg';
8
9
  import { FresnelEdge } from './effects/FresnelEdge';
9
10
  import { Button } from './ui/button';
10
11
 
12
+ /**
13
+ * Map an activated catalog product to its bespoke design slot by name (the
14
+ * allotrope tiers are stable). Returns '' for an unrecognized product, which
15
+ * falls back to default styling.
16
+ */
17
+ function designSlugFromName(name: string): string {
18
+ const n = name.toLowerCase();
19
+ if (n.includes('graphite')) return 'graphite';
20
+ if (n.includes('diamond')) return 'diamond';
21
+ if (n.includes('fullerene')) return 'fullerene';
22
+ return '';
23
+ }
24
+
11
25
  interface Tier {
12
26
  id: string;
13
27
  name: string;
@@ -132,6 +146,35 @@ export function PricingSection() {
132
146
  },
133
147
  ];
134
148
 
149
+ // When the operator has activated products via `vibecarbon configure`, those
150
+ // drive WHICH cards show, their order (price-sorted), and their price —
151
+ // matched to each tier's bespoke design above. The hardcoded `tiers` are the
152
+ // fallback when no catalog is configured. Curated visuals, taglines, discounts
153
+ // and feature copy stay local (they aren't in the provider catalog).
154
+ const tiersById = new Map(tiers.map((tier) => [tier.id, tier]));
155
+ const displayTiers: Tier[] = catalogTiers.length
156
+ ? catalogTiers.map((ct, i) => {
157
+ const design = tiersById.get(designSlugFromName(ct.name));
158
+ const price = Math.round(ct.amount / 100);
159
+ const delay = 0.1 * (i + 1);
160
+ if (design) return { ...design, price, delay };
161
+ // Unrecognized product — default (graphite) styling, content from the catalog.
162
+ return {
163
+ ...tiers[0],
164
+ id: ct.priceId,
165
+ name: ct.name,
166
+ tagline: ct.description ?? tiers[0].tagline,
167
+ price,
168
+ originalPrice: null,
169
+ popular: false,
170
+ features: ct.features.length
171
+ ? ct.features.map((text) => ({ text, included: true }))
172
+ : tiers[0].features,
173
+ delay,
174
+ };
175
+ })
176
+ : tiers;
177
+
135
178
  return (
136
179
  <section id="pricing" className="py-24 md:py-36 overflow-hidden">
137
180
  <div className="mx-auto max-w-7xl px-6">
@@ -173,7 +216,7 @@ export function PricingSection() {
173
216
  <div className="relative">
174
217
  {/* Pricing Cards */}
175
218
  <div className="relative grid gap-8 md:grid-cols-3">
176
- {tiers.map((tier) => (
219
+ {displayTiers.map((tier) => (
177
220
  <TierCard key={tier.id} tier={tier} />
178
221
  ))}
179
222
  </div>
@@ -4,6 +4,8 @@ const PROJECT_NAME = import.meta.env.VITE_PROJECT_NAME ?? '{{PROJECT_NAME}}';
4
4
 
5
5
  interface SEOProps {
6
6
  title?: string;
7
+ /** Exact document title, used verbatim without the `| PROJECT_NAME` suffix. */
8
+ fullTitle?: string;
7
9
  description?: string;
8
10
  }
9
11
 
@@ -13,11 +15,12 @@ interface SEOProps {
13
15
  * Usage:
14
16
  * <SEO title="Pricing" description="See our plans" />
15
17
  * <SEO title="Dashboard" />
18
+ * <SEO fullTitle="Acme - The Best Widget" />
16
19
  */
17
- export function SEO({ title, description }: SEOProps) {
20
+ export function SEO({ title, fullTitle, description }: SEOProps) {
18
21
  useEffect(() => {
19
- document.title = title ? `${title} | ${PROJECT_NAME}` : PROJECT_NAME;
20
- }, [title]);
22
+ document.title = fullTitle ?? (title ? `${title} | ${PROJECT_NAME}` : PROJECT_NAME);
23
+ }, [title, fullTitle]);
21
24
 
22
25
  useEffect(() => {
23
26
  if (!description) return;
@@ -70,7 +70,7 @@ function Calendar({
70
70
  : 'cn-calendar-caption-label rounded-(--cell-radius) flex items-center gap-1 text-sm [&>svg]:text-muted-foreground [&>svg]:size-3.5',
71
71
  defaultClassNames.caption_label
72
72
  ),
73
- table: 'w-full border-collapse',
73
+ month_grid: 'w-full border-collapse',
74
74
  weekdays: cn('flex', defaultClassNames.weekdays),
75
75
  weekday: cn(
76
76
  'text-muted-foreground rounded-(--cell-radius) flex-1 font-normal text-[0.8rem] select-none',
@@ -15,11 +15,19 @@ html {
15
15
  font-size: 18px;
16
16
  scroll-padding-top: 5rem;
17
17
  scrollbar-gutter: stable;
18
+ /* Guard against horizontal overflow from full-bleed decorative glows/orbs.
19
+ `clip` (not `hidden`) avoids creating a scroll container, so sticky/fixed
20
+ positioning and 100vh keep working. Body uses `clip` too — `hidden` there
21
+ forces overflow-y to `auto`, adding a second vertical scrollbar. */
22
+ overflow-x: clip;
18
23
  }
19
24
 
20
25
  body {
21
26
  position: relative;
22
- overflow-x: hidden;
27
+ /* `clip` (not `hidden`) hides horizontal overflow without forcing the
28
+ * computed overflow-y to `auto`, which would turn <body> into a second
29
+ * vertical scroll container on top of <html> (→ two scrollbars). */
30
+ overflow-x: clip;
23
31
  }
24
32
 
25
33
  @custom-variant dark (&:is(.dark *));