Skip to content

Instantly share code, notes, and snippets.

@ltomaszewski
Created July 9, 2025 06:36
Show Gist options
  • Save ltomaszewski/8156251a3a19d5edbda5bb149d71b930 to your computer and use it in GitHub Desktop.
Save ltomaszewski/8156251a3a19d5edbda5bb149d71b930 to your computer and use it in GitHub Desktop.
This script bootstraps a complete, production-ready monorepo for Node.js and TypeScript projects using NPM workspaces. It automates the entire setup process, creating a scalable and maintainable project structure with consistent tooling and configurations from the very start.
// Filename: init-monorepo.js
// Description: A project-agnostic script to bootstrap a complete Node.js/TypeScript monorepo.
// Usage: node init-monorepo.js
const fs = require('fs');
const path =require('path');
const { execSync } = require('child_process');
const readline = require('readline');
// --- Helper Functions ---
function createFile(filePath, content) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, content.trim());
console.log(`✅ Created ${filePath}`);
}
function toPascalCase(str) {
return str.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join('');
}
// --- File Content Templates ---
const fileTemplates = {
gitignore: `
# ## General Node.js ##
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
pnpm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Dependency directories
node_modules/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Output of 'npm pack'
*.tgz
# dotenv environment variables file
.env
.env.test
.env.production
.env.local
.env.*.local
# Mac files
.DS_Store
# ## TypeScript & Build Output ##
/packages/*/dist
/apps/*/dist
*.tsbuildinfo
# ## Project Specific ##
.idea/
.vscode/
`,
rootPackageJson: (projectName) => JSON.stringify({
name: projectName,
private: true,
version: "1.0.0",
description: "A modular monorepo.",
main: "index.js",
scripts: {
"create:package": `node scripts/create-package.js`,
"create:app": `node scripts/create-package.js --app`,
"build": "npx tsc --build",
"test": "npm run test --workspaces",
"clean": "npm run clean --workspaces"
},
keywords: [],
author: "",
license: "ISC",
workspaces: [
"packages/*",
"apps/*"
]
}, null, 2),
jestConfig: `
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
// Use ts-jest as a preset
preset: 'ts-jest',
// Set the test environment to Node.js
testEnvironment: 'node',
// Ignore the compiled 'dist' folder when running tests
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
};
`,
tsconfigBase: `
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"sourceMap": true,
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noEmitOnError": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true
}
}
`,
rootTsconfig: `
{
"files": [],
"references": []
}
`,
createPackageScript: (scopeName) => `
const fs = require('fs');
const path = require('path');
const scope = '@${scopeName}';
const args = process.argv.slice(2);
const isApp = args.includes('--app');
const packageName = args.find(arg => !arg.startsWith('--'));
if (!packageName) {
console.error('Error: Please provide a package name.');
console.log('Usage: node scripts/create-package.js [--app] <package-name>');
process.exit(1);
}
const baseDir = isApp ? 'apps' : 'packages';
const templatesDir = path.join(__dirname, 'templates');
function toPascalCase(str) {
return str.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join('');
}
const className = toPascalCase(packageName);
const fullPackageName = \`\${scope}/\${packageName}\`;
const packagePath = path.join(baseDir, packageName);
const srcPath = path.join(packagePath, 'src');
const testsPath = path.join(srcPath, '__tests__');
console.log(\`Creating \${isApp ? 'app' : 'package'}: \${fullPackageName} at \${packagePath}\`);
if (fs.existsSync(packagePath)) {
console.error(\`Error: Directory already exists at \${packagePath}\`);
process.exit(1);
}
fs.mkdirSync(testsPath, { recursive: true });
function createFileFromTemplate(templateName, destinationPath, replacements = {}) {
const templatePath = path.join(templatesDir, templateName);
let content = fs.readFileSync(templatePath, 'utf8');
for (const key in replacements) {
// Using string concatenation to build the RegExp to avoid nested template literal issues.
content = content.replace(new RegExp('{{' + key + '}}', 'g'), replacements[key]);
}
fs.writeFileSync(destinationPath, content);
}
const packageJsonTemplate = JSON.parse(fs.readFileSync(path.join(templatesDir, 'package.json.template'), 'utf8'));
const finalPackageJson = { name: fullPackageName, ...packageJsonTemplate };
fs.writeFileSync(path.join(packagePath, 'package.json'), JSON.stringify(finalPackageJson, null, 2));
createFileFromTemplate('tsconfig.json.template', path.join(packagePath, 'tsconfig.json'));
createFileFromTemplate('jest.config.js.template', path.join(packagePath, 'jest.config.js'));
createFileFromTemplate('index.ts.template', path.join(srcPath, 'index.ts'), { className });
createFileFromTemplate('index.test.ts.template', path.join(testsPath, 'index.test.ts'), { className });
const rootTsconfigPath = path.join(__dirname, '..', 'tsconfig.json');
try {
const rootTsconfigContent = fs.readFileSync(rootTsconfigPath, 'utf8');
const rootTsconfigJson = JSON.parse(rootTsconfigContent);
const newReference = { path: \`\${baseDir}/\${packageName}\` };
if (!rootTsconfigJson.references.some(ref => ref.path === newReference.path)) {
rootTsconfigJson.references.push(newReference);
rootTsconfigJson.references.sort((a, b) => a.path.localeCompare(b.path));
}
fs.writeFileSync(rootTsconfigPath, JSON.stringify(rootTsconfigJson, null, 2) + '\\n');
console.log('✅ Automatically updated root tsconfig.json');
} catch (error) {
console.error('⚠️ Could not update root tsconfig.json. Please add the reference manually.', error);
}
console.log(\`✅ Successfully created \${fullPackageName}.\`);
console.log('Next steps:');
console.log('Run \`npm install\` to link the new package.');
`,
scaffold_packageJson: `
{
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc --build",
"test": "tsc --build && jest",
"clean": "rm -rf dist tsconfig.tsbuildinfo"
},
"dependencies": {},
"description": "",
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}
`,
scaffold_tsconfig: `
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": []
}
`,
scaffold_jestConfig: `
// Import the root configuration
const rootConfig = require("../../jest.config.js");
module.exports = {
// Extend the root configuration
...rootConfig,
};
`,
scaffold_indexTs: `
/**
* A utility class for {{className}}.
*/
export class {{className}} {
/**
* Returns a greeting string.
* @returns A string that says "Hello from {{className}}!".
*/
public static getGreeting(): string {
return "Hello from {{className}}!";
}
}
`,
scaffold_indexTestTs: `
import { {{className}} } from "../index";
describe("{{className}}", () => {
it("should return a greeting", () => {
expect({{className}}.getGreeting()).toBe("Hello from {{className}}!");
});
});
`
};
// --- Main Execution ---
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('Enter the project name (e.g., my-new-project): ', (projectName) => {
rl.question('Enter the NPM scope name (e.g., my-org): ', (scopeName) => {
console.log(`\n🚀 Initializing monorepo "${projectName}" with scope "@${scopeName}"...`);
// Create root project directory
if (fs.existsSync(projectName)) {
console.error(`Error: Directory "${projectName}" already exists.`);
rl.close();
return;
}
fs.mkdirSync(projectName);
process.chdir(projectName);
// Create core config files from templates
createFile('.gitignore', fileTemplates.gitignore);
createFile('package.json', fileTemplates.rootPackageJson(projectName));
createFile('jest.config.js', fileTemplates.jestConfig);
createFile('tsconfig.base.json', fileTemplates.tsconfigBase);
createFile('tsconfig.json', fileTemplates.rootTsconfig);
// Create scaffolding script and its templates
const scriptsDir = 'scripts';
const templatesDir = path.join(scriptsDir, 'templates');
fs.mkdirSync(templatesDir, { recursive: true });
createFile(path.join(scriptsDir, 'create-package.js'), fileTemplates.createPackageScript(scopeName));
createFile(path.join(templatesDir, 'package.json.template'), fileTemplates.scaffold_packageJson);
createFile(path.join(templatesDir, 'tsconfig.json.template'), fileTemplates.scaffold_tsconfig);
createFile(path.join(templatesDir, 'jest.config.js.template'), fileTemplates.scaffold_jestConfig);
createFile(path.join(templatesDir, 'index.ts.template'), fileTemplates.scaffold_indexTs);
createFile(path.join(templatesDir, 'index.test.ts.template'), fileTemplates.scaffold_indexTestTs);
// Install root dependencies
console.log('\n📦 Installing root dependencies...');
try {
execSync('npm install typescript ts-node jest ts-jest @types/node @types/jest --save-dev', { stdio: 'inherit' });
} catch (error) {
console.error('\n❌ Failed to install root dependencies.');
process.exit(1);
}
console.log('\n🎉 Monorepo setup complete!');
console.log(`\nNavigate to your new project: \`cd ${projectName}\``);
console.log('To create your first package, run: `npm run create:package -- my-first-package`');
rl.close();
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment