DEV Community

Cover image for How I Built and Published a Vue 3 Starter Kit CLI
Jerry Hogan
Jerry Hogan

Posted on

How I Built and Published a Vue 3 Starter Kit CLI

A step-by-step breakdown of building create-vue-starter-with-test — from bootstrapping a Vue app to publishing a CLI tool on npm.


Overview

create-vue-starter-with-test is a CLI that scaffolds a fully configured Vue 3 project in seconds:

npx create-vue-starter-with-test my-app
cd my-app
yarn dev
Enter fullscreen mode Exit fullscreen mode

The generated project ships with Vue 3, Vite, Vitest, Vue Router, Pinia, MSW (Mock Service Worker), Sass, ESLint, and Prettier — all wired up and ready to go. This write-up walks through exactly how it was built, PR by PR.


Phase 1 — Bootstrap the Template App

The foundation starts with create-vue, the official Vue scaffolding tool. Run:

npm create vue@latest
Enter fullscreen mode Exit fullscreen mode

This launches an interactive prompt. Here's what was selected:

✔ Project name: vue-starter
✔ Add TypeScript? → Yes
✔ Add JSX Support? → No
✔ Add Vue Router for Single Page Application development? → Yes
✔ Add Pinia for state management? → Yes
✔ Add Vitest for Unit Testing? → Yes
✔ Add an End-to-End Testing Solution? → No
✔ Add ESLint for code quality? → Yes
✔ Add Prettier for code formatting? → Yes
✔ Add Vue DevTools 7 extension for browser debugging? → Yes   (https://devtools.vuejs.org)
Enter fullscreen mode Exit fullscreen mode

Then install dependencies and start the dev server:

cd vue-starter
yarn install
yarn dev
Enter fullscreen mode Exit fullscreen mode

At this point you have a working Vue 3 + Vite app with TypeScript, Vue Router, Pinia, Vitest, ESLint, and Prettier all wired up by the scaffolder. The next step is layering in the extra tooling that create-vue doesn't include by default — MSW, Sass, and the test configuration.

Additional packages installed manually:

yarn add -D msw sass
Enter fullscreen mode Exit fullscreen mode

Stack chosen:

Configuration files set up:

  • vite.config.ts — configures the Vue plugin and @ path alias
  • vitest.config.ts — merges with the Vite config, sets jsdom as the test environment, and supports both __tests__/ and *.test.ts file patterns
  • tsconfig.json, tsconfig.app.json, tsconfig.node.json, tsconfig.vitest.json — TypeScript project references for clean separation between app, node, and test code
  • eslint.config.ts — ESLint with Vue and TypeScript rules
  • .prettierrc.json — Prettier for consistent code formatting
  • .editorconfig — consistent editor settings across IDEs

The MSW Setup

MSW is configured for the Node environment (used in Vitest) via src/mock/serverSetup.ts:

import { setupServer } from 'msw/node';
import handlers from './handlers';

const server = setupServer(...handlers);
export { server };
Enter fullscreen mode Exit fullscreen mode

Handlers are organized by resource. The post handler intercepts GET /posts and returns fixture data:

import { http, HttpResponse } from 'msw';

const postHandler = http.get('https://jsonplaceholder.typicode.com/posts', () => {
  return HttpResponse.json([{ title: 'title a', body: 'body a' }]);
});

export default postHandler;
Enter fullscreen mode Exit fullscreen mode

This means tests never hit the real network — they're fast, deterministic, and don't depend on external services.


Phase 2 — Build a Real Feature (Posts View)

With the skeleton in place, the first real feature was a Posts view: a page that fetches posts from an API, shows a loading state while fetching, renders cards when data arrives, and shows an empty state when there's nothing to display.

PR #1 — Empty state for posts

PostView.vue handles three states using Vue's v-if / v-else-if / v-else directives:

<div v-if="isLoadingPosts" class="loading">Loading...</div>
<div class="cards-wrapper" v-else-if="hasPosts">
  <div class="card" v-for="(post, index) in posts" :key="index">...</div>
</div>
<div class="empty-state" v-else>Oops! Nothing to see here</div>
Enter fullscreen mode Exit fullscreen mode

The hasPosts computed property combines loading and data state cleanly:

const hasPosts = computed(() => !isLoadingPosts.value && posts.value.length);
Enter fullscreen mode Exit fullscreen mode

The test for this view (src/tests/pages/PostView.test.ts) uses MSW to mock the API and @vue/test-utils to mount the component:

beforeAll(() => server.listen());
afterAll(() => server.close());
afterEach(() => server.resetHandlers());

it('should render', async () => {
  const wrapper = shallowMount(PostView);
  expect(wrapper.find('.loading').exists()).toBe(true); // loading state

  await flushPromises(); // wait for fetch + DOM update

  const cards = wrapper.find('.cards-wrapper').findAll('.card');
  expect(cards.length).toBe(1); // one card from mock handler
});
Enter fullscreen mode Exit fullscreen mode

flushPromises() is the key here — it drains all async queues so the component fully resolves before assertions run.


Phase 3 — Prepare for Publishing

PRs #2 and #3 covered documentation and licensing — a README.md with usage instructions and a LICENSE file with MIT terms. These are non-negotiable for an open-source npm package.

PR #4 — The CLI itself

This is where the project transforms from a starter template into a distributable CLI. Three things were added:

1. package.json configured for npm

{
  "name": "create-vue-starter-with-test",
  "version": "1.0.0",
  "private": false,
  "bin": {
    "create-vue-starter-with-test": "dist/index.js"
  },
  "files": ["dist", "template"],
  "type": "module"
}
Enter fullscreen mode Exit fullscreen mode

The bin field tells npm what to run when someone calls npx create-vue-starter-with-test. The files field limits what gets published to just dist/ (compiled CLI) and template/ (the project scaffold).

2. tsconfig.cli.json — separate TypeScript config for the CLI

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode

This compiles only src/index.ts into dist/index.js — completely separate from the template app's TypeScript config.

3. src/index.ts — the CLI script

The CLI is a Node.js script that does four things when run:

  1. Validates input — exits with a helpful message if no project name is given
  2. Copies the template — recursively copies everything from template/ into the target directory
  3. Cleans up — removes any .git folder from the copy
  4. Installs dependencies — runs yarn install in the new project
#!/usr/bin/env node
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import chalk from 'chalk'; // https://github.com/chalk/chalk

function copyDir(src: string, dest: string) {
  fs.mkdirSync(dest, { recursive: true });
  for (const file of fs.readdirSync(src)) {
    const srcPath = path.join(src, file);
    const destPath = path.join(dest, file);
    fs.statSync(srcPath).isDirectory()
      ? copyDir(srcPath, destPath)
      : fs.copyFileSync(srcPath, destPath);
  }
}

async function main() {
  const projectName = process.argv[2];
  if (!projectName) { /* ... */ process.exit(1); }

  const targetDir = path.resolve(process.cwd(), projectName);
  if (fs.existsSync(targetDir)) { /* ... */ process.exit(1); }

  const templateDir = path.resolve(__dirname, '../template');
  copyDir(templateDir, targetDir);
  execSync(`cd ${projectName} && yarn install`, { stdio: 'inherit' });
}

main();
Enter fullscreen mode Exit fullscreen mode

The __dirname trick (via fileURLToPath) resolves the template path relative to the compiled CLI file — this is important because when the package is installed globally via npx, process.cwd() is the user's directory, not the package directory.

PR #5 — .npmignore

Without this file, npm would publish everything including node_modules, source files, and dev config. .npmignore explicitly excludes everything except what's already listed in files in package.json. It's redundant but acts as a safety net.

PR #6 — Move template into template/

This was the structural pivot. Up to this point the Vue app and the CLI tool lived in the same flat structure. PR #6 moved all Vue app files into template/:

Before:                     After:
src/                        src/
  index.ts                    index.ts  ← CLI only
  App.vue                   template/
  router/                     App.vue
  views/                      router/
  ...                         views/
                              ...
Enter fullscreen mode Exit fullscreen mode

The CLI's copyDir call was updated to point at ../template relative to the compiled output in dist/.


Publishing to npm (and making npx work)

Once the CLI and template are in place, publishing follows three steps:

1. Build the CLI

The CLI is written in TypeScript, so it must be compiled before publishing. The build script in package.json runs tsc using the CLI-specific tsconfig:

yarn build
# compiles src/index.ts → dist/index.js
Enter fullscreen mode Exit fullscreen mode

Always run this before publishing. The dist/ folder is what actually ships — if you publish without building, users get nothing.

2. Log in to npm and publish

See the npm publish docs for full options.

npm login        # one-time: authenticates with your npm account
npm publish      # publishes the package
Enter fullscreen mode Exit fullscreen mode

npm reads package.json to determine the package name (create-vue-starter-with-test), version, and what files to include (dist/ and template/ via the files field). Because private is set to false, npm allows the publish.

For subsequent releases, bump the version first:

npm version patch   # 1.0.6 → 1.0.7
yarn build
npm publish
Enter fullscreen mode Exit fullscreen mode

3. How npx resolves it

When a user runs:

npx create-vue-starter-with-test my-app
Enter fullscreen mode Exit fullscreen mode

npx looks up the package name on the npm registry, downloads it temporarily (or uses a cached version), then reads the bin field:

"bin": {
  "create-vue-starter-with-test": "dist/index.js"
}
Enter fullscreen mode Exit fullscreen mode

It executes dist/index.js with Node, passing my-app as process.argv[2]. The #!/usr/bin/env node shebang at the top of the file tells the OS to use Node to run it.

The naming convention create-* is intentional — it follows the same pattern as create-react-app, create-vite, and create-vue, which makes the package discoverable and the command feel natural.


Phase 4 — Component Architecture

PR #7 — Atomic Design folder structure

The final major feature was introducing an atomic design component hierarchy. Instead of dumping all components in one flat folder, they're organized by complexity:

src/components/
  atoms/          ← smallest building blocks
    BaseButton.vue
    BaseInput.vue
    icons/
  molecules/      ← groups of atoms
    FormField.vue
  organisms/      ← complex UI sections
    Card.vue
  templates/      ← page-level layout wrappers
    PageTemplate.vue
Enter fullscreen mode Exit fullscreen mode

This gives any team using the starter kit a clear mental model for where new components go and how they compose. A FormField (molecule) is built from a BaseInput (atom). A Card (organism) uses the styling primitives but operates at a higher abstraction level. PageTemplate defines layout structure independently of content.


How It All Fits Together

The repository has two distinct roles:

Layer Path Purpose
CLI tool src/index.ts → dist/index.js What runs when users call npx create-vue-starter-with-test
Template app template/ The Vue project that gets copied into the user's directory

When you run npm publish, npm packages dist/ and template/ per the files field. When a user runs npx create-vue-starter-with-test my-app, npm downloads the package and executes dist/index.js, which copies template/ to ./my-app and installs dependencies.


Phase 5 — GitHub Actions (CI)

PR #8 — Workflow setup

The final piece is automating quality checks on every push and pull request. A GitHub Actions workflow ensures the CLI builds and tests pass before anything merges.

The workflow lives at .github/workflows/ and runs on push to main and on pull requests:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: yarn install
      - run: yarn build       # compile the CLI
      - run: yarn test:run    # run Vitest once (no watch mode)
Enter fullscreen mode Exit fullscreen mode

This gives you a green/red signal on every PR — if the CLI fails to compile or a test breaks, the merge is blocked. It also means contributors can't accidentally ship a broken template.


Key Takeaways

  1. Separate concerns early. The CLI and the template app are different things. Keeping them in separate directories (src/ vs template/) avoids confusion and keeps the published package clean.

  2. Use __dirname relative paths in CLI tools. process.cwd() is the user's working directory. Template paths must be resolved relative to the installed package location.

  3. Test with MSW, not mocks. MSW intercepts at the network level, so tests exercise real fetch calls without hitting the network. This is closer to production behavior than vi.mock().

  4. vitest.config.ts should extend vite.config.ts. Using mergeConfig keeps the two in sync — especially for path aliases (@), which are needed in both build and test contexts.

  5. Atomic design scales. Starting the template with atoms/molecules/organisms/templates gives consumers a naming convention and mental model from day one, rather than a components/ folder that grows chaotically.

  6. .npmignore + files in package.json. Use both. files is the whitelist of what to include; .npmignore is the blacklist. Together they ensure the published package is lean.

Top comments (0)