vibecarbon 0.5.1 → 0.6.1
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/README.md +1 -1
- package/carbon/.env.example +3 -0
- package/carbon/.github/workflows/deploy.yml +24 -21
- package/carbon/.github/workflows/vibecarbon-build.yml +14 -0
- package/carbon/Dockerfile +3 -0
- package/carbon/cloud-init/k3s/master-init.sh +26 -2
- package/carbon/cloud-init/k3s/supabase-init.sh +22 -2
- package/carbon/cloud-init/k3s/worker-init.sh +22 -2
- package/carbon/content/docs/cli.mdx +1 -1
- package/carbon/docker-compose.yml +6 -0
- package/carbon/k8s/base/app/deployment.yaml +5 -0
- package/carbon/k8s/values/supabase.values.yaml +30 -0
- package/carbon/package.json +15 -15
- package/carbon/scripts/generate-sitemap.ts +18 -3
- package/carbon/src/client/App.tsx +2 -0
- package/carbon/src/client/components/NewsletterSignup.tsx +4 -3
- package/carbon/src/client/components/PricingSection.tsx +44 -1
- package/carbon/src/client/components/SEO.tsx +6 -3
- package/carbon/src/client/index.css +9 -1
- package/carbon/src/client/index.html +3 -3
- package/carbon/src/client/locales/en.json +1 -1
- package/carbon/src/client/pages/Pricing.tsx +169 -0
- package/carbon/src/client/pages/settings/Billing.tsx +2 -6
- package/carbon/src/server/index.ts +23 -4
- package/carbon/src/server/lib/env.ts +12 -1
- package/carbon/src/shared/billing-catalog.ts +25 -0
- package/carbon/src/shared/billing-catalog.types.ts +35 -0
- package/carbon/src/shared/pricing.ts +56 -1
- package/package.json +1 -1
- package/src/activate.js +1 -1
- package/src/backup.js +33 -87
- package/src/configure.js +365 -137
- package/src/create.js +5 -2
- package/src/deploy.js +24 -7
- package/src/destroy.js +11 -4
- package/src/failover.js +64 -47
- package/src/lib/backup-format.js +84 -0
- package/src/lib/billing/stripe-catalog.js +152 -0
- package/src/lib/billing/write-catalog.js +123 -0
- package/src/lib/ci-setup.js +11 -1
- package/src/lib/config-registry.js +116 -0
- package/src/lib/deploy/compose/build-args.js +6 -0
- package/src/lib/deploy/compose/ha.js +25 -25
- package/src/lib/deploy/compose/index.js +29 -18
- package/src/lib/deploy/k8s/gitops-deploy.js +38 -14
- package/src/lib/deploy/k8s/k3s.js +19 -9
- package/src/lib/deploy/orchestrator.js +22 -1
- package/src/lib/deploy/prompts.js +21 -2
- package/src/lib/deploy/utils.js +56 -32
- package/src/lib/github-environments.js +29 -0
- package/src/lib/licensing/index.js +8 -3
- package/src/lib/licensing/tiers.js +0 -3
- package/src/lib/pod-backups.js +3 -3
- package/src/lib/providers/base.js +1 -1
- package/src/lib/providers/hetzner.js +74 -82
- package/src/lib/server-types.js +3 -31
- package/src/lib/ssh.js +19 -18
- package/src/lib/walg-backups.js +81 -0
- package/src/restore.js +122 -202
- package/src/scale.js +25 -29
- package/src/status.js +0 -69
- 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
|
|
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)
|
package/carbon/.env.example
CHANGED
|
@@ -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.
|
|
78
|
-
#
|
|
79
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
package/carbon/package.json
CHANGED
|
@@ -53,13 +53,13 @@
|
|
|
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.
|
|
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.
|
|
62
|
-
"@supabase/supabase-js": "^2.
|
|
61
|
+
"@scalar/api-reference-react": "^0.9.49",
|
|
62
|
+
"@supabase/supabase-js": "^2.108.2",
|
|
63
63
|
"@tanstack/react-query": "^5.100.14",
|
|
64
64
|
"class-variance-authority": "^0.7.1",
|
|
65
65
|
"clsx": "^2.1.1",
|
|
@@ -67,12 +67,12 @@
|
|
|
67
67
|
"date-fns": "^4.4.0",
|
|
68
68
|
"embla-carousel-react": "^8.6.0",
|
|
69
69
|
"framer-motion": "^12.40.0",
|
|
70
|
-
"hono": "^4.12.
|
|
71
|
-
"i18next": "^26.3.
|
|
70
|
+
"hono": "^4.12.27",
|
|
71
|
+
"i18next": "^26.3.3",
|
|
72
72
|
"i18next-browser-languagedetector": "^8.2.1",
|
|
73
73
|
"input-otp": "^1.4.2",
|
|
74
|
-
"ioredis": "^5.11.
|
|
75
|
-
"lenis": "^1.3.
|
|
74
|
+
"ioredis": "^5.11.1",
|
|
75
|
+
"lenis": "^1.3.25",
|
|
76
76
|
"lucide-react": "^1.17.0",
|
|
77
77
|
"next-themes": "^0.4.6",
|
|
78
78
|
"nodemailer": "^8.0.10",
|
|
@@ -125,29 +125,29 @@
|
|
|
125
125
|
"@biomejs/biome": "^2.4.16",
|
|
126
126
|
"@hono/swagger-ui": "^0.6.1",
|
|
127
127
|
"@scalar/hono-api-reference": "^0.10.19",
|
|
128
|
-
"@tailwindcss/typography": "^0.5.
|
|
129
|
-
"@tailwindcss/vite": "^4.3.
|
|
128
|
+
"@tailwindcss/typography": "^0.5.20",
|
|
129
|
+
"@tailwindcss/vite": "^4.3.2",
|
|
130
130
|
"@testing-library/jest-dom": "^6.9.1",
|
|
131
131
|
"@testing-library/react": "^16.3.2",
|
|
132
132
|
"@testing-library/user-event": "^14.6.1",
|
|
133
133
|
"@types/node": "^25.9.1",
|
|
134
134
|
"@types/nodemailer": "^8.0.0",
|
|
135
135
|
"@types/pg": "^8.20.0",
|
|
136
|
-
"@types/react": "^19.2.
|
|
136
|
+
"@types/react": "^19.2.17",
|
|
137
137
|
"@types/react-dom": "^19.2.3",
|
|
138
|
-
"@vitejs/plugin-react": "^6.0.
|
|
139
|
-
"@vitest/coverage-v8": "^4.1.
|
|
140
|
-
"esbuild": "^0.28.
|
|
138
|
+
"@vitejs/plugin-react": "^6.0.3",
|
|
139
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
140
|
+
"esbuild": "^0.28.1",
|
|
141
141
|
"jsdom": "^29.1.1",
|
|
142
142
|
"pg": "^8.21.0",
|
|
143
143
|
"pino-pretty": "^13.1.3",
|
|
144
144
|
"remark-gfm": "^4.0.1",
|
|
145
145
|
"shadcn": "^4.10.0",
|
|
146
|
-
"tailwindcss": "^4.3.
|
|
146
|
+
"tailwindcss": "^4.3.2",
|
|
147
147
|
"tsx": "^4.22.4",
|
|
148
148
|
"tw-animate-css": "^1.4.0",
|
|
149
149
|
"typescript": "^6.0.3",
|
|
150
150
|
"vite": "^8.0.16",
|
|
151
|
-
"vitest": "^4.1.
|
|
151
|
+
"vitest": "^4.1.9"
|
|
152
152
|
}
|
|
153
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
|
|
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-
|
|
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
|
-
{
|
|
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;
|
|
@@ -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
|
|
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 *));
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
<meta property="og:type" content="website" />
|
|
14
14
|
<meta property="og:title" content="%VITE_PROJECT_NAME%" />
|
|
15
15
|
<meta property="og:description" content="Build, launch, and scale your SaaS product with auth, billing, teams, email, blog, docs, i18n, and production infrastructure included." />
|
|
16
|
-
<meta property="og:url" content="
|
|
16
|
+
<meta property="og:url" content="%VITE_PUBLIC_URL%" />
|
|
17
17
|
<meta property="og:site_name" content="%VITE_PROJECT_NAME%" />
|
|
18
18
|
<!-- Replace with your own 1200x630 OG image for social sharing previews -->
|
|
19
|
-
<meta property="og:image" content="
|
|
19
|
+
<meta property="og:image" content="%VITE_PUBLIC_URL%/og-image.png" />
|
|
20
20
|
<meta property="og:image:width" content="1200" />
|
|
21
21
|
<meta property="og:image:height" content="630" />
|
|
22
22
|
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
<meta name="twitter:card" content="summary_large_image" />
|
|
25
25
|
<meta name="twitter:title" content="%VITE_PROJECT_NAME%" />
|
|
26
26
|
<meta name="twitter:description" content="Build, launch, and scale your SaaS product with auth, billing, teams, email, blog, docs, i18n, and production infrastructure included." />
|
|
27
|
-
<meta name="twitter:image" content="
|
|
27
|
+
<meta name="twitter:image" content="%VITE_PUBLIC_URL%/og-image.png" />
|
|
28
28
|
|
|
29
29
|
<!-- RSS -->
|
|
30
30
|
<link rel="alternate" type="application/rss+xml" title="%VITE_PROJECT_NAME% Blog" href="/rss.xml" />
|