UNPKG

saas-smith

Version:

CLI to forge new SaaS projects effortlessly from boilerplates

751 lines (648 loc) • 22.7 kB
#!/usr/bin/env node import { Command } from "commander"; import chalk from "chalk"; import inquirer from "inquirer"; import degit from "degit"; import fs from "fs-extra"; import path from "path"; import { execSync } from "child_process"; import dotenv from "dotenv"; import { checkFirebaseCLI, authenticateFirebaseCLI, initializeFirebase, } from "./firebaseSetup.js"; import { setupMongoDB, getMongoDBConnectionString } from "./mongoDbSetup.js"; import { setupAWSS3 } from "./awsS3Setup.js"; const rootDrive = path.parse(process.cwd()).root; dotenv.config({ path: path.join(rootDrive, ".env") }); const program = new Command(); // Define available tech stacks and corresponding boilerplates const techStacks = { ".Net, Angular, Firebase": "github:Yewo-Devs/BoilerPlate-FirebaseAngular", ".Net, Angular, AWS S3, MongoDb": "github:Yewo-Devs/BoilerPlate-AngularMongoS3", }; // Helper: Prompt User for Project Details async function promptForDetails(existingName) { const questions = [ { type: "input", name: "projectName", message: "Enter your project name:", default: existingName, }, { type: "list", name: "techStack", message: "Choose your tech stack:", choices: Object.keys(techStacks), }, { type: "input", name: "companyAddress", message: "Enter your company address:", }, { type: "input", name: "saasOwnerEmail", message: "Enter your email:", }, { type: "input", name: "domain", message: "Enter your domain:", default: "example.com", }, { type: "input", name: "awsRegion", message: "Enter your preffered aws region:", default: "us-west-2", }, ]; const answers = await inquirer.prompt(questions); return answers; } // Helper: Prompt User for AWS Region async function promptForDeployDetails() { const questions = [ { type: "input", name: "projectName", message: "Enter your project name:", }, { type: "list", name: "techStack", message: "Choose your tech stack:", choices: Object.keys(techStacks), }, { type: "input", name: "domain", message: "Enter your domain:", default: "example.com", }, { type: "input", name: "awsRegion", message: "Enter your preffered aws region:", default: "us-west-2", }, { type: "input", name: "googleClientId", message: "Enter your googleClientId:", }, ]; const answers = await inquirer.prompt(questions); return answers; } // Helper: Update Launch Settings function updateLaunchSettings( projectName, techStack, bucketName, mongoConnectionString, awsRegion, companyAddress, saasOwnerEmail, domain ) { let directoryName = projectName; // Update launchSettings.json const launchSettingsPath = path.resolve( process.cwd(), `./${directoryName}/API/Properties/launchSettings.json` ); let launchSettings = fs.readFileSync(launchSettingsPath, "utf8"); let validName = projectName .toLowerCase() .replace(/[^a-z0-9-]/g, "-") // Replace invalid characters with hyphens .replace(/^-+|-+$/g, ""); // Remove leading or trailing hyphens //MongoDB Variables if (techStack.includes("MongoDb")) { launchSettings = launchSettings.replace( /"MongoDB_ConnectionString":\s*".*?"/, `"MongoDB_ConnectionString": "${mongoConnectionString}"` ); launchSettings = launchSettings.replace( /"MongoDB_DatabaseName":\s*".*?"/, `"MongoDB_DatabaseName": "${validName}"` ); } //AWS S3 Variables if (techStack.includes("AWS S3")) { launchSettings = launchSettings.replace( /"Aws_BucketName":\s*".*?"/, `"Aws_BucketName": "${bucketName}"` ); launchSettings = launchSettings.replace( /"Aws_AccessKeyId":\s*".*?"/, `"Aws_AccessKeyId": "${process.env["Saas_Smith_Aws_AccessKeyId"]}"` ); launchSettings = launchSettings.replace( /"Aws_SecretAccessKey":\s*".*?"/, `"Aws_SecretAccessKey": "${process.env["Saas_Smith_Aws_SecretAccessKey"]}"` ); launchSettings = launchSettings.replace( /"Aws_Region":\s*".*?"/, `"Aws_Region": "${awsRegion}"` ); } //Other Variables const generateRandomString = (length) => { const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let result = ""; for (let i = 0; i < length; i++) { result += characters.charAt( Math.floor(Math.random() * characters.length) ); } return result; }; const tokenKey = generateRandomString(256); launchSettings = launchSettings.replace( /"Token_Key":\s*".*?"/, `"Token_Key": "${tokenKey}"` ); launchSettings = launchSettings.replace( /"SaaS_Name":\s*".*?"/, `"SaaS_Name": "${projectName}"` ); launchSettings = launchSettings.replace( /"Company_Address":\s*".*?"/, `"Company_Address": "${companyAddress}"` ); launchSettings = launchSettings.replace( /"SaaS_Owner_Email":\s*".*?"/, `"SaaS_Owner_Email": "${saasOwnerEmail}"` ); launchSettings = launchSettings.replace( /"Email_Domain":\s*".*?"/, `"Email_Domain": "${domain}"` ); //Stripe Variables launchSettings = launchSettings.replace( /"Stripe_ApiKey":\s*".*?"/, `"Stripe_ApiKey": "${process.env["Saas_Smith_Stripe_ApiKey"]}"` ); //OpenAI Variables launchSettings = launchSettings.replace( /"OpenAi_ApiKey":\s*".*?"/, `"OpenAi_ApiKey": "${process.env["Saas_Smith_OpenAi_ApiKey"]}"` ); //Cloudinary Variables launchSettings = launchSettings.replace( /"Cloudinary_ApiKey":\s*".*?"/, `"Cloudinary_ApiKey": "${process.env["Saas_Smith_Cloudinary_ApiKey"]}"` ); launchSettings = launchSettings.replace( /"Cloudinary_ApiSecret":\s*".*?"/, `"Cloudinary_ApiSecret": "${process.env["Saas_Smith_Cloudinary_ApiSecret"]}"` ); launchSettings = launchSettings.replace( /"Cloudinary_CloudName":\s*".*?"/, `"Cloudinary_CloudName": "${process.env["Saas_Smith_Cloudinary_CloudName"]}"` ); //SendGrid Variables launchSettings = launchSettings.replace( /"SendGrid_ApiKey":\s*".*?"/, `"SendGrid_ApiKey": "${process.env["Saas_Smith_SendGrid_ApiKey"]}"` ); //Cloudflare Variables launchSettings = launchSettings.replace( /"Cloudflare_ApiToken":\s*".*?"/, `"Cloudflare_ApiToken": "${process.env["Saas_Smith_Cloudflare_ApiToken"]}"` ); fs.writeFileSync(launchSettingsPath, launchSettings); //Update .gitignore fs.appendFileSync( path.resolve(process.cwd(), `./${directoryName}/.gitignore`), "\n/API/Properties/launchSettings.json" ); } // Helper: Update Environment Files function updateEnvironmentFiles(projectName, domain, googleClientId) { let directoryName = ""; if (!process.cwd().includes(projectName)) { directoryName = path.join(process.cwd(), projectName); } else { directoryName = process.cwd(); } const environmentPath = path.resolve( directoryName, `./front-end/src/environments/environment.ts` ); // Update environment.ts let environment = fs.readFileSync(environmentPath, "utf8"); environment = environment.replace( /googleClientId:\s*'.*?'/, `googleClientId: '${googleClientId}'` ); environment = environment.replace( /saasName:\s*'.*?'/, `saasName: '${projectName}'` ); environment = environment.replace( /saasUrl:\s*'.*?'/, `saasUrl: 'https://www.${domain}/'` ); fs.writeFileSync(environmentPath, environment); // Update environment.prod.ts if (!process.cwd().includes(projectName)) { directoryName = path.join(process.cwd(), projectName); } else { directoryName = process.cwd(); } const environmentProdPath = path.resolve( directoryName, `./front-end/src/environments/environment.prod.ts` ); let environmentProd = fs.readFileSync(environmentProdPath, "utf8"); environmentProd = environmentProd.replace( /apiUrl:\s*'.*?'/, `apiUrl: 'https://api.${domain}'` ); environmentProd = environmentProd.replace( /clientUrl:\s*'.*?'/, `clientUrl: 'https://www.${domain}'` ); environmentProd = environmentProd.replace( /googleClientId:\s*'.*?'/, `googleClientId: '${googleClientId}'` ); environmentProd = environmentProd.replace( /saasName:\s*'.*?'/, `saasName: '${projectName}'` ); environmentProd = environmentProd.replace( /saasUrl:\s*'.*?'/, `saasUrl: 'https://www.${domain}/'` ); fs.writeFileSync(environmentProdPath, environmentProd); } // Command: Generate Project program .command("create [project-name]") .description("Generate a new SaaS project") .action(async (projectName) => { console.log(chalk.blue(`\nšŸ”Ø Welcome to SaaSsmith!\n`)); // Gather user input const details = await promptForDetails(projectName); projectName = projectName ? projectName : details.projectName; const projectPath = path.resolve(process.cwd(), details.projectName); if (fs.existsSync(projectPath)) { console.log( chalk.red(`\nāŒ Directory "${details.projectName}" already exists.`) ); process.exit(1); } // Select boilerplate based on tech stack const boilerplateRepo = techStacks[details.techStack]; // Clone the boilerplate console.log( chalk.green(`\nā³ Cloning boilerplate for "${details.techStack}"...`) ); const emitter = degit(boilerplateRepo, { cache: false, force: true }); try { await emitter.clone(projectPath); console.log( chalk.green(`\nāœ… Project created successfully at "${projectPath}"`) ); // Optional: Install dependencies if (details.installDependencies) { console.log( chalk.green( `\nšŸ“¦ Installing dependencies using ${details.packageManager}...` ) ); execSync(`${details.packageManager} install`, { cwd: projectPath, stdio: "inherit", }); } // Check and authenticate Firebase CLI if the tech stack includes Firebase if (details.techStack.includes("Firebase")) { checkFirebaseCLI(); authenticateFirebaseCLI(); // Initialize Firebase await initializeFirebase(details.projectName); } let mongoDBConnectionString = process.env["Saas_Smith_Default_MongoDB_Connection_String"]; let awsRegion = details.awsRegion; let bucketName = process.env["Saas_Smith_Default_Aws_S3_Bucket"]; // Update launch settings updateLaunchSettings( details.projectName, details.techStack, bucketName, mongoDBConnectionString, awsRegion, details.companyAddress, details.saasOwnerEmail, details.domain ); let googleClientId = ""; updateEnvironmentFiles( details.projectName, details.domain, googleClientId ); let frontendPath = projectPath + "/front-end/src"; // Install dependencies console.log(chalk.green(`\nšŸ”§ Installing npm dependencies...`)); execSync("npm install", { cwd: frontendPath, stdio: "inherit" }); //Initialize Git console.log(chalk.green(`\nšŸ”§ Initializing Git repository...`)); execSync("git init", { cwd: projectPath, stdio: "inherit" }); let ghUsername = process.env["Saas_Smith_Github_Username"]; console.log(chalk.green(`\nšŸ”§ Creating GitHub repository...`)); execSync("gh auth login", { stdio: "inherit" }); execSync(`gh repo create ${details.projectName} --private`, { stdio: "inherit", }); console.log(chalk.green(`\nšŸ”§ Adding remote origin...`)); execSync( `git remote add origin https://github.com/${ghUsername}/${details.projectName}.git`, { cwd: projectPath, stdio: "inherit" } ); console.log(chalk.green(`\nšŸ”§ Making initial commit...`)); execSync("git add .", { cwd: projectPath, stdio: "inherit" }); execSync('git commit -m "Initial commit"', { cwd: projectPath, stdio: "inherit", }); console.log(chalk.green(`\nšŸ”§ Pushing code to remote repository...`)); execSync("git push -u origin master", { cwd: projectPath, stdio: "inherit", }); // Display summary console.log(`\n✨ Your SaaS project is ready!`); console.log(`šŸ“‚ Directory: ${chalk.cyan(projectPath)}`); console.log(`šŸ’” Project Details:`); console.log(` Tech Stack: ${chalk.cyan(details.techStack)}`); console.log(`\nšŸ“‚ Next Steps:`); console.log(chalk.cyan(` cd ${details.projectName}`)); console.log(chalk.cyan(` saas-smith deploy`)); } catch (error) { console.log(chalk.red(`\nāŒ An error occured: ${error.message}`)); } }); // Command: Manage Environment Variables program .command("env") .description("Manage environment variables") .option("--set", "Set an environment variable") .option("-key, --key <key>", "Specify the key for the environment variable") .option( "-value, --value <value>", "Specify the value for the environment variable" ) .option("-g, --get <key>", "Get an environment variable") .option("-d, --delete <key>", "Delete an environment variable") .action((options) => { const envPath = path.join(rootDrive, ".env"); const prefix = "Saas_Smith_"; // Handle setting an environment variable if (options.set) { if (options.key && options.value) { const prefixedKey = `${prefix}${options.key}`; fs.appendFileSync(envPath, `\n${prefixedKey}=${options.value}`); console.log( chalk.green( `\nāœ… Environment variable ${prefixedKey} set to ${options.value}` ) ); } else { console.log(chalk.red("\nāŒ Please provide both --key and --value.")); } } // Handle getting an environment variable if (options.get) { const key = options.get; const prefixedKey = `${prefix}${key}`; const value = process.env[prefixedKey]; if (value) { console.log(chalk.green(`\nāœ… ${prefixedKey}=${value}`)); } else { console.log( chalk.red(`\nāŒ Environment variable ${prefixedKey} not found`) ); } } // Handle deleting an environment variable if (options.delete) { const key = options.delete; const prefixedKey = `${prefix}${key}`; const envContent = fs.readFileSync(envPath, "utf8"); const newEnvContent = envContent .split("\n") .filter((line) => !line.startsWith(`${prefixedKey}=`)) .join("\n"); fs.writeFileSync(envPath, newEnvContent); console.log( chalk.green(`\nāœ… Environment variable ${prefixedKey} deleted`) ); } }); // Command: Deploy Project program .command("deploy") .description("Deploy your SaaS project") .action(async () => { // Gather user input const details = await promptForDeployDetails(); console.log(chalk.blue(`\nšŸš€ Deploying your SaaS project...\n`)); // Purchase domain name console.log(chalk.green(`\nšŸ”§ Purchasing domain name...`)); // Set name servers // Create support email // Register email domain with Send Grid // Setup MongoDB if the tech stack includes MongoDb let validName = details.projectName .toLowerCase() .replace(/[^a-z0-9-]/g, "-") // Replace invalid characters with hyphens .replace(/^-+|-+$/g, ""); // Remove leading or trailing hyphens let mongoDBConnectionString; if (!details.techStack.includes("MongoDb")) { setupMongoDB(validName); mongoDBConnectionString = await getMongoDBConnectionString(validName); const mongoUserPassword = process.env["Saas_Smith_MongoDB_User_Password"]; mongoDBConnectionString = mongoDBConnectionString.replace( "mongodb+srv://", `mongodb+srv://${mongoUserPassword}@` ); mongoDBConnectionString = mongoDBConnectionString + `/?retryWrites=true&w=majority&appName=${validName}`; } const guid = () => { return "xxxxxxxx".replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0, v = c === "x" ? r : (r & 0x3) | 0x8; return v.toString(16); }); }; let bucketName = `${validName}-${guid()}`; // Setup AWS S3 if the tech stack includes AWS S3 let awsRegion = details.awsRegion; if (!details.techStack.includes("AWS S3")) { await setupAWSS3( bucketName, awsRegion, process.env["Saas_Smith_Aws_AccessKeyId"], process.env["Saas_Smith_Aws_SecretAccessKey"] ); } // Set environment.prod.ts variables updateEnvironmentFiles( details.projectName, details.domain, details.googleClientId ); // Run angular build command const frontendPath = path.join("front-end"); console.log(chalk.green(`\nšŸ”§ Building the front-end...`)); execSync("npm run build", { cwd: frontendPath, stdio: "inherit" }); // Push commit to remote console.log(chalk.green(`\nšŸ”§ Making deployment commit...`)); execSync("git add .", { stdio: "inherit" }); execSync('git commit -m "Deployment commit"', { stdio: "inherit" }); console.log(chalk.green(`\nšŸ”§ Pushing code to remote repository...`)); execSync("git push -u origin master", { stdio: "inherit", }); // Host cdn assets cloudflare const wranglerTomlPath = path.join("wrangler.toml"); fs.writeFileSync( wranglerTomlPath, ` name = "${validName}" env= "production" [assets] directory = "./front-end/dist/front-end/browser" ` ); const command = `wrangler pages deploy ./front-end/dist/front-end/browser --project-name=${validName}`; execSync(command, { stdio: "inherit" }); // delete wrangler.toml fs.removeSync(wranglerTomlPath); execSync("doctl auth init", { stdio: "inherit" }); // Host site on digitalOcean api/ssr server (set cdn url) // ssr server let serviceName = validName + "-ssr"; console.log( chalk.green( `\nšŸ”§ Hosting ssr server on Digital Ocean using Docker image...` ) ); let ghUsername = process.env["Saas_Smith_Github_Username"]; let appYamlPath = path.join("app.yaml"); fs.writeFileSync( appYamlPath, `name: ${serviceName} region: sfo3 services: - name: ssr-server environment_slug: node-js github: repo: ${ghUsername}/${details.projectName} branch: master deploy_on_push: true http_port: 4000 instance_count: 1 run_command: npm run start source_dir: front-end/dist/front-end/server instance_size_slug: basic-xxs` ); execSync(`doctl apps create --spec ${appYamlPath}`, { stdio: "inherit" }); // delete app.yaml fs.removeSync(appYamlPath); // api server serviceName = validName + "-api"; console.log( chalk.green(`\nšŸ”§ Hosting api server on Render using Docker image...`) ); // Read launchSettings.json as a string const launchSettingsPath = path.join( "API", "Properties", "launchSettings.json" ); const launchSettingsContent = fs.readFileSync(launchSettingsPath, "utf8"); // Extract the environmentVariables section const envVarsString = launchSettingsContent.match( /"environmentVariables":\s*{([^}]*)}/s )[0]; const envVarsJson = `{${envVarsString}}`; const envVars = JSON.parse(envVarsJson).environmentVariables; envVars["ASPNETCORE_ENVIRONMENT"] = "Production"; if (mongoDBConnectionString) { envVars["MongoDB_ConnectionString"] = mongoDBConnectionString; } if (details.techStack.includes("AWS S3")) { envVars["Aws_BucketName"] = bucketName; } envVars["Api_Domain"] = `https://api.${details.domain}`; envVars["FrontEnd_Domain"] = `https://www.${details.domain}`; envVars["Token_Issuer"] = `https://api.${details.domain}`; envVars["Token_Audience"] = `https://api.${details.domain}`; envVars[ "Logo_Url" ] = `https://cdn.${details.domain}/assets/images/yourlogo.svg`; // Remove environment variables with null values for (const key in envVars) { if (!envVars[key]) { delete envVars[key]; } } // Convert environment variables to Render format const finalEnvVars = Object.keys(envVars).map((key) => ({ key, value: envVars[key], })); appYamlPath = path.join("app-api.yaml"); fs.writeFileSync( appYamlPath, `name: ${serviceName} region: sfo3 services: - name: api-server github: repo: ${ghUsername}/${details.projectName} branch: master deploy_on_push: true dockerfile_path: Dockerfile http_port: 5000 instance_count: 1 instance_size_slug: basic-xxs routes: - path: / envs: ${finalEnvVars .map( (envVar) => `- key: ${envVar.key}\n value: "${envVar.value}"` ) .join("\n ")}` ); execSync(`doctl apps create --spec ${appYamlPath}`, { stdio: "inherit" }); // delete app.yaml fs.removeSync(appYamlPath); //DNS update console.log(chalk.green(`\n✨ Deployment complete!`)); console.log(`🌐 Your SaaS project has been successfully deployed.`); console.log(`šŸ“‚ Project Name: ${chalk.cyan(details.projectName)}`); console.log( `šŸ”— Frontend URL: ${chalk.cyan(`https://www.${details.domain}`)}` ); console.log(`šŸ”— API URL: ${chalk.cyan(`https://api.${details.domain}`)}`); console.log(`\nšŸ’” Next Steps:`); console.log(chalk.cyan(` - Monitor your deployment`)); console.log(chalk.cyan(` - Configure additional settings if needed`)); }); program.parse(process.argv);