typescript-express-starter
Version:
Quick and Easy TypeScript Express Starter
696 lines (608 loc) โข 23.3 kB
JavaScript
/*****************************************************************
* TYPESCRIPT-EXPRESS-STARTER - Quick and Easy TypeScript Express Starter
* (c) 2020-present AGUMON (https://github.com/ljlm0402/typescript-express-starter)
*
* MIT License
*
* Made with โค๏ธ by AGUMON ๐ฆ
*****************************************************************/
import { select, text, isCancel, intro, outro, cancel, note, confirm } from '@clack/prompts';
import chalk from 'chalk';
import editJsonFile from 'edit-json-file';
import { execa } from 'execa';
import fs from 'fs-extra';
import ora from 'ora';
import path from 'path';
// Config and constants
import { CONFIG, getEnvironmentConfig } from './config.js';
import { PACKAGE_MANAGER, TEMPLATES_VALUES, DEVTOOLS_VALUES } from './common.js';
import { getBenchmarkInfo } from './presets.js';
// Database configuration
import { TEMPLATE_DB, generateDockerFiles, generateDockerCompose } from './db-map.js';
// Error handling
import { CLIError, NetworkError, FileSystemError, printError } from './errors.js';
// Validation utilities
import {
validateProjectName,
validateProjectPath,
sanitizeInput,
validateNodeVersion,
validateAllTemplates,
} from './validators.js';
// Performance optimizations
import { versionCache, PackageBatch } from './performance.js';
// AST utilities
import { injectSwaggerIntoApp } from './ast-utils.js';
const RECOMMENDED_TOOLS = {
Linter: 'biome',
Compiler: 'tsup',
Testing: 'vitest',
Infrastructure: null,
};
function isNewerVersion(latest, current) {
const normalize = (version) =>
String(version)
.replace(/^v/i, '')
.split('-')[0]
.split('.')
.map((part) => Number.parseInt(part, 10) || 0);
const latestParts = normalize(latest);
const currentParts = normalize(current);
const maxLength = Math.max(latestParts.length, currentParts.length);
for (let index = 0; index < maxLength; index += 1) {
const left = latestParts[index] ?? 0;
const right = currentParts[index] ?? 0;
if (left > right) return true;
if (left < right) return false;
}
return false;
}
function getGroupedDevtools() {
return DEVTOOLS_VALUES.reduce((acc, tool) => {
const category = tool.category || 'Others';
if (!acc[category]) acc[category] = [];
acc[category].push(tool);
return acc;
}, {});
}
function getPresetDevtools(setupProfile, groupedDevtools) {
if (setupProfile === 'minimal') return [];
const picked = [];
for (const [category, tools] of Object.entries(groupedDevtools)) {
if (setupProfile === 'full') {
const firstTool = tools[0];
if (firstTool) picked.push(firstTool.value);
continue;
}
const recommendedValue = RECOMMENDED_TOOLS[category];
const recommendedTool = tools.find((tool) => tool.value === recommendedValue) || tools[0];
if (recommendedTool) picked.push(recommendedTool.value);
}
return picked;
}
function buildSetupSummary({ pkgManager, template, projectName, devtoolValues, setupMode }) {
const selectedTemplate = TEMPLATES_VALUES.find((item) => item.value === template);
const selectedToolNames = devtoolValues
.map((value) => DEVTOOLS_VALUES.find((tool) => tool.value === value)?.name)
.filter(Boolean);
return [
`Setup mode : ${setupMode}`,
`Package manager: ${pkgManager}`,
`Template : ${selectedTemplate?.name || template}`,
`Project name : ${projectName}`,
`Devtools : ${selectedToolNames.length > 0 ? selectedToolNames.join(', ') : 'None'}`,
].join('\n');
}
// ========== [๊ณตํต ํจ์๋ค] ==========
// ์ต์ CLI ๋ฒ์ ์ฒดํฌ & ์ ํ์ ์ค์น
async function checkForUpdate() {
try {
const pkgPath = path.resolve(process.cwd(), 'package.json');
const localPkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const pkgName = localPkg.name || 'typescript-express-stater';
const localVersion = localPkg.version || '0.0.0';
const latest = await versionCache.getLatestVersion(pkgName);
if (isNewerVersion(latest, localVersion)) {
console.log(
chalk.yellow(`๐ New version available: ${latest} (You are on ${localVersion})`),
);
const shouldUpdate = await confirm({
message: `Do you want to update ${pkgName} to version ${latest}?`,
initial: true,
});
if (isCancel(shouldUpdate)) return;
if (shouldUpdate) {
console.log(chalk.gray(` Updating to latest version...`));
try {
await execa('npm', ['install', '-g', `${pkgName}@${latest}`], { stdio: 'inherit' });
console.log(chalk.green(` โ Updated ${pkgName} to ${latest}`));
} catch (err) {
printError(new NetworkError(`Failed to update ${pkgName}`, 'update', err.message));
}
} else {
console.log(chalk.gray('Skipped updating.'));
}
}
} catch (err) {
printError(new NetworkError('Failed to check latest version', 'version-check', err.message));
}
}
// ํจํค์ง๋งค๋์ ๊ธ๋ก๋ฒ ์ค์น์ฌ๋ถ
async function checkPkgManagerInstalled(pm) {
try {
await execa(pm, ['--version']);
return true;
} catch {
return false;
}
}
// ๋๊ตฌ ๊ฐ ์์กด์ฑ ์๋ ํด๊ฒฐ
function resolveDependencies(selected) {
const all = new Set(selected);
let changed = true;
while (changed) {
changed = false;
for (const tool of DEVTOOLS_VALUES) {
if (all.has(tool.value) && tool.requires) {
for (const req of tool.requires) {
if (!all.has(req)) {
all.add(req);
changed = true;
}
}
}
}
}
return Array.from(all);
}
// ํ์ผ ๋ณต์ฌ
async function copyDevtoolFiles(devtool, destDir, template = 'default') {
// ๋๊ตฌ ์นดํ
๊ณ ๋ฆฌ์ ๋ฐ๋ฅธ ๊ฒฝ๋ก ๋งคํ
const categoryPathMap = {
Linter: 'core',
Compiler: 'build',
Testing: 'test',
'API development': 'api',
Infrastructure: 'infrastructure',
'Git Tools': 'core',
Deployment: 'core',
};
// ํ
ํ๋ฆฟ ์ด๋ฆ์ devtools ํ์ผ๋ช
prefix๋ก ๋งคํ
const templateToPrefix = {
'drizzle-postgresql': 'drizzle',
'prisma-postgresql': 'prisma',
'mongoose-mongodb': 'mongoose',
'typegoose-mongodb': 'typegoose',
default: null,
};
// ํ
ํ๋ฆฟ๋ณ src ํด๋ ๋งคํ (Testing ์นดํ
๊ณ ๋ฆฌ์ฉ)
const templateToSrcFolder = {
'drizzle-postgresql': 'src-drizzle',
'prisma-postgresql': 'src-prisma',
// 'mongoose-mongodb': 'src-default',
// 'typegoose-mongodb': 'src-default',
default: 'src-default',
};
const categoryPath = categoryPathMap[devtool.category] || '';
for (const file of devtool.files) {
let src = path.join(CONFIG.paths.devtools, categoryPath, devtool.value, file);
const dst = path.join(destDir, file);
// Testing ์นดํ
๊ณ ๋ฆฌ์ src/test ํ์ผ ํน๋ณ ์ฒ๋ฆฌ
if (devtool.category === 'Testing' && (file === 'src/test' || file.startsWith('src/'))) {
const srcFolderName = templateToSrcFolder[template] || 'src-default';
src = path.join(CONFIG.paths.devtools, categoryPath, devtool.value, srcFolderName, 'test');
if (await fs.pathExists(src)) {
console.log(chalk.cyan(` โ Using template-specific test files: ${srcFolderName}/test`));
} else {
// fallback to src-default if template-specific src doesn't exist
src = path.join(CONFIG.paths.devtools, categoryPath, devtool.value, 'src-default', 'test');
console.log(chalk.yellow(` โป Falling back to src-default/test`));
}
// src/test ํด๋๋ฅผ ๋ณต์ฌ
if (await fs.pathExists(src)) {
await fs.copy(src, dst, { overwrite: true });
console.log(chalk.gray(` โ ${file} copied from ${srcFolderName}.`));
} else {
console.log(chalk.red(` โ ${file} not found at ${src}`));
}
continue;
}
// Testing ์นดํ
๊ณ ๋ฆฌ์ ์ค์ ํ์ผ ํน๋ณ ์ฒ๋ฆฌ (์๋ก์ด ๊ตฌ์กฐ: src-{template}/์ค์ ํ์ผ)
if (devtool.category === 'Testing' && file.includes('.config.')) {
const srcFolderName = templateToSrcFolder[template] || 'src-default';
src = path.join(CONFIG.paths.devtools, categoryPath, devtool.value, srcFolderName, file);
if (await fs.pathExists(src)) {
console.log(chalk.cyan(` โ Using template-specific config: ${srcFolderName}/${file}`));
} else {
// fallback to src-default if template-specific config doesn't exist
src = path.join(CONFIG.paths.devtools, categoryPath, devtool.value, 'src-default', file);
console.log(chalk.yellow(` โป Falling back to src-default/${file}`));
}
if (await fs.pathExists(src)) {
await fs.copy(src, dst, { overwrite: true });
console.log(chalk.gray(` โ ${file} copied from ${srcFolderName}.`));
} else {
console.log(chalk.red(` โ ${file} not found at ${src}`));
}
continue;
}
// ํ
ํ๋ฆฟ๋ณ ํนํ ์ค์ ํ์ผ ์ฐ์ ๊ฒ์
if (template !== 'default' && templateToPrefix[template]) {
const prefix = templateToPrefix[template];
let templateSpecificFile;
if (file.includes('.config.')) {
// jest.config.cjs -> drizzle.jest.config.cjs
templateSpecificFile = `${prefix}.${file}`;
} else if (file.startsWith('.')) {
// .prettierrc -> drizzle.prettierrc
templateSpecificFile = `${prefix}${file}`;
} else {
// ์ผ๋ฐ ํ์ผ์ด๋ ๋๋ ํ ๋ฆฌ์ธ ๊ฒฝ์ฐ
templateSpecificFile = `${prefix}.${file}`;
}
const templateSpecificSrc = path.join(
CONFIG.paths.devtools,
categoryPath,
devtool.value,
templateSpecificFile,
);
if (await fs.pathExists(templateSpecificSrc)) {
src = templateSpecificSrc;
console.log(chalk.cyan(` โ Using template-specific config: ${templateSpecificFile}`));
}
}
if (await fs.pathExists(src)) {
await fs.copy(src, dst, { overwrite: true });
console.log(chalk.gray(` โ ${file} copied.`));
} else {
console.log(chalk.yellow(` โ ๏ธ ${file} not found at ${src}`));
}
}
}
// ํจํค์ง ์ค์น (์ฑ๋ฅ ์ต์ ํ๋ ๋ฐฐ์น ์ฒ๋ฆฌ ์ฌ์ฉ)
async function installPackages(pkgs, pkgManager, dev = true, destDir = process.cwd()) {
if (!pkgs || pkgs.length === 0) return;
const batch = new PackageBatch();
batch.addMany(pkgs);
const resolved = await batch.resolve();
const installCmd =
pkgManager === 'npm'
? ['install', dev ? '--save-dev' : '', ...resolved].filter(Boolean)
: pkgManager === 'yarn'
? ['add', dev ? '--dev' : '', ...resolved].filter(Boolean)
: ['add', dev ? '-D' : '', ...resolved].filter(Boolean);
await execa(pkgManager, installCmd, { cwd: destDir, stdio: 'inherit' });
}
// package.json ์์ (์คํฌ๋ฆฝํธ ์ถ๊ฐ ๋ฑ)
async function updatePackageJson(scripts, destDir, projectName = null) {
const pkgPath = path.join(destDir, 'package.json');
const file = editJsonFile(pkgPath, { autosave: true });
// ํ๋ก์ ํธ ์ด๋ฆ์ด ์ ๊ณต๋ ๊ฒฝ์ฐ package.json์ name ํ๋ ์
๋ฐ์ดํธ
if (projectName) {
file.set('name', projectName);
console.log(chalk.gray(` โฏ package.json name updated to: ${projectName}`));
}
Object.entries(scripts).forEach(([k, v]) => file.set(`scripts.${k}`, v));
if (!file.get('scripts.prepare') && fs.existsSync(path.join(destDir, '.huskyrc'))) {
file.set('scripts.prepare', 'husky install');
}
file.save();
}
// docker-compose ์์ฑ (ํธํ์ฑ ์ ์ง์ฉ)
async function generateCompose(template, destDir) {
try {
const composeYml = generateDockerCompose(template);
const filePath = path.join(destDir, 'docker-compose.yml');
await fs.writeFile(filePath, composeYml, 'utf8');
const dbType = TEMPLATE_DB[template];
console.log(chalk.gray(` โฏ docker-compose.yml generated with ${dbType || 'no database'}`));
return dbType;
} catch (error) {
console.log(chalk.yellow(`[docker-compose] Warning: ${error.message}`));
return null;
}
}
// Git init & ์ฒซ ์ปค๋ฐ
async function gitInitAndFirstCommit(destDir) {
const doGit = await confirm({ message: 'Initialize git and make first commit?', initial: true });
if (isCancel(doGit)) return;
if (!doGit) return;
try {
await execa('git', ['init'], { cwd: destDir });
await execa('git', ['add', '.'], { cwd: destDir });
await execa('git', ['commit', '-m', 'init'], { cwd: destDir });
console.log(chalk.green(' โ git initialized and first commit made!'));
} catch (e) {
printError(
new CLIError('git init/commit failed', 'git', 'Check git is installed and accessible.'),
);
}
}
// ========== [๋ฉ์ธ CLI ์คํ ํ๋ฆ] ==========
async function main() {
// 1. Node ๋ฒ์ ์ฒดํฌ
validateNodeVersion(CONFIG.minNodeVersion);
// 2. ํ
ํ๋ฆฟ ๋ฌด๊ฒฐ์ฑ ๊ฒ์ฆ
try {
const templateResults = validateAllTemplates();
const violations = Object.entries(templateResults).filter(([_, result]) => !result.valid);
if (violations.length > 0) {
const violationList = violations
.map(([name, result]) => `${name}: ${result.violations.join(', ')}`)
.join('\n');
throw new CLIError(
`Template integrity violations found:\n${violationList}`,
'template-integrity',
'Run "node bin/validate-templates.js" to fix these issues',
);
}
} catch (error) {
if (error instanceof CLIError) {
throw error;
}
// Other validation errors are non-critical, just log them
console.warn(chalk.yellow('โ ๏ธ Template validation warning:'), error.message);
}
const config = getEnvironmentConfig();
if (!config.env.skipVersionCheck) {
await checkForUpdate();
}
intro(config.banner.gradient);
// 3. ์ค์ ๋ชจ๋ ์ ํ
const setupMode = await select({
message: 'Choose setup mode:',
options: [
{
label: '๐ Quick start (recommended preset)',
value: 'quick',
hint: 'Automatically selects a sensible toolset',
},
{
label: '๐ Custom (step-by-step)',
value: 'custom',
hint: 'Pick tools category by category',
},
],
initialValue: 'quick',
});
if (isCancel(setupMode)) return cancel('โ Aborted.');
// 3. ํจํค์ง ๋งค๋์ ์ ํ + ๊ธ๋ก๋ฒ ์ค์น ํ์ธ
let pkgManager;
while (true) {
pkgManager = await select({
message: 'Which package manager do you want to use?',
options: PACKAGE_MANAGER,
initialValue: 'npm',
});
if (isCancel(pkgManager)) return cancel('โ Aborted.');
if (await checkPkgManagerInstalled(pkgManager)) break;
printError(
new CLIError(
`${pkgManager} is not installed globally!`,
'package-manager',
`Install ${pkgManager} first, then run the command again.`,
),
);
}
note(`Using: ${pkgManager}`);
// 4. ํ
ํ๋ฆฟ ์ ํ
const templateDirs = (await fs.readdir(CONFIG.paths.templates)).filter((f) =>
fs.statSync(path.join(CONFIG.paths.templates, f)).isDirectory(),
);
if (templateDirs.length === 0)
return printError(new CLIError('No templates found!', 'template-selection'));
const options = TEMPLATES_VALUES.filter((t) => t.active && templateDirs.includes(t.value)).map(
(t) => ({
label: t.name, // UI์ ํ์๋ ์ด๋ฆ (โ
ํฌํจ)
value: t.value, // ์ ํ ๊ฐ
hint: `${t.desc} ยท ${t.complexity} ยท ${t.maturity}`,
}),
);
const template = await select({
message: 'Choose a template:',
options: options,
initialValue: 'default',
});
if (isCancel(template)) return cancel('โ Aborted.');
// 5. ํ๋ก์ ํธ๋ช
์
๋ ฅ (์ค๋ณต์ฒดํฌ/๋ฎ์ด์ฐ๊ธฐ)
let projectName, destDir;
let shouldOverwrite = false;
while (true) {
const rawProjectName = await text({
message: 'Enter your project name:',
initial: CONFIG.defaultProjectName,
validate: (val) => {
try {
validateProjectName(val);
return undefined;
} catch (error) {
return error.message;
}
},
});
if (isCancel(rawProjectName)) return cancel('โ Aborted.');
projectName = sanitizeInput(rawProjectName);
destDir = validateProjectPath(path.resolve(process.cwd(), projectName));
if (await fs.pathExists(destDir)) {
const overwrite = await confirm({
message: `Directory "${projectName}" already exists. Overwrite?`,
initial: false,
});
if (isCancel(overwrite)) return cancel('โ Aborted.');
if (overwrite) {
shouldOverwrite = true;
break;
}
} else break;
}
// 6. ๊ฐ๋ฐ ๋๊ตฌ ์นดํ
๊ณ ๋ฆฌ๋ณ ์ ํ
let devtoolValues = [];
const groupedDevtools = getGroupedDevtools();
if (setupMode === 'quick') {
const setupProfile = await select({
message: 'Choose quick profile:',
options: [
{
label: 'Minimal',
value: 'minimal',
hint: 'Template only (no additional devtools)',
},
{
label: 'Recommended',
value: 'recommended',
hint: 'Balanced default toolchain',
},
{
label: 'Full',
value: 'full',
hint: 'One tool from each category',
},
],
initialValue: 'recommended',
});
if (isCancel(setupProfile)) return cancel('โ Aborted.');
devtoolValues = getPresetDevtools(setupProfile, groupedDevtools);
note(
devtoolValues.length > 0
? `Quick profile selected: ${setupProfile} (${devtoolValues.join(', ')})`
: `Quick profile selected: ${setupProfile} (no additional devtools)`,
'Preset',
);
} else {
for (const [category, tools] of Object.entries(groupedDevtools)) {
const picked = await select({
message: `Select a tool for "${category}":`,
options: [
{ label: 'Skip', value: null, hint: 'Do not add a tool for this category' },
...tools.map(({ name, value, desc }) => ({
label: `${name}`,
value,
hint: `${desc} ${getBenchmarkInfo({ value }, template)}`,
})),
],
initialValue: null,
});
if (isCancel(picked)) return cancel('โ Aborted.');
if (picked) devtoolValues.push(picked);
}
}
devtoolValues = resolveDependencies(devtoolValues);
note(
buildSetupSummary({
pkgManager,
template,
projectName,
devtoolValues,
setupMode,
}),
'Configuration summary',
);
const proceed = await confirm({
message: 'Proceed with project generation?',
initial: true,
});
if (isCancel(proceed) || !proceed) return cancel('โ Aborted.');
// === [์งํ] ===
// [1] ํ
ํ๋ฆฟ ๋ณต์ฌ
const spinner = ora('Copying template...').start();
try {
if (shouldOverwrite) {
await fs.emptyDir(destDir);
}
await fs.copy(path.join(CONFIG.paths.templates, template), destDir, { overwrite: true });
spinner.succeed('Template copied!');
} catch (e) {
spinner.fail('Template copy failed!');
printError(new FileSystemError(e.message, destDir, 'Check templates folder and permissions.'));
return process.exit(1);
}
// [1-0] package.json name ํ๋ ์
๋ฐ์ดํธ
await updatePackageJson({}, destDir, projectName);
// [1-1] Testing ๋๊ตฌ๋ฅผ ์ ํํ ๊ฒฝ์ฐ์๋ง /src/test ์์ ๋ณต์ฌ
// ์ฃผ์: ์ด์ files ๋ฐฐ์ด์ 'src/test'๊ฐ ํฌํจ๋์ด copyDevtoolFiles์์ ์๋ ์ฒ๋ฆฌ๋จ
/*
const testDevtool = devtoolValues
.map((val) => DEVTOOLS_VALUES.find((d) => d.value === val))
.find((tool) => tool && tool.category === 'Testing');
if (testDevtool) {
const devtoolTestDir = path.join(CONFIG.paths.devtools, testDevtool.value, 'src', 'test');
const projectTestDir = path.join(destDir, 'src', 'test');
if (await fs.pathExists(devtoolTestDir)) {
await fs.copy(devtoolTestDir, projectTestDir, { overwrite: true });
console.log(chalk.gray(` โฏ test files for ${testDevtool.name} copied.`));
}
}
*/
// [2] ๊ฐ๋ฐ ๋๊ตฌ ํ์ผ/ํจํค์ง/์คํฌ๋ฆฝํธ/์ฝ๋ํจ์น
for (const [index, val] of devtoolValues.entries()) {
const tool = DEVTOOLS_VALUES.find((d) => d.value === val);
if (!tool) continue;
spinner.start(`Setting up ${tool.name} (${index + 1}/${devtoolValues.length})...`);
await copyDevtoolFiles(tool, destDir, template);
// [2-1] ๊ฐ๋ฐ ๋๊ตฌ - ํจํค์ง ์ค์น
if (tool.pkgs?.length > 0) await installPackages(tool.pkgs, pkgManager, false, destDir);
if (tool.devPkgs?.length > 0) await installPackages(tool.devPkgs, pkgManager, true, destDir);
// [2-2] ๊ฐ๋ฐ ๋๊ตฌ - ์คํฌ๋ฆฝํธ ์ถ๊ฐ ๋ฑ
if (Object.keys(tool.scripts).length) await updatePackageJson(tool.scripts, destDir);
// [2-2-1] ๊ฐ๋ฐ ๋๊ตฌ - postInstall ํจ์ ์คํ (Jest ํ์
์ค์ ๋ฑ)
if (tool.postInstall && typeof tool.postInstall === 'function') {
try {
tool.postInstall(destDir);
console.log(chalk.gray(` โฏ ${tool.name} postInstall completed.`));
} catch (error) {
console.log(chalk.yellow(` โ ๏ธ ${tool.name} postInstall warning:`, error.message));
}
}
// [2-3] ๊ฐ๋ฐ ๋๊ตฌ - Docker ์ ํ ํ ๊ฒฝ์ฐ, ํ์ผ ๊ธฐ๋ฐ Docker ์ค์ ์์ฑ
if (tool.value === 'docker') {
try {
await generateDockerFiles(template, destDir);
console.log(chalk.gray(` โฏ Docker environment configured for ${template}`));
} catch (error) {
console.log(chalk.yellow(` โ ๏ธ Docker setup warning: ${error.message}`));
// Fallback to legacy method
await generateCompose(template, destDir);
}
}
// [2-4] ๊ฐ๋ฐ ๋๊ตฌ - Swagger ์ ํ ์์๋ง app.ts AST ํจ์น
if (tool.value === 'swagger') {
await injectSwaggerIntoApp(destDir);
}
spinner.succeed(`${tool.name} setup done.`);
}
// [3] ํ
ํ๋ฆฟ ๊ธฐ๋ณธ ํจํค์ง ์ค์น
spinner.start(`Installing base dependencies with ${pkgManager}...`);
await execa(pkgManager, ['install'], { cwd: destDir, stdio: 'inherit' });
spinner.succeed('๐ฆ Base dependencies installed!');
// [4] git ์ฒซ ์ปค๋ฐ ์ต์
// await gitInitAndFirstCommit(destDir);
outro(chalk.greenBright('\n๐ Project setup complete!\n'));
console.log(chalk.cyan(` $ cd ${projectName}`));
console.log(chalk.cyan(` $ ${pkgManager} run dev\n`));
// Docker๋ฅผ ์ ํํ ๊ฒฝ์ฐ ์ฌ์ฉ๋ฒ ์๋ด
const hasDocker = devtoolValues.includes('docker');
if (hasDocker) {
const dbType = TEMPLATE_DB[template];
console.log(chalk.yellow('๐ณ Docker setup:'));
console.log(chalk.gray(` $ ${pkgManager} run docker:dev # Start development environment`));
if (dbType) {
console.log(
chalk.gray(` $ ${pkgManager} run db:migrate # Run migrations (if applicable)`),
);
console.log(
chalk.gray(` $ ${pkgManager} run db:seed # Insert sample data (if applicable)`),
);
}
console.log(chalk.gray(` $ ${pkgManager} run docker:down # Stop containers\n`));
}
console.log(chalk.gray('โจ Happy hacking!\n'));
}
main().catch((err) => {
if (err instanceof CLIError) {
printError(err);
} else {
printError(new CLIError('Unexpected error', null, err.message));
}
process.exit(err.code || 1);
});