Skip to main content

Command Palette

Search for a command to run...

Stop Copy-Pasting! How to Build a Custom CLI for Your Turborepo Starter

Published
5 min read

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.

  1. The Factory: A new package in our monorepo that will contain our CLI logic.

  2. The Blueprint: A template directory that holds a clean copy of our starter project.

  3. 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 tells pnpm and npm that this package is meant to be published.

  • "bin": This is the magic field. It tells npx which file to execute when someone runs npx create-vuchint-repo.

  • "type": "module": We're living in the future! This lets us use modern ES Module syntax like import/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-in fs module 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:

  1. It asks the user for a project name or takes it from the command line.

  2. It creates the new project directory.

  3. It copies everything from our template directory into the new directory.

  4. It renames gitignore back to .gitignore (npm won't publish a file named .gitignore, so this is a common workaround).

  5. It updates the name in the new project's package.json.

  6. It prints out helpful next steps.

Part 3: The Blueprint (The Template Directory)

Our CLI needs something to copy. This is our "blueprint."

  1. Create a template directory inside packages/create-vuchint-repo.

  2. Copy your entire monorepo project into this template directory.

  3. Clean it up! This is the most important step. Delete the following from inside the template directory:

    • All node_modules folders.

    • All .turbo, dist, and .next folders.

    • The packages/create-vuchint-repo folder itself (we don't want the factory inside the product!).

  4. Rename template/.gitignore to template/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?