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.
- 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/biome.json +1 -1
- 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 +34 -36
- 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/components/ui/calendar.tsx +1 -1
- 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/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 +13 -13
- 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 +20 -10
- 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/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
|
|
45
|
-
'
|
|
46
|
-
'
|
|
47
|
-
'
|
|
48
|
-
'
|
|
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
|
|
77
|
-
const
|
|
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
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
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('
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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 (
|
|
514
|
+
if (p.isCancel(provider)) return null;
|
|
421
515
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
516
|
+
let host;
|
|
517
|
+
let port;
|
|
518
|
+
let user;
|
|
519
|
+
let pass;
|
|
426
520
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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: '
|
|
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: '
|
|
685
|
-
hint: 'Stripe
|
|
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: '
|
|
690
|
-
label: '
|
|
691
|
-
hint: '
|
|
692
|
-
promptFn:
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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: '
|
|
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: '
|
|
709
|
-
hint: '
|
|
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
|
-
//
|
|
750
|
-
//
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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(
|
|
991
|
+
if (p.isCancel(featureValue)) {
|
|
764
992
|
p.cancel('Operation cancelled.');
|
|
765
993
|
process.exit(0);
|
|
766
994
|
}
|
|
767
995
|
|
|
768
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
792
|
-
|
|
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
|
-
//
|
|
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(
|
|
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 ${
|
|
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
|
|