bubbles-express-generator
Version:
A simple CLI to scaffold Express.js starter projects.
345 lines (309 loc) β’ 9.83 kB
JavaScript
#!/usr/bin/env node
import updateNotifier from 'update-notifier';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(
readFileSync(path.join(__dirname, '..', 'package.json')),
);
const notifier = updateNotifier({ pkg, updateCheckInterval: 0 });
import prompts from 'prompts';
import fs from 'fs/promises';
import kleur from 'kleur';
import boxen from 'boxen';
import ora from 'ora';
if (notifier.update && process.stdout.isTTY) {
console.log(
boxen(
[
`π Update available: ${kleur.dim(notifier.update.current)} ${kleur
.green()
.bold('β')} ${kleur.green().bold(notifier.update.latest)}`,
'',
`π¦ Run ${kleur
.green()
.bold(`npm i -g ${notifier.update.name}`)} to update`,
].join('\n'),
{
padding: 1,
margin: 1,
borderStyle: 'double',
borderColor: 'red',
title: '!! Update available !!',
titleAlignment: 'center',
},
),
);
}
import { argv } from 'process';
const args = argv.slice(2);
if (args.includes('-h') || args.includes('--help')) {
console.log(
boxen(
[
kleur.magenta().bold("π§° Bubbles' Express Generator β Help"),
'',
kleur.white('Usage:'),
' npx bubbles-express [project-name|.] [flags]',
'',
kleur.white('Flags:'),
' --ts Use TypeScript',
' --js Use JavaScript',
' --mongo Use MongoDB (Mongoose)',
' --pg Use PostgreSQL (Supabase + Drizzle)',
' -h, --help Show this help message',
'',
kleur.gray('Example:'),
' npx bubbles-express my-api --ts --mongo',
' npx bubbles-express . --js --pg',
].join('\n'),
{
padding: 1,
margin: 1,
borderStyle: 'double',
borderColor: 'magenta',
title: 'Help',
titleAlignment: 'center',
},
),
);
process.exit(0);
}
const isDot = args.includes('.') || args[0] === '.';
const flags = {
useCurrentDir: isDot,
projectName: isDot ? '.' : args.find((arg) => !arg.startsWith('--')) || null,
language: args.includes('--ts') ? 'ts' : args.includes('--js') ? 'js' : null,
db: args.includes('--mongo') ? 'mongo' : args.includes('--pg') ? 'pg' : null,
};
const isTestMode = process.env.NODE_ENV === 'test';
if (!isTestMode) {
const hasFlags = flags.language && flags.db;
const introMessage = hasFlags
? `${kleur
.green()
.bold('π Oh, I see you know what you want β letβs get started!')}
${kleur.dim(
`> npx bubbles-express ${flags.projectName} --${flags.language} --${flags.db}`,
)}
${kleur.gray(
`project: ${flags.projectName} | language: ${flags.language} | database: ${flags.db}`,
)}`
: `π Welcome to ${kleur.magenta().bold("Bubbles' Express Generator")}!
${kleur.white("Answer a few questions and we'll get you set up quickly.")}
π‘ ${kleur.italic('Need help? Stop and run')} ${kleur.bold(
'npx bubbles-express -h',
)}
`;
console.log(
boxen(introMessage, {
padding: 1,
margin: 1,
borderStyle: 'double',
borderColor: hasFlags ? 'green' : 'magenta',
title: hasFlags ? 'Auto Setup' : "Let's get started",
titleAlignment: 'center',
textAlignment: 'left',
}),
);
}
const mockResponses = isTestMode
? {
projectName: flags.projectName ?? 'test-app',
language: flags.language ?? 'js',
db: flags.db ?? 'mongo',
}
: null;
const promptQuestions = [];
if (!flags.projectName || flags.projectName.trim() === '') {
promptQuestions.push({
type: 'text',
name: 'projectName',
message: 'What is the name of your project?',
initial: 'backend',
});
}
if (!flags.language) {
promptQuestions.push({
type: 'select',
name: 'language',
message: 'What language do you want to use?',
choices: [
{ title: 'JavaScript', value: 'js' },
{ title: 'TypeScript', value: 'ts' },
],
});
}
if (!flags.db) {
promptQuestions.push({
type: 'select',
name: 'db',
message: 'What database do you want to use?',
choices: [
{ title: 'MongoDB (Atlas) with Mongoose ODM', value: 'mongo' },
{ title: 'Supabase PostgreSQL with Drizzle ORM', value: 'pg' },
],
});
}
let response;
if (isTestMode) {
response = mockResponses;
} else {
response = await prompts(promptQuestions);
}
response.projectName = flags.projectName ?? response.projectName;
response.language = flags.language ?? response.language;
response.db = flags.db ?? response.db;
const createProject = async (choices) => {
try {
if (!choices.language || !choices.db) {
const moreChoices = await prompts([
{
type: 'select',
name: 'language',
message: 'What language do you want to use?',
choices: [
{ title: 'JavaScript', value: 'js' },
{ title: 'TypeScript', value: 'ts' },
],
},
{
type: 'select',
name: 'db',
message: 'What database do you want to use?',
choices: [
{ title: 'MongoDB (Atlas) with Mongoose ODM', value: 'mongo' },
{ title: 'Supabase PostgreSQL with Drizzle ORM', value: 'pg' },
],
},
]);
Object.assign(choices, moreChoices);
}
const templateDir = path.resolve(
__dirname,
'..',
`templates/${choices.language}-${choices.db}`,
);
const targetDir = path.resolve(
process.cwd(),
choices.projectName === '.' ? '.' : choices.projectName,
);
const existingFiles = await fs.readdir(targetDir).catch(() => []);
if (existingFiles.length > 0) {
const overwrite =
isTestMode && process.env.MOCK_OVERWRITE !== undefined
? process.env.MOCK_OVERWRITE === '1'
: (
await prompts({
type: 'confirm',
name: 'overwrite',
message: `The directory "${path.basename(
targetDir,
)}" is not empty. Overwrite?`,
initial: false,
})
).overwrite;
if (!overwrite) {
const newName =
isTestMode && process.env.MOCK_RENAME
? process.env.MOCK_RENAME
: (
await prompts({
type: 'text',
name: 'newName',
message: 'Choose a new name for your project:',
initial: `${choices.projectName}-new`,
})
).newName;
choices.projectName = newName;
return await createProject(choices);
} else {
await fs.rm(targetDir, { recursive: true, force: true });
}
}
await fs.mkdir(targetDir, { recursive: true });
await fs.cp(templateDir, targetDir, { recursive: true });
const placeholders = {
'{{__PROJECT_NAME__}}': path.basename(targetDir),
};
const replacePlaceholders = async (dir) => {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await replacePlaceholders(fullPath);
} else {
let content = await fs.readFile(fullPath, 'utf-8');
let updated = content;
for (const [placeholder, value] of Object.entries(placeholders)) {
updated = updated.replace(new RegExp(placeholder, 'g'), value);
}
if (updated !== content) {
await fs.writeFile(fullPath, updated, 'utf-8');
}
}
}
};
await replacePlaceholders(targetDir);
const spinner = ora('π¦ Installing dependencies...').start();
const { exec } = await import('child_process');
await new Promise((resolve, reject) => {
exec('npm install', { cwd: targetDir }, (error, stdout, stderr) => {
if (error) {
spinner.fail(kleur.red('β npm install failed'));
console.error(kleur.red(stderr));
reject(error);
} else {
spinner.succeed(kleur.green('β
Dependencies installed'));
console.log(kleur.gray(stdout));
resolve();
}
});
});
const summaryBox = boxen(
[
`π ${kleur.bold('Project created successfully!')}`,
`${kleur.gray(
`> npx bubbles-express ${choices.projectName} --${choices.language} --${choices.db}`,
)}`,
'',
`${kleur.bold('π Project Folder:')} ${kleur.green(
path.basename(targetDir),
)}`,
`${kleur.bold('π οΈ Language:')} ${kleur.yellow(
choices.language,
)}`,
`${kleur.bold('ποΈ Database:')} ${kleur.cyan(choices.db)}`,
'',
kleur.italic('Happy coding! π'),
'',
kleur.bold('π Next steps:'),
` ${kleur.dim(`cd ${path.basename(targetDir)}`)}`,
` ${kleur.dim('npm run dev')}`,
].join('\n'),
{
padding: { top: 1, bottom: 1, left: 2, right: 2 },
margin: 1,
borderStyle: 'double',
borderColor: 'green',
title: 'Setup Complete',
titleAlignment: 'center',
textAlignment: 'left',
align: 'left',
},
);
console.log(summaryBox);
} catch (error) {
console.error('Error creating project:', error);
return;
}
};
if (response.projectName && response.language && response.db) {
createProject(response);
} else {
console.log(
kleur.yellow('\nβ οΈ Project setup was canceled or incomplete.\n'),
);
process.exit(0);
}