vibecarbon 0.5.1 → 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 (61) 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/cloud-init/k3s/master-init.sh +26 -2
  7. package/carbon/cloud-init/k3s/supabase-init.sh +22 -2
  8. package/carbon/cloud-init/k3s/worker-init.sh +22 -2
  9. package/carbon/content/docs/cli.mdx +1 -1
  10. package/carbon/docker-compose.yml +6 -0
  11. package/carbon/k8s/base/app/deployment.yaml +5 -0
  12. package/carbon/k8s/values/supabase.values.yaml +30 -0
  13. package/carbon/package.json +14 -14
  14. package/carbon/scripts/generate-sitemap.ts +18 -3
  15. package/carbon/src/client/App.tsx +2 -0
  16. package/carbon/src/client/components/NewsletterSignup.tsx +4 -3
  17. package/carbon/src/client/components/PricingSection.tsx +44 -1
  18. package/carbon/src/client/components/SEO.tsx +6 -3
  19. package/carbon/src/client/index.css +9 -1
  20. package/carbon/src/client/index.html +3 -3
  21. package/carbon/src/client/locales/en.json +1 -1
  22. package/carbon/src/client/pages/Pricing.tsx +169 -0
  23. package/carbon/src/client/pages/settings/Billing.tsx +2 -6
  24. package/carbon/src/server/index.ts +23 -4
  25. package/carbon/src/shared/billing-catalog.ts +25 -0
  26. package/carbon/src/shared/billing-catalog.types.ts +35 -0
  27. package/carbon/src/shared/pricing.ts +56 -1
  28. package/package.json +1 -1
  29. package/src/activate.js +1 -1
  30. package/src/backup.js +33 -87
  31. package/src/configure.js +365 -137
  32. package/src/create.js +5 -2
  33. package/src/deploy.js +24 -7
  34. package/src/destroy.js +11 -4
  35. package/src/failover.js +64 -47
  36. package/src/lib/backup-format.js +84 -0
  37. package/src/lib/billing/stripe-catalog.js +152 -0
  38. package/src/lib/billing/write-catalog.js +123 -0
  39. package/src/lib/ci-setup.js +11 -1
  40. package/src/lib/config-registry.js +116 -0
  41. package/src/lib/deploy/compose/build-args.js +6 -0
  42. package/src/lib/deploy/compose/ha.js +25 -25
  43. package/src/lib/deploy/compose/index.js +29 -18
  44. package/src/lib/deploy/k8s/gitops-deploy.js +38 -14
  45. package/src/lib/deploy/k8s/k3s.js +19 -9
  46. package/src/lib/deploy/orchestrator.js +22 -1
  47. package/src/lib/deploy/prompts.js +21 -2
  48. package/src/lib/deploy/utils.js +56 -32
  49. package/src/lib/github-environments.js +29 -0
  50. package/src/lib/licensing/index.js +8 -3
  51. package/src/lib/licensing/tiers.js +0 -3
  52. package/src/lib/pod-backups.js +3 -3
  53. package/src/lib/providers/base.js +1 -1
  54. package/src/lib/providers/hetzner.js +74 -82
  55. package/src/lib/server-types.js +3 -31
  56. package/src/lib/ssh.js +19 -18
  57. package/src/lib/walg-backups.js +81 -0
  58. package/src/restore.js +122 -202
  59. package/src/scale.js +25 -29
  60. package/src/status.js +0 -69
  61. package/src/lib/cost.js +0 -103
package/src/configure.js CHANGED
@@ -15,13 +15,17 @@ import { existsSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import * as p from '@clack/prompts';
17
17
 
18
+ import { formatAmount, listStripePrices } from './lib/billing/stripe-catalog.js';
19
+ import { writeBillingCatalog } from './lib/billing/write-catalog.js';
18
20
  import { renderHelp } from './lib/cli/help.js';
19
21
  import { parseFlags } from './lib/cli/parse-flags.js';
20
22
  import { c, printBanner } from './lib/colors.js';
21
23
  import { loadCredentials, loadProjectConfig } from './lib/config.js';
24
+ import { featureKeys, featureSecretKeys } from './lib/config-registry.js';
22
25
  import { requireLicense } from './lib/licensing/index.js';
23
26
  import { buildGitAddArgv, loadEnvVariables, setEnvVar } from './lib/project.js';
24
27
  import { assertInProjectDir } from './lib/project-guard.js';
28
+ import { validateAdminEmail } from './lib/validators.js';
25
29
  import { VERSION } from './lib/version.js';
26
30
 
27
31
  /** @type {import('./lib/cli/parse-flags.js').CommandSpec & { summary?: string, description?: string, examples?: Array<{ command: string, description?: string }> }} */
@@ -41,12 +45,11 @@ const SPEC = {
41
45
  ' k8s/k8s-ha cluster. Diamond/Fullerene only.',
42
46
  '',
43
47
  'CONFIGURABLE FEATURES',
44
- ' CI/CD GitHub Actions build + deploy workflows',
45
- ' Billing Stripe, Paddle, or Polar payment processing',
46
- ' Google OAuth Sign in with Google',
47
- ' Microsoft OAuth Sign in with Microsoft',
48
- ' SMTP / Email Transactional emails (Resend, Mailgun, SES, etc.)',
49
- ' Plausible Analytics Privacy-friendly web analytics',
48
+ ' CI/CD Auto build & deploy with GitHub Actions',
49
+ ' Payments Stripe payments (subscriptions + one-time)',
50
+ ' OAuth Sign in with Google or Microsoft',
51
+ ' SMTP / Email Transactional emails (Resend or any SMTP)',
52
+ ' Analytics Privacy-friendly web analytics (Plausible)',
50
53
  ].join('\n'),
51
54
  flags: [
52
55
  { name: 'h', boolean: true, description: 'Show this help' },
@@ -73,16 +76,22 @@ const SPEC = {
73
76
  * Returns the entered value, or the current value if user pressed Enter on a non-empty placeholder.
74
77
  */
75
78
  async function promptText(message, currentValue, options = {}) {
76
- const placeholder = currentValue || options.placeholder || '';
77
- const defaultValue = currentValue || options.defaultValue || undefined;
79
+ const fallback = currentValue || options.defaultValue || undefined;
80
+ const placeholder = fallback || options.placeholder || '';
78
81
  const result = await p.text({
79
82
  message,
80
83
  placeholder: placeholder ? `${placeholder}` : undefined,
81
- defaultValue,
82
- validate: options.validate,
84
+ defaultValue: fallback,
85
+ // @clack's text prompt runs validate on the raw (possibly empty) value before
86
+ // substituting defaultValue, so a bare validate would reject Enter-on-default.
87
+ // When we have a default, accept empty submit (Tab/Enter) and fall back to it.
88
+ validate: (value) => {
89
+ if (!value?.trim() && fallback !== undefined) return undefined;
90
+ return options.validate?.(value);
91
+ },
83
92
  });
84
93
  if (p.isCancel(result)) return null;
85
- return result;
94
+ return result || fallback || '';
86
95
  }
87
96
 
88
97
  function requireNonEmpty(label) {
@@ -107,6 +116,21 @@ async function promptSecret(message, currentValue) {
107
116
  return result || currentValue || '';
108
117
  }
109
118
 
119
+ /**
120
+ * Best-effort deploy domain for project-aware prompt suggestions.
121
+ * Prefers the prod environment, then any environment that has a domain.
122
+ * Returns null when the project hasn't been deployed / has no domain yet.
123
+ */
124
+ function projectDomain(projectConfig) {
125
+ const envs = projectConfig?.environments;
126
+ if (!envs) return null;
127
+ if (envs.prod?.domain) return envs.prod.domain;
128
+ for (const e of Object.values(envs)) {
129
+ if (e?.domain) return e.domain;
130
+ }
131
+ return null;
132
+ }
133
+
110
134
  // ---- Billing ----
111
135
 
112
136
  // Webhook endpoint that all billing providers point at. The unified handler
@@ -114,32 +138,61 @@ async function promptSecret(message, currentValue) {
114
138
  // active provider; Stripe also keeps /api/webhooks/stripe for back-compat.
115
139
  const BILLING_WEBHOOK_PATH = '/api/webhooks/billing';
116
140
 
141
+ // `parked: true` disables a billing provider for this release without removing
142
+ // its setup flow below or the server-side adapter — Stripe is the only fully
143
+ // tested path for now. Paddle/Polar are hidden from the provider prompt and the
144
+ // app won't be configured to use them; flip `parked` off to re-enable once
145
+ // they're validated. Mirrors the parked-feature pattern in src/add.js.
146
+ const BILLING_PROVIDERS = [
147
+ { value: 'stripe', label: 'Stripe', hint: 'most popular' },
148
+ { value: 'paddle', label: 'Paddle', hint: 'merchant of record', parked: true },
149
+ { value: 'polar', label: 'Polar', hint: 'open-source focused', parked: true },
150
+ ];
151
+
117
152
  async function promptBilling(env) {
118
153
  p.log.info(c.bold('Billing Configuration'));
119
154
  p.log.info(c.dim('Choose your payment provider and enter API credentials.'));
120
155
 
121
- const currentProvider = env.BILLING_PROVIDER || 'stripe';
122
- const provider = await p.select({
123
- message: 'Billing provider',
124
- options: [
125
- { value: 'stripe', label: 'Stripe', hint: 'most popular' },
126
- { value: 'paddle', label: 'Paddle', hint: 'merchant of record' },
127
- { value: 'polar', label: 'Polar', hint: 'open-source focused' },
128
- ],
129
- initialValue: currentProvider,
130
- });
131
- if (p.isCancel(provider)) return null;
156
+ const availableProviders = BILLING_PROVIDERS.filter((bp) => !bp.parked);
157
+
158
+ let provider;
159
+ if (availableProviders.length === 1) {
160
+ // Only one provider enabled this release no point prompting; use it.
161
+ provider = availableProviders[0].value;
162
+ p.log.info(`Billing provider: ${c.bold(availableProviders[0].label)}`);
163
+ } else {
164
+ const current = availableProviders.some((bp) => bp.value === env.BILLING_PROVIDER)
165
+ ? env.BILLING_PROVIDER
166
+ : availableProviders[0].value;
167
+ const selected = await p.select({
168
+ message: 'Billing provider',
169
+ options: availableProviders.map((bp) => ({
170
+ value: bp.value,
171
+ label: bp.label,
172
+ hint: bp.hint,
173
+ })),
174
+ initialValue: current,
175
+ });
176
+ if (p.isCancel(selected)) return null;
177
+ provider = selected;
178
+ }
132
179
 
133
180
  const vars = { BILLING_PROVIDER: provider };
134
181
 
135
182
  if (provider === 'stripe') {
136
183
  p.note(
137
184
  [
138
- `${c.bold('1. Secret key')}`,
185
+ `${c.bold('1. Create your products in Stripe FIRST')}`,
186
+ ` ${c.info('https://dashboard.stripe.com/products')}`,
187
+ ' Add a product for each plan you want to sell and give it a price —',
188
+ ` ${c.dim('recurring')} for subscriptions or ${c.dim('one-time')} for single purchases.`,
189
+ ` Name them anything you like; we'll sync them in and let you pick which to activate.`,
190
+ '',
191
+ `${c.bold('2. Secret key')}`,
139
192
  ` ${c.info('https://dashboard.stripe.com/apikeys')}`,
140
193
  ` Use ${c.dim('sk_test_...')} for testing or ${c.dim('sk_live_...')} in production.`,
141
194
  '',
142
- `${c.bold('2. Webhook')} (Workbench → Webhooks → Create an event destination)`,
195
+ `${c.bold('3. Webhook')} (Workbench → Webhooks → Create an event destination)`,
143
196
  ` ${c.info('https://dashboard.stripe.com/webhooks')}`,
144
197
  ` • Endpoint URL: ${c.dim(`https://<your-domain>${BILLING_WEBHOOK_PATH}`)}`,
145
198
  ' • Events to send:',
@@ -149,11 +202,6 @@ async function promptBilling(env) {
149
202
  ' customer.subscription.deleted',
150
203
  ' invoice.payment_failed',
151
204
  ` • After creating, click ${c.dim('Reveal')} to copy the ${c.dim('whsec_...')} signing secret.`,
152
- '',
153
- `${c.bold('3. Price IDs')}`,
154
- ` ${c.info('https://dashboard.stripe.com/products')}`,
155
- ' Create a Startup and Pro product, then copy each recurring',
156
- ` price's ${c.dim('price_...')} ID.`,
157
205
  ].join('\n'),
158
206
  'Stripe setup',
159
207
  );
@@ -169,19 +217,62 @@ async function promptBilling(env) {
169
217
  if (webhookSecret === null) return null;
170
218
  vars.STRIPE_WEBHOOK_SECRET = webhookSecret;
171
219
 
172
- const priceStarter = await promptText(
173
- 'Stripe price ID for Startup plan (price_...)',
174
- env.STRIPE_PRICE_STARTER,
175
- );
176
- if (priceStarter === null) return null;
177
- vars.STRIPE_PRICE_STARTER = priceStarter;
220
+ // Pull live prices so the user activates real products by name instead of
221
+ // pasting opaque price IDs. On fetch failure (or zero prices) we warn and
222
+ // skip catalog writing — there is no free-text fallback now that the app
223
+ // renders whatever tiers we snapshot.
224
+ let prices = null;
225
+ const s = p.spinner();
226
+ try {
227
+ s.start('Fetching your Stripe products and prices');
228
+ prices = await listStripePrices(secretKey);
229
+ s.stop(`Found ${prices.length} active price${prices.length === 1 ? '' : 's'} in Stripe.`);
230
+ } catch (err) {
231
+ s.stop('Could not fetch Stripe prices.');
232
+ p.log.warn(`${err.message}. No products activated — fix the key and re-run to set pricing.`);
233
+ prices = null;
234
+ }
178
235
 
179
- const pricePro = await promptText(
180
- 'Stripe price ID for Pro plan (price_...)',
181
- env.STRIPE_PRICE_PRO,
182
- );
183
- if (pricePro === null) return null;
184
- vars.STRIPE_PRICE_PRO = pricePro;
236
+ if (prices && prices.length > 0) {
237
+ // Single multiselect: pick which products to activate. `prices` is already
238
+ // amount-sorted, so options (and the resulting snapshot) read cheapest-first.
239
+ const selected = await p.multiselect({
240
+ message: 'Select the products to activate',
241
+ options: prices.map((pr) => ({
242
+ value: pr.priceId,
243
+ label: `${pr.name} — ${formatAmount(pr.amount, pr.currency)}${pr.type === 'one_time' ? ' (one-time)' : `/${pr.interval}`}`,
244
+ })),
245
+ initialValues: prices.map((pr) => pr.priceId), // pre-check all; user deselects
246
+ required: false,
247
+ });
248
+ if (p.isCancel(selected)) return null;
249
+
250
+ // Filter preserves the amount-sorted order; these objects already match
251
+ // the CatalogTier shape the snapshot expects.
252
+ const activeTiers = prices.filter((pr) => selected.includes(pr.priceId));
253
+
254
+ // Side effect (like promptCicd writing vibecarbon.json): snapshot the
255
+ // activated products into src/shared/billing-catalog.ts so the app renders
256
+ // accurate pricing without hitting Stripe at runtime. Only write when at
257
+ // least one product is activated; otherwise leave the shipped default.
258
+ if (activeTiers.length > 0) {
259
+ const written = writeBillingCatalog(process.cwd(), {
260
+ provider: 'stripe',
261
+ generatedAt: new Date().toISOString(),
262
+ tiers: activeTiers,
263
+ });
264
+ if (written) {
265
+ p.log.success(
266
+ `Activated ${activeTiers.length} product${activeTiers.length === 1 ? '' : 's'} — wrote pricing snapshot to src/shared/billing-catalog.ts`,
267
+ );
268
+ }
269
+ } else {
270
+ p.log.warn('No products selected — left the existing pricing snapshot unchanged.');
271
+ }
272
+ } else if (prices) {
273
+ // Fetch succeeded but the account has no active prices.
274
+ p.log.warn('No active prices found in Stripe — create products first, then re-run.');
275
+ }
185
276
  } else if (provider === 'paddle') {
186
277
  p.note(
187
278
  [
@@ -381,64 +472,127 @@ async function promptMicrosoftOAuth(env) {
381
472
  };
382
473
  }
383
474
 
475
+ // ---- OAuth (provider picker) ----
476
+
477
+ // Single "OAuth" feature that asks which provider to set up, then delegates to
478
+ // the provider-specific flow. Each provider writes its own env vars, so they
479
+ // coexist — configuring one never clears the other.
480
+ async function promptOAuth(env, ctx) {
481
+ const googleDone = Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
482
+ const microsoftDone = Boolean(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET);
483
+
484
+ const provider = await p.select({
485
+ message: 'Which OAuth provider?',
486
+ options: [
487
+ { value: 'google', label: googleDone ? `Google ${c.success('✓ configured')}` : 'Google' },
488
+ {
489
+ value: 'microsoft',
490
+ label: microsoftDone ? `Microsoft ${c.success('✓ configured')}` : 'Microsoft',
491
+ },
492
+ ],
493
+ });
494
+ if (p.isCancel(provider)) return null;
495
+
496
+ return provider === 'google' ? promptGoogleOAuth(env, ctx) : promptMicrosoftOAuth(env, ctx);
497
+ }
498
+
384
499
  // ---- SMTP / Email ----
385
500
 
386
- async function promptSmtp(env) {
501
+ async function promptSmtp(env, ctx = {}) {
387
502
  p.log.info(c.bold('SMTP / Email'));
388
503
  p.log.info(c.dim('Used for password resets, verification, and transactional emails.'));
389
504
 
390
- p.note(
391
- [
392
- 'Pick a provider, create an SMTP credential, then paste the values below.',
393
- '',
394
- `${c.bold('Resend')}`,
395
- ` ${c.info('https://resend.com/api-keys')}`,
396
- ` host ${c.dim('smtp.resend.com')} • port ${c.dim('587')} • user ${c.dim('resend')} • pass = API key`,
397
- '',
398
- `${c.bold('Mailgun')}`,
399
- ` ${c.info('https://app.mailgun.com/app/sending/domains')}`,
400
- ` host ${c.dim('smtp.mailgun.org')} • port ${c.dim('587')}`,
401
- '',
402
- `${c.bold('AWS SES')}`,
403
- ` ${c.info('https://console.aws.amazon.com/ses/home#/smtp-settings')}`,
404
- ` region-specific host • port ${c.dim('587')}`,
405
- '',
406
- `${c.bold('Postmark')}`,
407
- ` ${c.info('https://account.postmarkapp.com/servers')}`,
408
- ` host ${c.dim('smtp.postmarkapp.com')} • port ${c.dim('587')}`,
409
- '',
410
- `${c.bold('Verify your sender domain')} in the provider before going live,`,
411
- 'or production emails will land in spam (or get rejected outright).',
412
- ].join('\n'),
413
- 'SMTP setup',
414
- );
415
-
416
- const host = await promptText('SMTP host', env.SMTP_HOST, {
417
- placeholder: 'smtp.resend.com',
418
- validate: requireNonEmpty('SMTP host'),
505
+ const currentIsResend = !env.SMTP_HOST || env.SMTP_HOST === 'smtp.resend.com';
506
+ const provider = await p.select({
507
+ message: 'Email provider',
508
+ options: [
509
+ { value: 'resend', label: 'Resend', hint: 'recommended — simplest setup' },
510
+ { value: 'smtp', label: 'Other SMTP server', hint: 'Mailgun, SES, Postmark, any SMTP' },
511
+ ],
512
+ initialValue: currentIsResend ? 'resend' : 'smtp',
419
513
  });
420
- if (host === null) return null;
514
+ if (p.isCancel(provider)) return null;
421
515
 
422
- const port = await promptText('SMTP port', env.SMTP_PORT || '587', {
423
- placeholder: '587',
424
- });
425
- if (port === null) return null;
516
+ let host;
517
+ let port;
518
+ let user;
519
+ let pass;
426
520
 
427
- const user = await promptText('SMTP username', env.SMTP_USER, {
428
- validate: requireNonEmpty('SMTP username'),
429
- });
430
- if (user === null) return null;
521
+ if (provider === 'resend') {
522
+ p.note(
523
+ [
524
+ `${c.bold('1. Add your sending domain')} ${c.info('https://resend.com/domains')}`,
525
+ ` Use your ${c.bold('root domain')} ${c.dim('(e.g. yourdomain.com)')} — not a subdomain`,
526
+ ` ${c.dim('like mail.yourdomain.com')}. Add the DNS records it shows you,`,
527
+ ` then wait for the status to read ${c.dim('"Verified"')}.`,
528
+ '',
529
+ `${c.bold('2. Create an API key')} ${c.info('https://resend.com/api-keys')}`,
530
+ ` ${c.dim('"Sending access"')} permission is all you need ${c.dim('(not "Full access")')}.`,
531
+ '',
532
+ '3. Paste the API key below.',
533
+ ].join('\n'),
534
+ 'Resend setup',
535
+ );
536
+
537
+ host = 'smtp.resend.com';
538
+ port = '587';
539
+ user = 'resend';
431
540
 
432
- const pass = await promptSecret('SMTP password', env.SMTP_PASS);
433
- if (pass === null) return null;
541
+ pass = await promptSecret('Resend API key', env.SMTP_PASS);
542
+ if (pass === null) return null;
543
+ } else {
544
+ p.note(
545
+ [
546
+ 'Works with any SMTP provider (Mailgun, AWS SES, Postmark, …).',
547
+ "Grab the host, port, username, and password from your provider's",
548
+ 'SMTP settings. Verify your sender domain there first, or production',
549
+ 'email will land in spam.',
550
+ ].join('\n'),
551
+ 'SMTP setup',
552
+ );
553
+
554
+ host = await promptText('SMTP host', env.SMTP_HOST, {
555
+ validate: requireNonEmpty('SMTP host'),
556
+ });
557
+ if (host === null) return null;
558
+
559
+ port = await promptText('SMTP port', env.SMTP_PORT, {
560
+ defaultValue: '587',
561
+ });
562
+ if (port === null) return null;
563
+
564
+ user = await promptText('SMTP username', env.SMTP_USER, {
565
+ validate: requireNonEmpty('SMTP username'),
566
+ });
567
+ if (user === null) return null;
568
+
569
+ pass = await promptSecret('SMTP password', env.SMTP_PASS);
570
+ if (pass === null) return null;
571
+ }
572
+
573
+ // Suggest a sender address on the project's own domain when we know it, so
574
+ // Enter accepts e.g. support@vibecarbon.com instead of a generic placeholder.
575
+ const domain = ctx.domain || 'yourdomain.com';
576
+ const emailSuggestion = ctx.domain ? `support@${ctx.domain}` : undefined;
577
+
578
+ p.note(
579
+ [
580
+ 'Use a real inbox you monitor as the "from" address',
581
+ `${c.dim(`(e.g. hi@${domain} or support@${domain})`)} so replies —`,
582
+ 'including to password-reset and verification emails — reach you.',
583
+ `${c.bold('Avoid noreply@')}.`,
584
+ ].join('\n'),
585
+ 'Sender identity',
586
+ );
434
587
 
435
588
  const adminEmail = await promptText('Sender email address (from)', env.SMTP_ADMIN_EMAIL, {
436
- placeholder: 'noreply@yourdomain.com',
437
- validate: requireNonEmpty('Sender email'),
589
+ defaultValue: emailSuggestion,
590
+ placeholder: `support@${domain}`,
591
+ validate: validateAdminEmail,
438
592
  });
439
593
  if (adminEmail === null) return null;
440
594
 
441
- const senderDefault = env.PROJECT_NAME || 'My App';
595
+ const senderDefault = env.PROJECT_NAME || ctx.projectConfig?.projectName || 'My App';
442
596
  const senderName = await promptText('Sender display name', env.SMTP_SENDER_NAME, {
443
597
  placeholder: senderDefault,
444
598
  defaultValue: senderDefault,
@@ -672,42 +826,108 @@ async function promptCicd() {
672
826
  // FEATURE DEFINITIONS
673
827
  // ============================================================================
674
828
 
829
+ // Env vars that hold secrets — partially masked when echoing existing config.
830
+ // Derived from the config-registry so a new feature secret is masked
831
+ // automatically (no second list to keep in sync).
832
+ const SECRET_ENV_KEYS = new Set(featureSecretKeys());
833
+
834
+ // Show enough of a secret to recognize it, never the whole thing.
835
+ function maskEnvValue(key, value) {
836
+ if (!SECRET_ENV_KEYS.has(key)) return value;
837
+ if (value.length <= 4) return '••••';
838
+ return `${value.slice(0, 4)}${'•'.repeat(8)}`;
839
+ }
840
+
841
+ // Render the currently-set env vars for a feature as `KEY = value` lines,
842
+ // skipping anything unset. Detection is env-based so it works whether the user
843
+ // ran `vibecarbon configure` or edited .env.local by hand.
844
+ function envSummaryLines(env, keys) {
845
+ const lines = [];
846
+ for (const key of keys) {
847
+ const value = env[key];
848
+ if (value === undefined || value === '') continue;
849
+ lines.push(`${c.dim(key)} = ${maskEnvValue(key, value)}`);
850
+ }
851
+ return lines;
852
+ }
853
+
854
+ // Billing is provider-tagged; infer the active provider from BILLING_PROVIDER
855
+ // or, for hand-edited envs, from whichever provider secret is present.
856
+ const BILLING_KEYS = {
857
+ stripe: ['BILLING_PROVIDER', 'STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'],
858
+ paddle: [
859
+ 'BILLING_PROVIDER',
860
+ 'PADDLE_API_KEY',
861
+ 'PADDLE_WEBHOOK_SECRET',
862
+ 'PADDLE_ENVIRONMENT',
863
+ 'PADDLE_PRICE_STARTER',
864
+ 'PADDLE_PRICE_PRO',
865
+ ],
866
+ polar: [
867
+ 'BILLING_PROVIDER',
868
+ 'POLAR_ACCESS_TOKEN',
869
+ 'POLAR_WEBHOOK_SECRET',
870
+ 'POLAR_ORGANIZATION_ID',
871
+ 'POLAR_PRICE_STARTER',
872
+ 'POLAR_PRICE_PRO',
873
+ ],
874
+ };
875
+
876
+ function billingProvider(env) {
877
+ if (env.BILLING_PROVIDER) return env.BILLING_PROVIDER;
878
+ if (env.STRIPE_SECRET_KEY) return 'stripe';
879
+ if (env.PADDLE_API_KEY) return 'paddle';
880
+ if (env.POLAR_ACCESS_TOKEN) return 'polar';
881
+ return null;
882
+ }
883
+
675
884
  const FEATURES = [
676
885
  {
677
886
  value: 'cicd',
678
887
  label: 'CI/CD',
679
- hint: 'GitHub Actions build + deploy workflows (optional; enables git-push deploys)',
888
+ hint: 'Auto build & deploy with GitHub Actions',
680
889
  promptFn: promptCicd,
890
+ isConfigured: (_env, ctx) => ctx?.projectConfig?.cicdEnabled === true,
891
+ summary: () => [
892
+ `${c.dim('cicdEnabled')} = true ${c.dim('(GitHub Actions workflows installed)')}`,
893
+ ],
681
894
  },
682
895
  {
683
896
  value: 'billing',
684
- label: 'Billing',
685
- hint: 'Stripe, Paddle, or Polar',
897
+ label: 'Payments',
898
+ hint: 'Stripe',
686
899
  promptFn: promptBilling,
900
+ isConfigured: (env) => Boolean(billingProvider(env)),
901
+ summary: (env) =>
902
+ envSummaryLines(env, BILLING_KEYS[billingProvider(env)] || ['BILLING_PROVIDER']),
687
903
  },
688
904
  {
689
- value: 'google-oauth',
690
- label: 'Google OAuth',
691
- hint: 'Sign in with Google',
692
- promptFn: promptGoogleOAuth,
693
- },
694
- {
695
- value: 'microsoft-oauth',
696
- label: 'Microsoft OAuth',
697
- hint: 'Sign in with Microsoft',
698
- promptFn: promptMicrosoftOAuth,
905
+ value: 'oauth',
906
+ label: 'OAuth',
907
+ hint: 'Google or Microsoft',
908
+ promptFn: promptOAuth,
909
+ isConfigured: (env) =>
910
+ Boolean(
911
+ (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) ||
912
+ (env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET),
913
+ ),
914
+ summary: (env) => envSummaryLines(env, featureKeys('oauth')),
699
915
  },
700
916
  {
701
917
  value: 'smtp',
702
918
  label: 'SMTP / Email',
703
- hint: 'password resets, verification, transactional emails',
919
+ hint: 'Password resets, account verifications, notifications, etc',
704
920
  promptFn: promptSmtp,
921
+ isConfigured: (env) => Boolean(env.SMTP_HOST && env.SMTP_PASS),
922
+ summary: (env) => envSummaryLines(env, featureKeys('smtp')),
705
923
  },
706
924
  {
707
925
  value: 'plausible',
708
- label: 'Plausible Analytics',
709
- hint: 'privacy-friendly web analytics',
926
+ label: 'Analytics',
927
+ hint: 'Plausible',
710
928
  promptFn: promptPlausible,
929
+ isConfigured: (env) => Boolean(env.VITE_PLAUSIBLE_DOMAIN),
930
+ summary: (env) => envSummaryLines(env, featureKeys('analytics')),
711
931
  },
712
932
  ];
713
933
 
@@ -746,69 +966,77 @@ async function main(cliArgs) {
746
966
  // Load current env values
747
967
  const env = loadEnvVariables(cwd);
748
968
 
749
- // Feature selection opt-in: nothing selected by default. Most users only
750
- // want to configure one or two features per run; default-on forced them to
751
- // uncheck the rest, which was tedious and hid the intent of the prompt.
752
- const selected = await p.multiselect({
753
- message: 'Which features would you like to configure?',
969
+ // Project metadata (name, deploy domain) powers project-aware prompt
970
+ // suggestions e.g. the SMTP sender address. Non-fatal if it can't load.
971
+ let projectConfig = null;
972
+ try {
973
+ projectConfig = loadProjectConfig(cwd);
974
+ } catch {
975
+ // Best-effort — prompts fall back to generic placeholders without it.
976
+ }
977
+ const ctx = { projectConfig, domain: projectDomain(projectConfig) };
978
+
979
+ // Configure one feature per run. Multi-select made failures ambiguous — if a
980
+ // later feature errored, it was unclear which earlier ones had been written.
981
+ // Re-running the command is simple and keeps each session's outcome clear.
982
+ const featureValue = await p.select({
983
+ message: 'Which feature would you like to configure?',
754
984
  options: FEATURES.map((f) => ({
755
985
  value: f.value,
756
- label: f.label,
986
+ label: f.isConfigured(env, ctx) ? `${f.label} ${c.success('✓ configured')}` : f.label,
757
987
  hint: f.hint,
758
988
  })),
759
- required: false,
760
- initialValues: [],
761
989
  });
762
990
 
763
- if (p.isCancel(selected)) {
991
+ if (p.isCancel(featureValue)) {
764
992
  p.cancel('Operation cancelled.');
765
993
  process.exit(0);
766
994
  }
767
995
 
768
- if (selected.length === 0) {
769
- p.log.warn('No features selected.');
770
- p.outro('Nothing to configure.');
771
- return;
772
- }
773
-
774
- // Collect credentials for each selected feature
775
- const allVars = {};
776
- const configuredFeatures = [];
996
+ const feature = FEATURES.find((f) => f.value === featureValue);
777
997
 
778
- for (const featureValue of selected) {
779
- const feature = FEATURES.find((f) => f.value === featureValue);
780
- const result = await feature.promptFn(env);
998
+ // Already configured? Show what's set (secrets masked) and require an explicit
999
+ // overwrite confirmation before re-running the feature's prompts.
1000
+ if (feature.isConfigured(env, ctx)) {
1001
+ const lines = feature.summary(env, ctx);
1002
+ p.note(
1003
+ lines.length ? lines.join('\n') : c.dim('(configured)'),
1004
+ `${feature.label} — current settings`,
1005
+ );
781
1006
 
782
- if (result === null) {
783
- p.log.warn(`Skipped ${feature.label} (cancelled)`);
784
- continue;
1007
+ const overwrite = await p.confirm({
1008
+ message: `${feature.label} is already configured. Re-configure and overwrite these settings?`,
1009
+ initialValue: false,
1010
+ });
1011
+ if (p.isCancel(overwrite)) {
1012
+ p.cancel('Operation cancelled.');
1013
+ process.exit(0);
1014
+ }
1015
+ if (!overwrite) {
1016
+ p.outro(`Left ${c.bold(feature.label)} unchanged.`);
1017
+ return;
785
1018
  }
786
-
787
- Object.assign(allVars, result);
788
- configuredFeatures.push(feature.label);
789
1019
  }
790
1020
 
791
- if (configuredFeatures.length === 0) {
792
- p.outro('No features configured.');
1021
+ const result = await feature.promptFn(env, ctx);
1022
+
1023
+ if (result === null) {
1024
+ p.outro(`Skipped ${feature.label} (cancelled).`);
793
1025
  return;
794
1026
  }
795
1027
 
796
- // Show summary
797
- const summaryLines = configuredFeatures.map((f) => ` ${c.success('+')} ${f}`).join('\n');
798
- p.note(summaryLines, 'Features to configure');
799
-
800
- // Write all values
1028
+ // Write values
801
1029
  const s = p.spinner();
802
1030
  s.start('Writing configuration');
803
1031
 
804
- for (const [key, value] of Object.entries(allVars)) {
1032
+ for (const [key, value] of Object.entries(result)) {
805
1033
  setEnvVar(key, value, cwd);
806
1034
  }
807
1035
 
808
1036
  s.stop('Configuration saved to .env.local and .env');
809
1037
 
810
1038
  p.outro(
811
- `Configured ${configuredFeatures.length} feature${configuredFeatures.length === 1 ? '' : 's'}. Run ${c.info('vibecarbon deploy')} to push to production.`,
1039
+ `Configured ${c.bold(feature.label)}. Run ${c.info('vibecarbon configure')} again for another feature, or ${c.info('vibecarbon deploy')} to push to production.`,
812
1040
  );
813
1041
  }
814
1042