saas-smith
Version:
CLI to forge new SaaS projects effortlessly from boilerplates
751 lines (648 loc) ⢠22.7 kB
JavaScript
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);