diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index 777d83b1b..db2f690c7 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -1,10 +1,17 @@ import type { PlopTypes } from '@turbo/gen'; +import { createEnvironmentVariablesGenerator } from './templates/env/generator'; import { createKeystaticAdminGenerator } from './templates/keystatic/generator'; import { createPackageGenerator } from './templates/package/generator'; +import { createEnvironmentVariablesValidatorGenerator } from './templates/validate-env/generator'; // List of generators to be registered -const generators = [createPackageGenerator, createKeystaticAdminGenerator]; +const generators = [ + createPackageGenerator, + createKeystaticAdminGenerator, + createEnvironmentVariablesGenerator, + createEnvironmentVariablesValidatorGenerator, +]; export default function generator(plop: PlopTypes.NodePlopAPI): void { generators.forEach((gen) => gen(plop)); diff --git a/turbo/generators/templates/env/generator.ts b/turbo/generators/templates/env/generator.ts new file mode 100644 index 000000000..1cdb12331 --- /dev/null +++ b/turbo/generators/templates/env/generator.ts @@ -0,0 +1,281 @@ +import type { PlopTypes } from '@turbo/gen'; +import { execSync } from 'node:child_process'; + +export function createEnvironmentVariablesGenerator( + plop: PlopTypes.NodePlopAPI, +) { + return plop.setGenerator('env', { + description: 'Generate the environment variables to be used in the app', + actions: [ + async (answers) => { + let env = ''; + + for (const [key, value] of Object.entries( + ( + answers as { + values: Record; + } + ).values, + )) { + env += `${key}=${value}\n`; + } + + // write .env.local here with values + execSync( + `echo "${env}" > turbo/generators/templates/env/out/.env.local`, + ); + + return 'Environment variables generated at turbo/generators/templates/env/out/.env.local. Please double check and use this file in your hosting provider to set the environment variables. Never commit this file, it contains secrets!'; + }, + ], + prompts: [ + { + type: 'input', + name: 'values.NEXT_PUBLIC_SITE_URL', + message: + 'What is the site URL of you website? (Ex. https://makerkit.dev)', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_PRODUCT_NAME', + message: 'What is the name of your product? (Ex. MakerKit)', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_SITE_TITLE', + message: + 'What is the title of your website? (Ex. MakerKit - The best way to make things)', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_SITE_DESCRIPTION', + message: + 'What is the description of your website? (Ex. MakerKit is the best way to make things and stuff)', + }, + { + type: 'list', + name: 'values.NEXT_PUBLIC_DEFAULT_THEME_MODE', + message: + 'What is the default theme mode of your website? (leave empty for light)', + choices: ['light', 'dark', 'system'], + default: 'light', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_DEFAULT_LOCALE', + message: + 'What is the default locale of your website? (leave empty for en)', + default: 'en', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_AUTH_PASSWORD', + message: + 'Do you want to use email/password authentication? (leave empty for true)', + default: 'true', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_AUTH_MAGIC_LINK', + message: + 'Do you want to use magic link authentication? (leave empty for false)', + default: 'false', + }, + { + type: 'input', + name: 'values.CONTACT_EMAIL', + message: 'What is the contact email you want to receive emails to?', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_ENABLE_THEME_TOGGLE', + message: + 'Do you want to enable the theme toggle? (leave empty for true)', + default: 'true', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION', + message: + 'Do you want to enable personal account deletion? (leave empty for true)', + default: 'true', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING', + message: + 'Do you want to enable personal account billing? (leave empty for true)', + default: 'true', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS', + message: 'Do you want to enable team accounts? (leave empty for true)', + default: 'true', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNT_DELETION', + message: + 'Do you want to enable team account deletion? (leave empty for true)', + default: 'true', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING', + message: + 'Do you want to enable team account billing? (leave empty for true)', + default: 'true', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION', + message: + 'Do you want to enable team account creation? (leave empty for true)', + default: 'true', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_REALTIME_NOTIFICATIONS', + message: + 'Do you want to enable realtime notifications? (leave empty for false)', + default: 'false', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_ENABLE_NOTIFICATIONS', + message: + 'Do you want to enable email notifications? (leave empty for true)', + default: 'true', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_SUPABASE_URL', + message: 'What is the Supabase URL? (Ex. https://yourapp.supabase.co)', + }, + { + type: 'input', + name: 'values.NEXT_PUBLIC_SUPABASE_ANON_KEY', + message: 'What is the Supabase anon key?', + }, + { + type: 'input', + name: 'values.SUPABASE_SERVICE_ROLE_KEY', + message: 'What is the Supabase Service Role Key?', + }, + { + type: 'list', + name: 'values.NEXT_PUBLIC_BILLING_PROVIDER', + message: + 'What is the billing provider you want to use? (leave empty for stripe)', + choices: ['stripe', 'lemon-squeezy'], + default: 'stripe', + }, + { + when: (answers) => + answers.values.NEXT_PUBLIC_BILLING_PROVIDER === 'stripe', + type: 'input', + name: 'values.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', + message: 'What is the Stripe publishable key?', + }, + { + when: (answers) => + answers.values.NEXT_PUBLIC_BILLING_PROVIDER === 'stripe', + type: 'input', + name: 'values.STRIPE_SECRET_KEY', + message: 'What is the Stripe secret key?', + }, + { + when: (answers) => + answers.values.NEXT_PUBLIC_BILLING_PROVIDER === 'stripe', + type: 'input', + name: 'values.STRIPE_WEBHOOK_SECRET', + message: 'What is the Stripe webhook secret?', + }, + { + when: (answers) => + answers.values.NEXT_PUBLIC_BILLING_PROVIDER === 'lemon-squeezy', + type: 'input', + name: 'values.LEMON_SQUEEZY_SECRET_KEY', + message: 'What is the Lemon Squeezy secret key?', + }, + { + when: (answers) => + answers.values.NEXT_PUBLIC_BILLING_PROVIDER === 'lemon-squeezy', + type: 'input', + name: 'values.LEMON_SQUEEZY_STORE_ID', + message: 'What is the Lemon Squeezy store ID?', + }, + { + when: (answers) => + answers.values.NEXT_PUBLIC_BILLING_PROVIDER === 'lemon-squeezy', + type: 'input', + name: 'values.LEMON_SQUEEZY_SIGNING_SECRET', + message: 'What is the Lemon Squeezy signing secret?', + }, + { + type: 'input', + name: 'values.SUPABASE_DB_WEBHOOK_SECRET', + message: 'What is the Supabase DB webhook secret?', + }, + { + type: 'list', + name: 'values.CMS_CLIENT', + message: + 'What is the CMS client you want to use? (leave empty for keystatic)', + choices: ['keystatic', 'wordpress'], + default: 'keystatic', + }, + { + type: 'list', + name: 'values.MAILER_PROVIDER', + message: 'What is the mailer provider you want to use?', + choices: ['nodemailer', 'resend'], + default: 'nodemailer', + }, + { + when: (answers) => answers.values.MAILER_PROVIDER === 'resend', + type: 'input', + name: 'values.RESEND_API_KEY', + message: 'What is the Resend API key?', + }, + { + when: (answers) => answers.values.MAILER_PROVIDER === 'nodemailer', + type: 'input', + name: 'values.EMAIL_SENDER', + message: 'What is the email sender? (ex. info@makerkit.dev)', + }, + { + when: (answers) => answers.values.MAILER_PROVIDER === 'nodemailer', + type: 'input', + name: 'values.EMAIL_HOST', + message: 'What is the email host?', + }, + { + when: (answers) => answers.values.MAILER_PROVIDER === 'nodemailer', + type: 'input', + name: 'values.EMAIL_PORT', + message: 'What is the email port?', + }, + { + when: (answers) => answers.values.MAILER_PROVIDER === 'nodemailer', + type: 'input', + name: 'values.EMAIL_USER', + message: 'What is the email username? (check your email provider)', + }, + { + when: (answers) => answers.values.MAILER_PROVIDER === 'nodemailer', + type: 'input', + name: 'values.EMAIL_PASSWORD', + message: 'What is the email password? (check your email provider)', + }, + { + when: (answers) => answers.values.MAILER_PROVIDER === 'nodemailer', + type: 'input', + name: 'values.EMAIL_TLS', + message: 'Do you want to enable TLS? (leave empty for true)', + default: 'true', + }, + ], + }); +} diff --git a/turbo/generators/templates/validate-env/generator.ts b/turbo/generators/templates/validate-env/generator.ts new file mode 100644 index 000000000..d7b67bf9a --- /dev/null +++ b/turbo/generators/templates/validate-env/generator.ts @@ -0,0 +1,207 @@ +import type { PlopTypes } from '@turbo/gen'; +import { readFileSync } from 'node:fs'; + +// quick hack to avoid installing zod globally +import { z } from '../../../../apps/web/node_modules/zod'; + +const BooleanStringEnum = z.enum(['true', 'false']); + +const Schema: Record = { + NEXT_PUBLIC_SITE_URL: z + .string({ + description: `This is the URL of your website. It should start with https:// like https://makerkit.dev.`, + }) + .url({ + message: + 'NEXT_PUBLIC_SITE_URL must be a valid URL. Please use HTTPS for production sites, otherwise it will fail.', + }) + .refine( + (url) => { + return url.startsWith('https://'); + }, + { + message: 'NEXT_PUBLIC_SITE_URL must start with https://', + path: ['NEXT_PUBLIC_SITE_URL'], + }, + ), + NEXT_PUBLIC_PRODUCT_NAME: z + .string({ + message: 'Product name must be a string', + description: `This is the name of your product. It should be a short name like MakerKit.`, + }) + .min(1), + NEXT_PUBLIC_SITE_DESCRIPTION: z.string({ + message: 'Site description must be a string', + description: `This is the description of your website. It should be a short sentence or two.`, + }), + NEXT_PUBLIC_DEFAULT_THEME_MODE: z.enum(['light', 'dark', 'system'], { + message: 'Default theme mode must be light, dark or system', + description: `This is the default theme mode for your website. It should be light, dark or system.`, + }), + NEXT_PUBLIC_DEFAULT_LOCALE: z.string({ + message: 'Default locale must be a string', + description: `This is the default locale for your website. It should be a two-letter code like en or fr.`, + }), + CONTACT_EMAIL: z + .string({ + message: 'Contact email must be a valid email', + description: `This is the email address that will receive contact form submissions.`, + }) + .email(), + NEXT_PUBLIC_ENABLE_THEME_TOGGLE: BooleanStringEnum, + NEXT_PUBLIC_AUTH_PASSWORD: BooleanStringEnum, + NEXT_PUBLIC_AUTH_MAGIC_LINK: BooleanStringEnum, + NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION: BooleanStringEnum, + NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING: BooleanStringEnum, + NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS: BooleanStringEnum, + NEXT_PUBLIC_ENABLE_TEAM_ACCOUNT_DELETION: BooleanStringEnum, + NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING: BooleanStringEnum, + NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION: BooleanStringEnum, + NEXT_PUBLIC_REALTIME_NOTIFICATIONS: BooleanStringEnum, + NEXT_PUBLIC_ENABLE_NOTIFICATIONS: BooleanStringEnum, + NEXT_PUBLIC_SUPABASE_URL: z + .string({ + description: `This is the URL to your hosted Supabase instance.`, + }) + .url({ + message: 'Supabase URL must be a valid URL', + }), + NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string({ + message: 'Supabase anon key must be a string', + description: `This is the key provided by Supabase. It is a public key used client-side.`, + }), + SUPABASE_SERVICE_ROLE_KEY: z.string({ + message: 'Supabase service role key must be a string', + description: `This is the key provided by Supabase. It is a private key used server-side.`, + }), + NEXT_PUBLIC_BILLING_PROVIDER: z.enum(['stripe', 'lemon-squeezy'], { + message: 'Billing provider must be stripe or lemon-squeezy', + description: `This is the billing provider you want to use. It should be stripe or lemon-squeezy.`, + }), + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z + .string({ + message: 'Stripe publishable key must be a string', + description: `This is the publishable key from your Stripe dashboard. It should start with pk_`, + }) + .refine( + (value) => { + return value.startsWith('pk_'); + }, + { + message: 'Stripe publishable key must start with pk_', + path: ['NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY'], + }, + ), + STRIPE_SECRET_KEY: z + .string({ + message: 'Stripe secret key must be a string', + description: `This is the secret key from your Stripe dashboard. It should start with sk_`, + }) + .refine( + (value) => { + return value.startsWith('sk_'); + }, + { + message: 'Stripe secret key must start with sk_', + path: ['STRIPE_SECRET_KEY'], + }, + ), + STRIPE_WEBHOOK_SECRET: z + .string({ + message: 'Stripe webhook secret must be a string', + description: `This is the signing secret you copy after creating a webhook in your Stripe dashboard.`, + }) + .min(1) + .refine( + (value) => { + return value.startsWith('whsec_'); + }, + { + message: 'Stripe webhook secret must start with whsec_', + path: ['STRIPE_WEBHOOK_SECRET'], + }, + ), + LEMON_SQUEEZY_SECRET_KEY: z + .string({ + message: 'Lemon Squeezy API key must be a string', + description: `This is the API key from your Lemon Squeezy account`, + }) + .min(1), + LEMON_SQUEEZY_STORE_ID: z + .string({ + message: 'Lemon Squeezy store ID must be a string', + description: `This is the store ID of your Lemon Squeezy account`, + }) + .min(1), + LEMON_SQUEEZY_SIGNING_SECRET: z + .string({ + message: 'Lemon Squeezy signing secret must be a string', + description: `This is a shared secret that you must set in your Lemon Squeezy account when you create an API Key`, + }) + .min(1), + MAILER_PROVIDER: z.enum(['nodemailer', 'resend'], { + message: 'Mailer provider must be nodemailer or resend', + description: `This is the mailer provider you want to use for sending emails. nodemailer is a generic SMTP mailer, resend is a service.`, + }), +}; + +export function createEnvironmentVariablesValidatorGenerator( + plop: PlopTypes.NodePlopAPI, +) { + return plop.setGenerator('validate-env', { + description: 'Validate the environment variables to be used in the app', + actions: [ + async (answers) => { + if (!('path' in answers) || !answers.path) { + throw new Error('URL is required'); + } + + const file = readFileSync(answers.path as string, 'utf-8'); + + if (!file) { + throw new Error('File is empty'); + } + + const vars = file.split('\n').filter((line) => line.trim() !== ''); + + for (const line of vars) { + const [key, value] = line.split('='); + + if (!key) { + throw new Error(`The line ${line} has no key`); + } + + if (!value) { + console.warn(`The value ${key} has no value`); + } + + const property = Schema[key]; + + if (property) { + // parse with Zod + const { error } = property.safeParse(value); + + if (error) { + throw new Error( + `Encountered a validation error for key ${key}: ${JSON.stringify(error, null, 2)}`, + ); + } else { + console.log(`Key ${key} is valid!`); + } + } + } + + return 'Environment variables are valid!'; + }, + ], + prompts: [ + { + type: 'input', + name: 'path', + message: + 'Where is the path to the environment variables file? Leave empty to use the generated turbo/generators/templates/env/out/.env.local', + default: 'turbo/generators/templates/env/out/.env.local', + }, + ], + }); +}