Stop Copy-Pasting! How to Build a Custom CLI for Your Turborepo Starter
You’ve done it. You’ve perfected your project starter: the perfect folder structure, the ideal linter setup, the exact build scripts. It’s a work of art. But every time you start a new project, you find yourself in the same tedious loop: git clone, rm -rf .git, rename a bunch of files, and pray you didn't forget a step.
There has to be a better way. And there is.
What if you could just run npx create-my-awesome-repo@latest and have a fresh copy of your masterpiece, ready to go in seconds? Today, we're going to turn that "what if" into a reality. We'll build a custom CLI, right inside your existing Turborepo, that can scaffold your starter project for anyone, anywhere.
The Game Plan
We're going to build a "factory" for our projects.
The Factory: A new package in our monorepo that will contain our CLI logic.
The Blueprint: A
templatedirectory that holds a clean copy of our starter project.The Assembly Line: A Node.js script that prompts the user for a project name, copies the blueprint, and personalizes it.
Let's get our hands dirty!
Part 1: Building the Factory (The CLI Package)
First, we need a place to build our CLI. Inside your monorepo's packages directory, create a new folder. We'll call ours create-vuchint-repo.
Inside this new package, the most important file is package.json. This isn't just any package.json; it's the heart of our CLI.
packages/create-vuchint-repo/package.json:
{
"name": "create-vuchint-repo",
"version": "1.0.0",
"private": false,
"description": "Create a new monorepo based on vuchint's starter",
"bin": {
"create-vuchint-repo": "./dist/index.js"
},
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsc -w"
},
"dependencies": {
"fs-extra": "^11.2.0",
"kolorist": "^1.8.0",
"prompts": "^2.4.2"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^20.11.24",
"@types/prompts": "^2.4.9",
"@repo/typescript-config": "workspace:*",
"typescript": "^5.3.3"
},
"publishConfig": {
"access": "public"
}
}
Let's break down the key parts:
"private": false: This is essential! It tellspnpmandnpmthat this package is meant to be published."bin": This is the magic field. It tellsnpxwhich file to execute when someone runsnpx create-vuchint-repo."type": "module": We're living in the future! This lets us use modern ES Module syntax likeimport/export.Dependencies: We're pulling in a few helpers:
prompts: For asking the user for their project name.kolorist: To add some snazzy colors to our console output.fs-extra: A supercharged version of Node's built-infsmodule that makes file operations a breeze.
Part 2: The Assembly Line (The Scaffolding Script)
Now for the fun part: the logic. We'll create an index.ts file inside a src directory. This script will be our assembly line, taking the blueprint and turning it into a finished product.
packages/create-vuchint-repo/src/index.ts:
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs-extra';
import prompts from 'prompts';
import { red, green } from 'kolorist';
// ES Module-friendly way to get __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function main() {
// 1. Get the project name
const argvTargetDir = process.argv[2]?.trim();
const defaultProjectName = 'my-vuchint-repo';
let result: prompts.Answers<'projectName'>;
try {
result = await prompts(/* ... */); // Abridged for blog
} catch (cancelled: any) {
console.log(cancelled.message);
return;
}
const targetDir = argvTargetDir || result.projectName?.trim() || defaultProjectName;
const root = path.resolve(process.cwd(), targetDir);
// 2. Check if the directory already exists
if (fs.existsSync(root)) {
console.error(red(`✖ Directory ${targetDir} already exists.`));
process.exit(1);
}
console.log(`\nScaffolding project in ${root}...`);
// 3. Copy the blueprint
const templateDir = path.resolve(__dirname, '..', 'template');
await fs.copy(templateDir, root);
// 4. The .gitignore trick
if (fs.existsSync(path.join(root, 'gitignore'))) {
fs.renameSync(path.join(root, 'gitignore'), path.join(root, '.gitignore'));
}
// 5. Personalize the project
const pkgJsonPath = path.join(root, 'package.json');
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
pkgJson.name = result.projectName || targetDir;
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
// 6. Give the user their next steps
console.log(`\n${green('✔')} Done. Now run:\n`);
console.log(` cd ${targetDir}`);
console.log(` pnpm install`);
console.log(` pnpm dev`);
console.log();
}
main().catch((e) => {
console.error(e);
});
This script is surprisingly simple:
It asks the user for a project name or takes it from the command line.
It creates the new project directory.
It copies everything from our
templatedirectory into the new directory.It renames
gitignoreback to.gitignore(npm won't publish a file named.gitignore, so this is a common workaround).It updates the
namein the new project'spackage.json.It prints out helpful next steps.
Part 3: The Blueprint (The Template Directory)
Our CLI needs something to copy. This is our "blueprint."
Create a
templatedirectory insidepackages/create-vuchint-repo.Copy your entire monorepo project into this
templatedirectory.Clean it up! This is the most important step. Delete the following from inside the
templatedirectory:All
node_modulesfolders.All
.turbo,dist, and.nextfolders.The
packages/create-vuchint-repofolder itself (we don't want the factory inside the product!).
Rename
template/.gitignoretotemplate/gitignore.
Your template directory is now a pristine, ready-to-copy version of your starter.
Part 4: Test, Publish, and Launch!
We're ready for liftoff.
1. Local Testing
Before we show our creation to the world, let's test it locally.
# From your project root, install the new package's dependencies
pnpm install
# Build the CLI script
pnpm --filter create-vuchint-repo build
# Link your CLI to make it globally available on your machine
pnpm link --global ./packages/create-vuchint-repo
Now, cd into a completely different folder and run your command!
# Go to a test directory
cd ~/Desktop/test-zone
# Run the command!
create-vuchint-repo my-awesome-new-project
If all goes well, you'll see a new folder with your entire monorepo structure, ready to go.
2. Going Public
It works! Time to publish it to npm.
# First, make sure all your work is saved to Git
git add .
git commit -m "feat: create my awesome CLI"
# Log in to your npm account
npm login
# Navigate into your CLI package and hit the big red button
cd packages/create-vuchint-repo
pnpm publish --access public
You Did It!
Congratulations! You are now the proud author of an npx command. You've not only made your own life easier but have created a tool that you can share with your team or the entire world.
Go ahead, open a new terminal and give it a try:
npx create-vuchint-repo@latest my-next-big-thing
It feels good, doesn't it?