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
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Stripe catalog fetch — pulls the customer's live products + prices so
3
+ * `vibecarbon configure` (Billing → Stripe) can let them pick which products to
4
+ * activate and snapshot the display fields into the project.
5
+ *
6
+ * Contains NO secrets in its output — product names, amounts, and price IDs are
7
+ * all public. The secret key only ever travels in the Authorization header of
8
+ * the live API call; it is never persisted by this module.
9
+ */
10
+
11
+ import { fetchWithRetry } from '../fetch-retry.js';
12
+
13
+ /**
14
+ * @typedef {Object} NormalizedPrice
15
+ * @property {string} priceId Stripe price id (`price_...`).
16
+ * @property {string} name Expanded product name (falls back to nickname/id).
17
+ * @property {string | null} description Expanded product description.
18
+ * @property {string[]} features Product marketing features (names).
19
+ * @property {number} amount Smallest currency unit (cents).
20
+ * @property {string} currency Lowercase ISO 4217 (e.g. `usd`).
21
+ * @property {'month' | 'year' | 'one_time'} interval
22
+ * @property {'recurring' | 'one_time'} type
23
+ */
24
+
25
+ /**
26
+ * Format a smallest-unit amount + currency for human-readable select labels,
27
+ * e.g. (1900, 'usd') -> "$19.00". Falls back to a generic "<major> <CODE>"
28
+ * form for currencies Intl doesn't render with a symbol.
29
+ *
30
+ * @param {number} amount Smallest currency unit (cents).
31
+ * @param {string} currency Lowercase ISO 4217 code.
32
+ * @returns {string}
33
+ */
34
+ export function formatAmount(amount, currency) {
35
+ const code = (currency || 'usd').toUpperCase();
36
+ try {
37
+ return new Intl.NumberFormat('en-US', {
38
+ style: 'currency',
39
+ currency: code,
40
+ }).format(amount / 100);
41
+ } catch {
42
+ // Unknown / unsupported currency code — render the major-unit number plainly.
43
+ return `${(amount / 100).toFixed(2)} ${code}`;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Normalize a raw Stripe price object (with `product` expanded) into the shape
49
+ * our billing catalog stores. Returns null for prices we can't represent
50
+ * (metered / null `unit_amount`) so the caller can skip them.
51
+ *
52
+ * @param {any} price
53
+ * @returns {NormalizedPrice | null}
54
+ */
55
+ function normalizePrice(price) {
56
+ if (!price || typeof price !== 'object') return null;
57
+ // Metered prices have a null unit_amount — we can't display a fixed amount,
58
+ // so skip them rather than emit a bogus 0.
59
+ if (price.unit_amount === null || price.unit_amount === undefined) return null;
60
+
61
+ const product = price.product && typeof price.product === 'object' ? price.product : null;
62
+ const name = product?.name || price.nickname || price.id;
63
+ const description = product?.description ?? null;
64
+ // Stripe product marketing_features is an array of { name }; flatten to the
65
+ // feature strings, dropping any with an empty/missing name.
66
+ const features = Array.isArray(product?.marketing_features)
67
+ ? product.marketing_features.map((f) => f?.name).filter(Boolean)
68
+ : [];
69
+
70
+ const recurring = price.recurring && typeof price.recurring === 'object' ? price.recurring : null;
71
+ // Our catalog type only allows month/year/one_time. Use the Stripe interval
72
+ // when it's month or year; for any other recurring interval (week/day) fall
73
+ // back to a safe 'month' default; non-recurring prices are one_time.
74
+ let interval;
75
+ if (recurring) {
76
+ interval =
77
+ recurring.interval === 'month' || recurring.interval === 'year'
78
+ ? recurring.interval
79
+ : 'month';
80
+ } else {
81
+ interval = 'one_time';
82
+ }
83
+
84
+ return {
85
+ priceId: price.id,
86
+ name,
87
+ description,
88
+ features,
89
+ amount: price.unit_amount,
90
+ currency: price.currency,
91
+ interval,
92
+ type: recurring ? 'recurring' : 'one_time',
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Fetch the customer's active Stripe prices (with products expanded),
98
+ * normalize them, and return them sorted by amount ascending.
99
+ *
100
+ * Single page (limit=100) — fine for MVP. If Stripe reports `has_more`, we log
101
+ * a note but don't paginate; a customer with >100 active prices is well past
102
+ * the activate-products use case this serves.
103
+ *
104
+ * @param {string} secretKey Stripe secret key (`sk_test_...` / `sk_live_...`).
105
+ * @returns {Promise<NormalizedPrice[]>} Sorted by amount ascending.
106
+ * @throws {Error} on non-OK response (e.g. 401 invalid key) so the caller can
107
+ * warn and skip catalog writing.
108
+ */
109
+ export async function listStripePrices(secretKey) {
110
+ // Stripe wants form-encoded query params; `expand[]=data.product` must be
111
+ // sent literally (URLSearchParams would percent-encode the brackets, which
112
+ // Stripe rejects), so build the query string by hand.
113
+ const url = 'https://api.stripe.com/v1/prices?active=true&limit=100&expand[]=data.product';
114
+
115
+ const response = await fetchWithRetry(url, {
116
+ headers: { Authorization: `Bearer ${secretKey}` },
117
+ label: 'stripe prices',
118
+ });
119
+
120
+ if (!response.ok) {
121
+ // Surface Stripe's own error message when present — its body is
122
+ // { error: { message, type, ... } } for auth/validation failures.
123
+ let detail = `HTTP ${response.status}`;
124
+ try {
125
+ const body = await response.json();
126
+ if (body?.error?.message) detail = body.error.message;
127
+ } catch {
128
+ // Non-JSON body (rare) — keep the status-code detail.
129
+ }
130
+ throw new Error(`Stripe price fetch failed: ${detail}`);
131
+ }
132
+
133
+ const body = await response.json();
134
+ const rows = Array.isArray(body?.data) ? body.data : [];
135
+ const prices = [];
136
+ for (const price of rows) {
137
+ const normalized = normalizePrice(price);
138
+ if (normalized) prices.push(normalized);
139
+ }
140
+
141
+ if (body?.has_more) {
142
+ console.error(
143
+ '[stripe] more than 100 active prices exist; only the first page was loaded. Activation uses this page.',
144
+ );
145
+ }
146
+
147
+ // Sort by amount ascending so the activate multiselect and the snapshot both
148
+ // present cheapest-first (free tiers, then paid).
149
+ prices.sort((a, b) => a.amount - b.amount);
150
+
151
+ return prices;
152
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * write-catalog — serializes a billing catalog snapshot into the project's
3
+ * `src/shared/billing-catalog.ts` data module.
4
+ *
5
+ * The companion `billing-catalog.types.ts` ships in the template and is never
6
+ * regenerated; this writer only replaces the data file so `vibecarbon
7
+ * configure` (Billing → Stripe) can refresh pricing without touching types.
8
+ */
9
+
10
+ import { existsSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ /**
14
+ * Escaped TS string literal matching the repo's biome style: prefer single
15
+ * quotes, but switch to double quotes when the string contains more single than
16
+ * double quotes (biome's "fewer escapes" rule, e.g. `"Developer's Plan"`).
17
+ */
18
+ function tsString(s) {
19
+ const str = String(s);
20
+ const singles = (str.match(/'/g) || []).length;
21
+ const doubles = (str.match(/"/g) || []).length;
22
+ const quote = singles > doubles ? '"' : "'";
23
+ const escaped = str
24
+ .replace(/\\/g, '\\\\')
25
+ .replaceAll(quote, `\\${quote}`)
26
+ .replace(/\n/g, '\\n')
27
+ .replace(/\r/g, '\\r')
28
+ .replace(/\t/g, '\\t');
29
+ return `${quote}${escaped}${quote}`;
30
+ }
31
+
32
+ const IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
33
+ const LINE_WIDTH = 80; // biome's default; keep generated output collapse/break-equivalent
34
+
35
+ function isPrimitive(v) {
36
+ return v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
37
+ }
38
+
39
+ /**
40
+ * Serialize plain JSON-ish data (objects / strings / numbers / booleans / null)
41
+ * into a biome-conformant TS object literal: unquoted identifier keys, single
42
+ * quotes, trailing commas, two-space indent. Avoids the double-quoted, quoted-key
43
+ * output of JSON.stringify, which fails the project's lint gate.
44
+ *
45
+ * `column` is the column at which this value begins, used to mirror biome's
46
+ * "inline an array if it fits within the line width, else break one-per-line"
47
+ * rule for primitive arrays (e.g. short `features` lists stay on one line).
48
+ */
49
+ function serializeTs(value, indentLevel = 0, column = 0) {
50
+ if (value === null) return 'null';
51
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
52
+ if (typeof value === 'string') return tsString(value);
53
+
54
+ const pad = ' '.repeat(indentLevel);
55
+ const padInner = ' '.repeat(indentLevel + 1);
56
+
57
+ if (Array.isArray(value)) {
58
+ if (value.length === 0) return '[]';
59
+ if (value.every(isPrimitive)) {
60
+ const inline = `[${value.map((v) => serializeTs(v)).join(', ')}]`;
61
+ // +1 for the trailing comma that follows on the same line.
62
+ if (column + inline.length + 1 <= LINE_WIDTH) return inline;
63
+ }
64
+ const items = value
65
+ .map((v) => `${padInner}${serializeTs(v, indentLevel + 1, padInner.length)},`)
66
+ .join('\n');
67
+ return `[\n${items}\n${pad}]`;
68
+ }
69
+
70
+ const entries = Object.entries(value).filter(([, v]) => v !== undefined);
71
+ if (entries.length === 0) return '{}';
72
+ const lines = entries
73
+ .map(([k, v]) => {
74
+ const key = IDENT_RE.test(k) ? k : tsString(k);
75
+ const prefix = `${padInner}${key}: `;
76
+ return `${prefix}${serializeTs(v, indentLevel + 1, prefix.length)},`;
77
+ })
78
+ .join('\n');
79
+ return `{\n${lines}\n${pad}}`;
80
+ }
81
+
82
+ /**
83
+ * Serialize + write the billing catalog into `<cwd>/src/shared/billing-catalog.ts`.
84
+ *
85
+ * The emitted module mirrors the shipped default's shape exactly: the
86
+ * `import type { BillingCatalog }` line, a generated-file header, and
87
+ * `export const billingCatalog: BillingCatalog = <json>;`.
88
+ *
89
+ * If `<cwd>/src/shared` does not exist this isn't an app project root — return
90
+ * false and skip rather than throwing, so the configure flow never blocks on a
91
+ * missing snapshot target.
92
+ *
93
+ * @param {string} cwd Project root.
94
+ * @param {{ provider: string | null, generatedAt: string | null, tiers: Array<Record<string, unknown>> }} catalog
95
+ * `tiers` is a price-sorted list of activated products (CatalogTier[]).
96
+ * @returns {boolean} true if written, false if skipped (no src/shared).
97
+ */
98
+ export function writeBillingCatalog(cwd, catalog) {
99
+ const sharedDir = join(cwd, 'src', 'shared');
100
+ if (!existsSync(sharedDir)) return false;
101
+
102
+ const targetPath = join(sharedDir, 'billing-catalog.ts');
103
+ // Emit a biome-conformant TS literal (single quotes, unquoted keys, trailing
104
+ // commas) so the generated file passes the project's lint gate as-is.
105
+ const body = serializeTs(catalog, 0);
106
+
107
+ const content = `/**
108
+ * Billing catalog snapshot — GENERATED by \`vibecarbon configure\` (Billing →
109
+ * Stripe). Do not hand-edit: re-run \`vibecarbon configure\` to refresh after
110
+ * changing a price or product name in the provider dashboard, then redeploy.
111
+ *
112
+ * Contains NO secrets — product names, amounts, and price IDs are all public.
113
+ * Types live in \`billing-catalog.types.ts\` (not regenerated).
114
+ */
115
+
116
+ import type { BillingCatalog } from './billing-catalog.types';
117
+
118
+ export const billingCatalog: BillingCatalog = ${body};
119
+ `;
120
+
121
+ writeFileSync(targetPath, content);
122
+ return true;
123
+ }
@@ -449,7 +449,7 @@ export async function commitAndPushWorkflow(cwd = process.cwd()) {
449
449
  let _ghAuthChecked = false;
450
450
 
451
451
  export async function ensureCIImageReady(options = {}) {
452
- const { yes = false, tracker, cwd = process.cwd() } = options;
452
+ const { yes = false, tracker, cwd = process.cwd(), buildArgs = null } = options;
453
453
 
454
454
  if (!checkDependency('gh', 'GitHub CLI')) {
455
455
  throw new Error(
@@ -473,6 +473,16 @@ export async function ensureCIImageReady(options = {}) {
473
473
  );
474
474
  }
475
475
 
476
+ // Seed the VITE_* build args as repo variables BEFORE the workflow runs, so
477
+ // the CI-built image bakes real values instead of empty strings. Must
478
+ // precede commitAndPushWorkflow / workflow dispatch below (both trigger the
479
+ // build). Skipped when the caller passes no buildArgs (e.g. older callers).
480
+ if (buildArgs && Object.keys(buildArgs).length > 0) {
481
+ const { seedBuildVars } = await import('./github-environments.js');
482
+ const applied = await seedBuildVars(buildArgs);
483
+ if (applied.length) p.log.info(`Seeded ${applied.length} VITE build var(s) for CI`);
484
+ }
485
+
476
486
  // Install workflow if missing — first-deploy case
477
487
  const installed = installWorkflowFile(cwd);
478
488
  if (installed) {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Single source of truth for the env keys that `vibecarbon configure` manages.
3
+ *
4
+ * `configure` writes feature config/secrets to `.env.local`/`.env`. Several
5
+ * deploy paths each need to know *which* keys to propagate and *how* to treat
6
+ * each one (build-time vs runtime, secret vs non-secret). Historically each
7
+ * path carried its own hand-maintained list, so a key added to one feature
8
+ * silently failed to reach the cloud on another path. This registry is the one
9
+ * list they all derive from; coverage tests assert registry ⊆ each path.
10
+ *
11
+ * Scope: feature keys only (billing, OAuth, SMTP, analytics). Infra secrets
12
+ * (DB_PASSWORD/JWT_SECRET/ANON_KEY/…) are *generated*, not configured, and
13
+ * carry k8s-specific Supabase-chart translation logic — they stay local to the
14
+ * k8s deploy modules.
15
+ *
16
+ * Classification:
17
+ * - 'client-build' → baked into the client bundle at image build time
18
+ * (VITE_*). A change must force an image rebuild.
19
+ * - 'runtime-config' → non-secret server/auth runtime env.
20
+ * - 'runtime-secret' → secret server/auth runtime env.
21
+ *
22
+ * Keep this aligned with what `configure` writes (src/configure.js) and what
23
+ * the app/auth services read (carbon/src/server/lib/env.ts + the GoTrue
24
+ * `auth` service wiring in carbon/docker-compose.yml). Dependency-free on
25
+ * purpose so deploy code can import it without pulling in @clack/prompts.
26
+ */
27
+
28
+ /**
29
+ * @typedef {'client-build' | 'runtime-config' | 'runtime-secret'} ConfigClass
30
+ * @typedef {{ key: string, class: ConfigClass, feature: string }} ConfigKey
31
+ */
32
+
33
+ /** @type {ConfigKey[]} */
34
+ export const CONFIG_KEYS = [
35
+ // ---- Billing: provider selection ----
36
+ { key: 'BILLING_PROVIDER', class: 'runtime-config', feature: 'billing' },
37
+
38
+ // ---- Billing: Stripe ----
39
+ { key: 'STRIPE_SECRET_KEY', class: 'runtime-secret', feature: 'billing' },
40
+ { key: 'STRIPE_WEBHOOK_SECRET', class: 'runtime-secret', feature: 'billing' },
41
+ { key: 'STRIPE_PRICE_STARTER', class: 'runtime-config', feature: 'billing' },
42
+ { key: 'STRIPE_PRICE_PRO', class: 'runtime-config', feature: 'billing' },
43
+
44
+ // ---- Billing: Paddle ----
45
+ { key: 'PADDLE_API_KEY', class: 'runtime-secret', feature: 'billing' },
46
+ { key: 'PADDLE_WEBHOOK_SECRET', class: 'runtime-secret', feature: 'billing' },
47
+ { key: 'PADDLE_ENVIRONMENT', class: 'runtime-config', feature: 'billing' },
48
+ { key: 'PADDLE_PRICE_STARTER', class: 'runtime-config', feature: 'billing' },
49
+ { key: 'PADDLE_PRICE_PRO', class: 'runtime-config', feature: 'billing' },
50
+
51
+ // ---- Billing: Polar ----
52
+ { key: 'POLAR_ACCESS_TOKEN', class: 'runtime-secret', feature: 'billing' },
53
+ { key: 'POLAR_WEBHOOK_SECRET', class: 'runtime-secret', feature: 'billing' },
54
+ { key: 'POLAR_ORGANIZATION_ID', class: 'runtime-config', feature: 'billing' },
55
+ { key: 'POLAR_PRICE_STARTER', class: 'runtime-config', feature: 'billing' },
56
+ { key: 'POLAR_PRICE_PRO', class: 'runtime-config', feature: 'billing' },
57
+
58
+ // ---- OAuth: Google (consumed by the GoTrue auth service) ----
59
+ { key: 'GOOGLE_ENABLED', class: 'runtime-config', feature: 'oauth' },
60
+ { key: 'GOOGLE_CLIENT_ID', class: 'runtime-config', feature: 'oauth' },
61
+ { key: 'GOOGLE_CLIENT_SECRET', class: 'runtime-secret', feature: 'oauth' },
62
+
63
+ // ---- OAuth: Microsoft ----
64
+ { key: 'MICROSOFT_ENABLED', class: 'runtime-config', feature: 'oauth' },
65
+ { key: 'MICROSOFT_CLIENT_ID', class: 'runtime-config', feature: 'oauth' },
66
+ { key: 'MICROSOFT_CLIENT_SECRET', class: 'runtime-secret', feature: 'oauth' },
67
+ { key: 'MICROSOFT_TENANT_ID', class: 'runtime-config', feature: 'oauth' },
68
+
69
+ // ---- SMTP / Email (shared by the app and the auth service) ----
70
+ // Canonical name is SMTP_PASS everywhere (app env.ts, compose, manifests).
71
+ { key: 'SMTP_HOST', class: 'runtime-config', feature: 'smtp' },
72
+ { key: 'SMTP_PORT', class: 'runtime-config', feature: 'smtp' },
73
+ { key: 'SMTP_USER', class: 'runtime-config', feature: 'smtp' },
74
+ { key: 'SMTP_PASS', class: 'runtime-secret', feature: 'smtp' },
75
+ { key: 'SMTP_ADMIN_EMAIL', class: 'runtime-config', feature: 'smtp' },
76
+ { key: 'SMTP_SENDER_NAME', class: 'runtime-config', feature: 'smtp' },
77
+
78
+ // ---- Analytics (client-side, baked at build time) ----
79
+ { key: 'VITE_PLAUSIBLE_DOMAIN', class: 'client-build', feature: 'analytics' },
80
+ { key: 'VITE_PLAUSIBLE_SCRIPT_URL', class: 'client-build', feature: 'analytics' },
81
+ ];
82
+
83
+ /** Keys of a given classification. @param {ConfigClass} cls */
84
+ function keysOfClass(cls) {
85
+ return CONFIG_KEYS.filter((k) => k.class === cls).map((k) => k.key);
86
+ }
87
+
88
+ /** Secret runtime keys (Stripe/Paddle/Polar secrets, OAuth secrets, SMTP_PASS). */
89
+ export function featureSecretKeys() {
90
+ return keysOfClass('runtime-secret');
91
+ }
92
+
93
+ /** Non-secret runtime keys (BILLING_PROVIDER, SMTP_HOST, GOOGLE_CLIENT_ID, …). */
94
+ export function featureConfigKeys() {
95
+ return keysOfClass('runtime-config');
96
+ }
97
+
98
+ /** All runtime keys (secret + non-secret) — everything that must reach a pod/container at runtime. */
99
+ export function featureRuntimeKeys() {
100
+ return [...featureConfigKeys(), ...featureSecretKeys()];
101
+ }
102
+
103
+ /** Client build-time keys (VITE_*) — a change must force an image rebuild. */
104
+ export function clientBuildKeys() {
105
+ return keysOfClass('client-build');
106
+ }
107
+
108
+ /** Whether `key` is a configure-managed secret. @param {string} key */
109
+ export function isSecretKey(key) {
110
+ return CONFIG_KEYS.some((k) => k.key === key && k.class === 'runtime-secret');
111
+ }
112
+
113
+ /** All keys belonging to a feature, in registry order. @param {string} feature */
114
+ export function featureKeys(feature) {
115
+ return CONFIG_KEYS.filter((k) => k.feature === feature).map((k) => k.key);
116
+ }
@@ -58,6 +58,12 @@ export function collectComposeBuildArgs(cwd, opts = {}) {
58
58
  // server's runtime .env so the client + server agree on the URL.
59
59
  if (projectName) args.VITE_PROJECT_NAME = projectName;
60
60
  if (domain) args.VITE_SUPABASE_URL = `https://api.${domain}`;
61
+ // Public canonical URL (apex domain) baked into the client at build time —
62
+ // drives og:url / og:image / twitter:image and the generated sitemap. Distinct
63
+ // from VITE_SUPABASE_URL's `api.` host. Without this the client falls back to
64
+ // the create-time SITE_URL (localhost), so social previews + sitemap point at
65
+ // localhost on every deployed site.
66
+ if (domain) args.VITE_PUBLIC_URL = `https://${domain}`;
61
67
  return args;
62
68
  }
63
69
 
@@ -215,7 +215,7 @@ async function deleteFirewallByName(name, hetznerHeaders, deadlineMs = 30_000) {
215
215
  * 3. Set wal_level=replica and max_wal_senders
216
216
  * 4. Restart PostgreSQL to apply changes
217
217
  */
218
- function configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName) {
218
+ async function configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName) {
219
219
  const remoteDir = `/opt/${projectName}`;
220
220
  // replPassword is generated at create time from crypto.randomBytes and is
221
221
  // restricted to base64url characters — no shell escaping needed.
@@ -264,7 +264,7 @@ BEGIN
264
264
  END $$;
265
265
  `;
266
266
  const createRoleB64 = Buffer.from(createRoleSql).toString('base64');
267
- sshRun(
267
+ await sshRun(
268
268
  primaryIp,
269
269
  sshKeyPath,
270
270
  `cd ${remoteDir} && echo ${createRoleB64} | base64 -d | docker compose exec -T db psql -U supabase_admin -d postgres`,
@@ -279,7 +279,7 @@ END $$;
279
279
  // pg_hba_file_rules reflected the actual /etc loaded contents (no replicator
280
280
  // entry). Resolve the path at runtime via SHOW hba_file so we don't break
281
281
  // again if the image moves the file.
282
- sshRun(
282
+ await sshRun(
283
283
  primaryIp,
284
284
  sshKeyPath,
285
285
  `cd ${remoteDir} && HBA_FILE=$(docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SHOW hba_file;' | tr -d '[:space:]') && ` +
@@ -289,7 +289,7 @@ END $$;
289
289
  );
290
290
 
291
291
  // Set WAL settings for replication
292
- sshRun(
292
+ await sshRun(
293
293
  primaryIp,
294
294
  sshKeyPath,
295
295
  `cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER SYSTEM SET wal_level = 'replica';" && docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER SYSTEM SET max_wal_senders = 5;" && docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER SYSTEM SET wal_keep_size = '512MB';"`,
@@ -297,14 +297,14 @@ END $$;
297
297
  );
298
298
 
299
299
  // Restart PostgreSQL to apply WAL changes
300
- sshRun(primaryIp, sshKeyPath, `cd ${remoteDir} && docker compose restart db`, {
300
+ await sshRun(primaryIp, sshKeyPath, `cd ${remoteDir} && docker compose restart db`, {
301
301
  timeout: 60_000,
302
302
  });
303
303
 
304
304
  // Wait for PostgreSQL to be ready again
305
305
  for (let i = 0; i < 15; i++) {
306
306
  try {
307
- sshRun(
307
+ await sshRun(
308
308
  primaryIp,
309
309
  sshKeyPath,
310
310
  `cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres`,
@@ -359,7 +359,7 @@ BEGIN
359
359
  END IF;
360
360
  END $$;`;
361
361
  const ensureSlotB64 = Buffer.from(ensureSlotSql).toString('base64');
362
- sshRun(
362
+ await sshRun(
363
363
  primaryIp,
364
364
  sshKeyPath,
365
365
  `cd ${remoteDir} && echo ${ensureSlotB64} | base64 -d | docker compose exec -T db psql -U supabase_admin -d postgres`,
@@ -367,7 +367,7 @@ END $$;`;
367
367
  );
368
368
 
369
369
  // Stop the standby database
370
- sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose stop db`, {
370
+ await sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose stop db`, {
371
371
  timeout: 60_000,
372
372
  });
373
373
 
@@ -430,7 +430,7 @@ grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
430
430
  `;
431
431
  const basebackupB64 = Buffer.from(basebackupScript).toString('base64');
432
432
  try {
433
- sshRun(
433
+ await sshRun(
434
434
  standbyIp,
435
435
  sshKeyPath,
436
436
  // Use bash, not sh: the supabase/postgres image symlinks /bin/sh →
@@ -465,7 +465,7 @@ grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
465
465
  }
466
466
 
467
467
  // Start the standby database
468
- sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose start db`, {
468
+ await sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose start db`, {
469
469
  timeout: 60_000,
470
470
  });
471
471
 
@@ -496,7 +496,7 @@ grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
496
496
  let lastPrimarySignal = '';
497
497
  for (let i = 0; i < PROBE_BUDGET_S / PROBE_INTERVAL_S; i++) {
498
498
  try {
499
- const result = sshRun(
499
+ const result = await sshRun(
500
500
  standbyIp,
501
501
  sshKeyPath,
502
502
  `cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SELECT pg_is_in_recovery()'`,
@@ -508,7 +508,7 @@ grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
508
508
  // Retry — database may still be applying initial WAL, hot_standby off
509
509
  }
510
510
  try {
511
- const primaryResult = sshRun(
511
+ const primaryResult = await sshRun(
512
512
  primaryIp,
513
513
  sshKeyPath,
514
514
  `cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT state FROM pg_stat_replication WHERE client_addr = '${standbyIp}'::inet"`,
@@ -629,7 +629,7 @@ async function openReplicationPortHetznerFirewall(serverName, peerIp, hetznerTok
629
629
  */
630
630
  async function isPrimaryReachable(primaryIp, sshKeyPath, remoteDir) {
631
631
  try {
632
- const result = sshRun(
632
+ const result = await sshRun(
633
633
  primaryIp,
634
634
  sshKeyPath,
635
635
  `cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres -t 3`,
@@ -1122,7 +1122,7 @@ export async function deployComposeHA(options) {
1122
1122
  const healthConfirmed = await perfAsync('deploy.ha.compose.primaryHealthProbe', async () => {
1123
1123
  for (let i = 0; i < 30; i++) {
1124
1124
  try {
1125
- const health = sshRun(
1125
+ const health = await sshRun(
1126
1126
  primaryIp,
1127
1127
  sshKeyPath,
1128
1128
  `cd /opt/${projectName} && docker compose exec -T db pg_isready -h localhost -p 5432 2>/dev/null`,
@@ -1185,7 +1185,7 @@ export async function deployComposeHA(options) {
1185
1185
  // dominated by pg_basebackup for the standby seed.
1186
1186
  await perfAsync('deploy.ha.replication.setup', async () => {
1187
1187
  onProgress('Configuring PostgreSQL replication on primary...');
1188
- configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName);
1188
+ await configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName);
1189
1189
 
1190
1190
  onProgress('Configuring standby as hot replica...');
1191
1191
  let replicationOk = false;
@@ -1221,7 +1221,7 @@ export async function deployComposeHA(options) {
1221
1221
  let replActive = false;
1222
1222
  for (let i = 0; i < REPL_DELAYS_MS.length; i++) {
1223
1223
  try {
1224
- const count = sshRun(
1224
+ const count = await sshRun(
1225
1225
  primaryIp,
1226
1226
  sshKeyPath,
1227
1227
  `cd /opt/${projectName} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'"`,
@@ -1597,7 +1597,7 @@ export async function waitForNewPrimaryApi(ip, sshKeyPath, domain, opts = {}) {
1597
1597
  let lastCode = '';
1598
1598
  for (let i = 0; i < attempts; i++) {
1599
1599
  try {
1600
- const code = runner(ip, sshKeyPath, cmd, { silent: true, timeout: 12_000 });
1600
+ const code = await runner(ip, sshKeyPath, cmd, { silent: true, timeout: 12_000 });
1601
1601
  lastCode = typeof code === 'string' ? code.trim() : '';
1602
1602
  if (lastCode === '200') return true;
1603
1603
  } catch {
@@ -1746,9 +1746,9 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
1746
1746
  // converted to "did not exit recovery mode after 5 attempts" because
1747
1747
  // attempts 2-5 hit the same not-in-recovery error and never re-checked.
1748
1748
  // RCA: compose-ha 2026-05-01 fanout4b failover.
1749
- const probeRecovery = () => {
1749
+ const probeRecovery = async () => {
1750
1750
  try {
1751
- const r = sshRun(
1751
+ const r = await sshRun(
1752
1752
  standbyServer.ip,
1753
1753
  sshKeyPath,
1754
1754
  `cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SELECT pg_is_in_recovery()'`,
@@ -1772,7 +1772,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
1772
1772
  const MAX_PROMOTE_ATTEMPTS = 5;
1773
1773
  for (let attempt = 1; attempt <= MAX_PROMOTE_ATTEMPTS && !promoted; attempt++) {
1774
1774
  try {
1775
- const result = sshRun(
1775
+ const result = await sshRun(
1776
1776
  standbyServer.ip,
1777
1777
  sshKeyPath,
1778
1778
  `cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT pg_promote(wait => true, wait_seconds => 120)"`,
@@ -1783,7 +1783,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
1783
1783
  promoted = true;
1784
1784
  break;
1785
1785
  }
1786
- const probeTrimmed = probeRecovery();
1786
+ const probeTrimmed = await probeRecovery();
1787
1787
  if (probeTrimmed === 'f') {
1788
1788
  promoted = true;
1789
1789
  break;
@@ -1792,7 +1792,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
1792
1792
  `pg_promote returned ${JSON.stringify(trimmed)}; pg_is_in_recovery=${JSON.stringify(probeTrimmed)}`,
1793
1793
  );
1794
1794
  } catch (err) {
1795
- const probeTrimmed = probeRecovery();
1795
+ const probeTrimmed = await probeRecovery();
1796
1796
  if (probeTrimmed === 'f') {
1797
1797
  promoted = true;
1798
1798
  break;
@@ -1809,7 +1809,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
1809
1809
  // instead of guessing. Best-effort — failover-step abort is what matters.
1810
1810
  let dbLogs = '';
1811
1811
  try {
1812
- dbLogs = sshRun(
1812
+ dbLogs = await sshRun(
1813
1813
  standbyServer.ip,
1814
1814
  sshKeyPath,
1815
1815
  `cd ${remoteDir} && docker compose logs db --tail=60`,
@@ -1837,7 +1837,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
1837
1837
  // promotion. Restart is cheap (~10s) and forces a clean reconnect.
1838
1838
  s.start('Restarting new-primary app-tier');
1839
1839
  try {
1840
- sshRun(
1840
+ await sshRun(
1841
1841
  standbyServer.ip,
1842
1842
  sshKeyPath,
1843
1843
  `cd ${remoteDir} && docker compose restart supavisor auth rest realtime storage app`,
@@ -1868,7 +1868,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
1868
1868
  // Step 2: Stop old primary services to prevent split-brain
1869
1869
  s.start('Stopping old primary services');
1870
1870
  try {
1871
- sshRun(
1871
+ await sshRun(
1872
1872
  primaryServer.ip,
1873
1873
  sshKeyPath,
1874
1874
  `cd ${remoteDir} && docker compose stop app auth rest realtime storage supavisor`,