
Npm Package
- 310 installs
- 133 repo stars
- Updated February 24, 2026
- jwynia/agent-skills
npm-package is an agent skill that scaffolds, configures, tests, versions, and publishes npm packages using Bun, strict TypeScript, Vitest, Biome, Bunup, and Changesets for developers shipping libraries.
About
npm-package is an agent skill from jwynia/agent-skills that teaches a Bun-first workflow for creating and publishing npm-compatible TypeScript libraries. The toolchain pairs Bun as runtime and package manager, Bunup for ESM and CJS bundle generation with declaration files, strict TypeScript with module nodenext, Biome v2 plus ESLint for linting, Vitest for unit tests, and Changesets for versioning and changelog management. Developers reach for npm-package when scaffolding a new library, fixing CJS and ESM interop or package.exports map issues, configuring conditional exports and .d.ts generation, or preparing CI-ready npm publish pipelines. The skill documents when to create packages from scratch versus migrating existing code, resolving default and named export compatibility across module systems, and reviewing package.json fields before registry release. Keywords include npm, bun, bunup, esm, cjs, vitest, biome, and changesets.
- package.json setup
- Entry points
- Build config
- Semantic versioning
- npm publish
Npm Package by the numbers
- 310 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #63 of 248 Release Management skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jwynia/agent-skills --skill npm-packageAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 310 |
|---|---|
| repo stars | ★ 133 |
| Last updated | February 24, 2026 |
| Repository | jwynia/agent-skills ↗ |
How do you publish a TypeScript npm package with Bun?
Scaffold, configure, test, version, and publish npm packages with correct package.json fields, entry points, build steps, and registry publishing workflows.
Who is it for?
TypeScript developers creating npm libraries who want a Bun-first scaffold with strict typing, dual ESM/CJS output, and automated versioning.
Skip if: Teams publishing Python wheels, Rust crates, or monorepo apps without a standalone npm library package boundary.
When should I use this skill?
The user wants to create a new npm library, fix CJS/ESM interop, configure package.exports, set up Vitest and Biome for a package, or publish to the npm registry.
What you get
package.json with exports map, ESM and CJS build artifacts, Vitest test suite, lint configuration, and Changesets release workflow ready for npm publish.
- package.json exports map
- bundled ESM/CJS artifacts
- Changesets release configuration
By the numbers
- Toolchain spans 5 tools: Bun, Bunup, Vitest, Biome, and Changesets
- Targets strict TypeScript with module nodenext configuration
- Covers dual ESM and CJS output with declaration file generation
Files
npm Package Development (Bun-First)
Build and publish npm packages using Bun as the primary runtime and toolchain, producing output that works everywhere npm packages are consumed.
When to Use This Skill
Use when:
- Creating a new npm library package from scratch
- Setting up build/test/lint tooling for an existing package
- Fixing CJS/ESM interop, exports map, or TypeScript declaration issues
- Publishing a package to npm
- Reviewing or improving package configuration
Do NOT use when:
- Building an npx-executable CLI tool (use the
npx-cliskill) - Building an application (not a published package)
- Working in a monorepo (this skill targets single-package repos)
Toolchain
| Concern | Tool | Why |
|---|---|---|
| Runtime / package manager | Bun | Fast install, run, transpile |
| Bundler | Bunup | Bun-native, dual output, .d.ts generation |
| Type declarations | Bunup (via tsc) | Integrated with build |
| TypeScript | module: "nodenext", strict: true + extras | Maximum correctness for published code |
| Formatting + basic linting | Biome v2 | 10-25x faster than ESLint, single tool |
| Type-aware linting | ESLint + typescript-eslint | 40+ type-aware rules Biome can't do |
| Testing | Vitest | Test isolation, mature mocking, coverage |
| Versioning | Changesets | File-based, explicit, monorepo-ready |
| Publishing | npm publish --provenance | Trusted Publishing / OIDC |
Scaffolding a New Package
Run the scaffold script to generate a complete project:
bun run <skill-path>/scripts/scaffold.ts ./my-package \
--name my-package \
--description "What this package does" \
--author "Your Name" \
--license MITOptions:
--dual— Generate dual CJS/ESM output (default: ESM-only)--no-eslint— Skip ESLint, use Biome only
Then install dependencies:
cd my-package
bun install
bun add -d bunup typescript vitest @vitest/coverage-v8 @biomejs/biome @changesets/cli
bun add -d eslint typescript-eslint # unless --no-eslintProject Structure
my-package/
├── src/
│ ├── index.ts # Package entry point — all public API exports here
│ └── index.test.ts # Tests co-located with source
├── dist/ # Built output (gitignored, included in published tarball)
├── .changeset/
│ └── config.json
├── package.json
├── tsconfig.json
├── bunup.config.ts
├── biome.json
├── eslint.config.ts # Type-aware rules only
├── vitest.config.ts
├── .gitignore
├── README.md
└── LICENSECritical Configuration Details
Read these reference docs before modifying any configuration. They contain the reasoning behind each decision and the sharp edges that cause subtle breakage:
- [reference/esm-cjs-guide.md](./reference/esm-cjs-guide.md) —
exportsmap configuration, dual package hazard,module-sync, common mistakes - [reference/strict-typescript.md](./reference/strict-typescript.md) — tsconfig rationale, Biome rules, ESLint type-aware rules, Vitest config
- [reference/publishing-workflow.md](./reference/publishing-workflow.md) — Changesets,
filesfield, Trusted Publishing, CI pipeline
Key Rules (Non-Negotiable)
These are the rules that, when violated, cause the most common and painful bugs in published packages. Follow these without exception.
Package Configuration
1. Always use `"type": "module"` in package.json. ESM-only is the correct default. require(esm) works in all supported Node.js versions.
2. Always use `exports` field, not `main`. main is legacy. exports gives precise control over what consumers can access.
3. `types` must be the first condition in every exports block. TypeScript silently fails to resolve types if it isn't.
4. Always export `"./package.json": "./package.json"`. Many tools need access to the package.json and exports encapsulates completely.
5. Use `files: ["dist"]` in package.json. Whitelist approach prevents shipping secrets. Never use .npmignore.
6. Run `npm pack --dry-run` before every publish. Verify the tarball contains exactly what you intend.
TypeScript
7. Use `module: "nodenext"` for published packages. Not "bundler". Code satisfying nodenext works everywhere; the reverse is not true.
8. `strict: true` is non-negotiable. Without it, your .d.ts files can contain types that error for consumers using strict mode.
9. Enable `noUncheckedIndexedAccess`. Catches real runtime bugs from unguarded array/object access.
10. Ship `declarationMap: true`. Enables "Go to Definition" to reach original source for consumers.
11. Do not use path aliases (`paths`) in published packages. tsc does not rewrite them in emitted code. Consumers can't resolve them.
Code Quality
12. `any` is banned. Use unknown and narrow. Suppress with // biome-ignore suspicious/noExplicitAny: <reason> only when genuinely unavoidable, and always include the reason.
13. Prefer named exports over default exports. Default exports behave differently across CJS/ESM boundaries.
14. Always use `import type` for type-only imports. Enforced by both verbatimModuleSyntax and Biome's useImportType rule.
Build
15. Build with Bunup using format: ['esm'] (or ['esm', 'cjs'] for dual). Bunup handles .d.ts generation, external detection, and correct file extensions.
16. Set `engines.node` to `>=20.19.0` in package.json. This documents the minimum supported Node.js version (first LTS with stable require(esm)).
Testing
17. Use Vitest, not bun:test. bun:test lacks test isolation — module mocks leak between files. Vitest runs each test file in its own worker.
18. Set coverage thresholds (branches, functions, lines, statements all ≥ 80%). Enforced in vitest.config.ts.
Development Workflow
# Write code and tests
bun run test:watch # Vitest watch mode
# Check everything
bun run lint # Biome + ESLint
bun run typecheck # tsc --noEmit
bun run test # Vitest run
# Build
bun run build # Bunup → dist/
# Prepare release
bunx changeset # Create changeset describing changes
bunx changeset version # Bump version, update CHANGELOG
# Publish
bun run release # Build + npm publish --provenanceAdding Subpath Exports
When the package needs to expose multiple entry points:
1. Add the source file: src/utils.ts 2. Add to bunup.config.ts entry: entry: ['src/index.ts', 'src/utils.ts'] 3. Add to package.json exports:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./utils": {
"types": "./dist/utils.d.ts",
"default": "./dist/utils.js"
},
"./package.json": "./package.json"
}
}Reminder: Adding or removing export paths is a semver-major change.
Switching to Dual CJS/ESM Output
If consumers require CJS support for Node.js < 20.19.0:
1. Update bunup.config.ts: format: ['esm', 'cjs'] 2. Update package.json exports to include module-sync, import, and require conditions 3. See reference/esm-cjs-guide.md for the exact exports map structure
Bun-Specific Gotchas
- `bun build` does not generate .d.ts files. Use Bunup (which delegates to tsc) or run
tsc --emitDeclarationOnlyseparately. - `bun build` CJS output is experimental. Always use
target: "node"for npm-publishable CJS.target: "bun"produces Bun-specific wrappers. - `bun build` does not downlevel syntax. Modern ES2022+ syntax ships as-is. If targeting older runtimes, additional transpilation is needed.
- `bun publish` does not support `--provenance`. Use
npm publishfor provenance signing. - `bun publish` uses `NPM_CONFIG_TOKEN`, not
NODE_AUTH_TOKEN. CI pipelines may need adjustment.
ESM/CJS Interoperability Guide
The Current State (2026)
ESM-only is now the correct default for new packages. require(esm) is stable and unflagged in all supported Node.js LTS versions (v20.19.0+, v22.12.0+). Node.js 18 reached EOL in April 2025.
Default to ESM-only (`"type": "module"`) unless the package has a specific, documented need to support Node.js < 20.19.0.
Package.json exports Map
ESM-Only (Preferred)
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": ["dist"]
}Dual CJS/ESM (When Required)
Use the module-sync condition (Node.js 22.10+, backported to 20.19.0) to serve ESM to both import and require() consumers, eliminating the dual package hazard:
{
"type": "module",
"exports": {
".": {
"module-sync": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./package.json": "./package.json"
}
}Critical Rules
types Must Be First
Within each condition block, types must appear before default or any other condition. TypeScript silently fails to resolve types otherwise.
// CORRECT
{ "types": "./dist/index.d.ts", "default": "./dist/index.js" }
// BROKEN — TypeScript won't find types
{ "default": "./dist/index.js", "types": "./dist/index.d.ts" }exports Encapsulates Completely
Once you add an exports field, all non-exported paths become inaccessible to consumers. Always explicitly export "./package.json": "./package.json" — many tools need it.
Condition Order Matters
default must always be last. Custom conditions go before import/require. Wrong order silently serves the wrong file to some consumers.
Prefer Named Exports
Default exports behave differently between CJS and ESM. A CJS consumer doing const pkg = require('your-pkg') gets the module namespace, not the default export, unless they use pkg.default. Named exports avoid this entirely.
// PREFER
export function doThing(): void { /* ... */ }
export const CONFIG = { /* ... */ };
// AVOID as primary API surface
export default class MyThing { /* ... */ }Changes to exports Are Semver-Major
Adding, removing, or restructuring export paths will break some consumer in some environment. Treat exports map changes as breaking.
The Dual Package Hazard
When Node.js loads both a CJS and ESM copy of the same package (e.g., one dependency imports it and another require()s it), the package initializes twice. This causes:
- Duplicate state (singletons aren't singletons)
instanceofchecks fail across module boundaries- Side effects run twice
Solutions (in order of preference): 1. Ship ESM-only — no hazard possible 2. Use module-sync condition — both import and require resolve to the same ESM file 3. Use a stateless API design where duplicate instantiation doesn't matter
Subpath Exports
For packages that expose multiple entry points:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./utils": {
"types": "./dist/utils.d.ts",
"default": "./dist/utils.js"
},
"./package.json": "./package.json"
}
}Each subpath needs its own types + default pair. The types condition must be first in every subpath.
Common Mistakes
1. Using `main` instead of `exports`: main is legacy. Use exports for all new packages. Only add main as a fallback for very old tooling.
2. Forgetting `"type": "module"`: Without this, .js files are treated as CJS by Node.js, even if they contain ESM syntax.
3. Using `.mjs`/`.cjs` extensions when unnecessary: With "type": "module", .js is ESM. Only use .cjs for the rare CJS file in an ESM package. Avoid .mjs in new packages — it causes issues with some tooling.
4. Path aliases in published code: tsc does not rewrite path aliases ("@lib/utils") in emitted JS or .d.ts files. Consumers can't resolve them. Use Node.js subpath imports ("#imports" in package.json) instead, or keep directory structures flat.
Publishing Workflow
Versioning: Changesets
`standard-version` is deprecated. Use Changesets for new projects.
Changesets uses a file-based approach: each PR includes a changeset file describing what changed and whether it's a patch, minor, or major bump. At release time, changesets are consumed to bump versions and generate changelogs.
Setup
bun add -d @changesets/cli
bunx changeset initThis creates a .changeset/ directory with a config.json:
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}Workflow
1. During development: Run bunx changeset to create a changeset file describing the change 2. At release time: Run bunx changeset version to consume changesets, bump package.json version, update CHANGELOG.md 3. Publish: Run the build + publish pipeline
Package.json Scripts
{
"scripts": {
"changeset": "changeset",
"version": "changeset version",
"release": "bun run build && npm publish"
}
}Pre-Publish Checklist
Use files Field, Never .npmignore
The files field is a whitelist — only listed paths are included in the published tarball. This prevents accidentally shipping secrets, .env files, test fixtures, or source code.
{
"files": ["dist"]
}.npmignore is a blacklist that replaces .gitignore (they are not merged). This is a common source of credential leaks — if .gitignore blocks .env but .npmignore doesn't, your secrets ship to npm.
Verify Before Publishing
Always dry-run before publishing:
npm pack --dry-runThis shows exactly what will be in the tarball. Review the file list. If anything unexpected appears, fix files in package.json.
Use prepublishOnly for Build + Test
{
"scripts": {
"prepublishOnly": "bun run lint && bun run test && bun run build"
}
}Never use the legacy prepublish hook — it runs on both npm publish AND npm install (in npm v7+), which is almost never what you want.
Publishing
npm publish with Provenance (Recommended)
npm publish --provenance --access publicProvenance signing creates a cryptographic attestation linking the published package to its source code and build process. This is the gold standard for supply chain security.
Note: bun publish exists and works, but does NOT support --provenance. Use npm publish for provenance signing.
npm Trusted Publishing (OIDC)
npm Trusted Publishing eliminates long-lived npm tokens. Configure it on npmjs.com by linking your GitHub repository to your npm package. Then in GitHub Actions:
name: Release
on:
push:
tags: ['v*']
permissions:
contents: read
id-token: write # Required for OIDC
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run build
- uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- run: npm publish --provenance --access publicNo NODE_AUTH_TOKEN needed — the OIDC token is obtained automatically.
bun publish (When Provenance Not Required)
bun publish --access publicBun publish handles workspace: protocol stripping, respects .npmrc, supports --dry-run and --tag. Use NPM_CONFIG_TOKEN (not NODE_AUTH_TOKEN) for authentication in CI.
GitHub Actions: Full Release Pipeline
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run typecheck
- run: bun run test
- run: bun run buildVersion Tagging Convention
After changeset version bumps the version:
git add .
git commit -m "chore: release v$(node -p "require('./package.json').version")"
git tag "v$(node -p "require('./package.json').version")"
git push --follow-tagsOr automate this with a release script.
Access Control
For scoped packages (@scope/package-name), the first publish requires --access public (scoped packages default to restricted). Subsequent publishes inherit the access level.
{
"publishConfig": {
"access": "public"
}
}Adding publishConfig.access to package.json avoids needing --access public on every publish.
Strict TypeScript & Linting Configuration
TypeScript: nodenext for Libraries
Use module: "nodenext" for all published packages. Not "bundler".
Why: "bundler" allows extensionless imports (import { foo } from "./utils") that work in bundlers but crash in Node.js with ERR_MODULE_NOT_FOUND. Code satisfying nodenext constraints works everywhere; the reverse is not true.
Recommended tsconfig.json
{
"compilerOptions": {
// Module system
"module": "nodenext",
"moduleResolution": "nodenext",
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"isolatedModules": true,
// Output
"target": "es2022",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
// Strict type safety — ALL of these are non-negotiable for published packages
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
// Build optimization
"skipLibCheck": true,
// Interop
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}Key Settings Explained
`strict: true` enables noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, alwaysStrict, useUnknownInCatchVariables. This is non-negotiable for published packages — without it, your .d.ts files can contain types that error when consumers compile with strict mode.
`noUncheckedIndexedAccess: true` adds | undefined to all index signature access. Not part of strict, but catches real runtime bugs where array/object access might be undefined.
`exactOptionalPropertyTypes: true` distinguishes between { x?: string } (property may be absent) and { x: string | undefined } (property present but undefined). Catches subtle API design bugs.
`verbatimModuleSyntax: true` replaces isolatedModules conceptually. Ensures import/export statements are preserved exactly as written — critical for tree-shaking and type-only import elision. Guarantees each file can be independently transpiled (required by esbuild, SWC, Bun).
`declarationMap: true` generates .d.ts.map files enabling "Go to Definition" to navigate to your original source. Always ship these — they dramatically improve consumer DX.
`moduleDetection: "force"` treats every file as a module regardless of whether it has import/export statements. Prevents accidental global script files.
What NOT to Do
Don't use path aliases (`paths`) in published packages. tsc does not rewrite path aliases in emitted JS or .d.ts files. Your consumers will see unresolvable "@lib/utils" imports. Use relative imports or Node.js subpath imports (#imports in package.json) instead.
Don't set `target` lower than `es2022` unless you have a documented reason. Bun and all supported Node.js versions support ES2022. Lower targets lose features like top-level await and cause-chained errors.
Don't use `composite: true` for single-package repos. It's a project-references feature for monorepos and adds unnecessary build artifacts.
Biome Configuration
Biome handles formatting and syntax-level linting. It's 10-25x faster than ESLint for linting and 25x faster than Prettier for formatting.
biome.json
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"organizeImports": {
"enabled": true
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error"
},
"style": {
"noNonNullAssertion": "warn",
"useImportType": "error",
"useConsistentArrayType": {
"level": "error",
"options": { "syntax": "generic" }
},
"noNamespace": "error"
},
"complexity": {
"noBannedTypes": "error",
"useOptionalChain": "error"
},
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": "warn",
"useExhaustiveDependencies": "warn"
},
"nursery": {
"noFloatingPromises": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
}
}Biome Key Rules for Strict TypeScript
- `noExplicitAny: "error"` — Bans
anytype annotations. Useunknownand narrow, or use// biome-ignore suspicious/noExplicitAny: <reason>when genuinely unavoidable. - `useImportType: "error"` — Enforces
import typefor type-only imports, aligning withverbatimModuleSyntax. - `noFloatingPromises: "error"` — Biome v2's first type-aware rule. Catches unhandled promise rejections.
- `noUnusedImports: "error"` — Keeps imports clean. Biome auto-fixes these.
What Biome Cannot Do (Yet)
Biome v2 has exactly one type-aware rule (noFloatingPromises). ESLint + typescript-eslint provides 40+ type-aware rules. For strict library authoring, the most impactful missing rules are:
no-misused-promises— Catches promises used in boolean contextsawait-thenable— Catchesawaiton non-Promise valuesno-unsafe-assignment/no-unsafe-return— Catchesanypropagation even from inferred typesstrict-boolean-expressions— Catches falsy-value bugsno-unnecessary-condition— Catches always-truthy/falsy checks
ESLint: Type-Aware Rules Only
Use ESLint exclusively for type-aware rules that Biome cannot provide. Biome handles everything else.
eslint.config.ts
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['dist/', 'node_modules/', '*.config.*'],
},
{
files: ['src/**/*.ts'],
extends: [tseslint.configs.strictTypeChecked],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Disable rules that Biome already covers
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
// Keep the type-aware rules that Biome can't do
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/strict-boolean-expressions': 'warn',
'@typescript-eslint/prefer-readonly': 'error',
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/return-await': ['error', 'always'],
},
},
);Why This Hybrid Works
Biome runs in ~50ms for a medium project. ESLint with type-checking runs in 2-10 seconds. By using Biome for all syntax-level checks (fast, in-editor feedback) and ESLint only for type-aware rules (slower, CI and pre-commit), you get:
- Fast editor feedback (Biome)
- Deep type safety (ESLint)
- No rule conflicts (Biome rules disabled in ESLint config)
Package.json Scripts
{
"scripts": {
"lint": "biome check . && eslint src/",
"lint:fix": "biome check --write . && eslint src/ --fix",
"format": "biome format --write ."
}
}Vitest Configuration
Vitest is the testing framework. It provides test isolation (each file in its own worker), mature mocking, coverage, snapshot testing, and rich IDE integration.
vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
include: ['src/**/*.test.ts'],
typecheck: {
enabled: true,
},
},
});Test File Convention
Place test files next to the code they test:
src/
├── utils.ts
├── utils.test.ts
├── parser.ts
└── parser.test.tsRunning Tests
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}#!/usr/bin/env bun
/**
* npm-package scaffold script
*
* Usage: bun run <skill-path>/scripts/scaffold.ts <project-dir> [options]
*
* Options:
* --name <name> Package name (defaults to directory name)
* --description <desc> Package description
* --author <author> Author name
* --license <license> License (default: MIT)
* --dual Generate dual CJS/ESM output (default: ESM-only)
* --no-eslint Skip ESLint setup (Biome only)
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, chmodSync } from 'node:fs';
import { resolve, dirname, basename, join } from 'node:path';
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
function getFlag(name: string): boolean {
const idx = args.indexOf(`--${name}`);
if (idx !== -1) {
args.splice(idx, 1);
return true;
}
return false;
}
function getOption(name: string, fallback: string): string {
const idx = args.indexOf(`--${name}`);
if (idx !== -1 && idx + 1 < args.length) {
const val = args[idx + 1]!;
args.splice(idx, 2);
return val;
}
return fallback;
}
const projectDir = resolve(args[0] ?? '.');
const dirName = basename(projectDir);
const packageName = getOption('name', dirName);
const description = getOption('description', '');
const author = getOption('author', '');
const license = getOption('license', 'MIT');
const dual = getFlag('dual');
const noEslint = getFlag('no-eslint');
// ---------------------------------------------------------------------------
// Handlebars-lite template engine (just {{variable}} replacement)
// ---------------------------------------------------------------------------
interface TemplateContext {
packageName: string;
description: string;
author: string;
license: string;
[key: string]: string;
}
function render(template: string, ctx: TemplateContext): string {
return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => ctx[key] ?? '');
}
// ---------------------------------------------------------------------------
// Resolve paths
// ---------------------------------------------------------------------------
const scriptDir = dirname(new URL(import.meta.url).pathname);
const skillRoot = resolve(scriptDir, '..');
const templatesDir = join(skillRoot, 'templates');
const ctx: TemplateContext = { packageName, description, author, license };
// ---------------------------------------------------------------------------
// Create project directory
// ---------------------------------------------------------------------------
function ensureDir(dir: string): void {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
function writeTemplate(templatePath: string, outputPath: string): void {
const raw = readFileSync(templatePath, 'utf-8');
const content = templatePath.endsWith('.hbs') ? render(raw, ctx) : raw;
ensureDir(dirname(outputPath));
writeFileSync(outputPath, content, 'utf-8');
}
function copyFile(src: string, dest: string): void {
ensureDir(dirname(dest));
copyFileSync(src, dest);
}
console.log(`\nScaffolding npm package: ${packageName}`);
console.log(`Directory: ${projectDir}\n`);
ensureDir(projectDir);
ensureDir(join(projectDir, 'src'));
// ---------------------------------------------------------------------------
// Static templates (no Handlebars)
// ---------------------------------------------------------------------------
copyFile(join(templatesDir, 'tsconfig.json'), join(projectDir, 'tsconfig.json'));
copyFile(join(templatesDir, 'biome.json'), join(projectDir, 'biome.json'));
copyFile(join(templatesDir, 'vitest.config.ts'), join(projectDir, 'vitest.config.ts'));
copyFile(join(templatesDir, 'gitignore'), join(projectDir, '.gitignore'));
if (!noEslint) {
copyFile(join(templatesDir, 'eslint.config.ts'), join(projectDir, 'eslint.config.ts'));
}
// ---------------------------------------------------------------------------
// Handlebars templates
// ---------------------------------------------------------------------------
writeTemplate(join(templatesDir, 'src', 'index.ts.hbs'), join(projectDir, 'src', 'index.ts'));
writeTemplate(join(templatesDir, 'src', 'index.test.ts.hbs'), join(projectDir, 'src', 'index.test.ts'));
// ---------------------------------------------------------------------------
// package.json — needs conditional modification for dual output
// ---------------------------------------------------------------------------
const pkgRaw = readFileSync(join(templatesDir, 'package.json.hbs'), 'utf-8');
let pkgContent = render(pkgRaw, ctx);
let pkg = JSON.parse(pkgContent) as Record<string, unknown>;
if (dual) {
pkg['exports'] = {
'.': {
'module-sync': { types: './dist/index.d.ts', default: './dist/index.js' },
import: { types: './dist/index.d.ts', default: './dist/index.js' },
require: { types: './dist/index.d.cts', default: './dist/index.cjs' },
},
'./package.json': './package.json',
};
}
if (noEslint) {
const scripts = pkg['scripts'] as Record<string, string>;
scripts['lint'] = 'biome check .';
scripts['lint:fix'] = 'biome check --write .';
}
writeFileSync(join(projectDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
// ---------------------------------------------------------------------------
// bunup.config.ts — conditional for dual
// ---------------------------------------------------------------------------
const bunupConfig = dual
? `import { defineConfig } from 'bunup';
export default defineConfig({
\tentry: ['src/index.ts'],
\tformat: ['esm', 'cjs'],
\tdts: true,
\tclean: true,
});
`
: readFileSync(join(templatesDir, 'bunup.config.ts'), 'utf-8');
writeFileSync(join(projectDir, 'bunup.config.ts'), bunupConfig, 'utf-8');
// ---------------------------------------------------------------------------
// .changeset/config.json
// ---------------------------------------------------------------------------
ensureDir(join(projectDir, '.changeset'));
writeFileSync(
join(projectDir, '.changeset', 'config.json'),
JSON.stringify(
{
$schema: 'https://unpkg.com/@changesets/config@3.1.1/schema.json',
changelog: '@changesets/cli/changelog',
commit: false,
fixed: [],
linked: [],
access: 'public',
baseBranch: 'main',
updateInternalDependencies: 'patch',
ignore: [],
},
null,
2,
) + '\n',
'utf-8',
);
// ---------------------------------------------------------------------------
// README.md
// ---------------------------------------------------------------------------
const readme = `# ${packageName}
${description}
## Installation
\`\`\`bash
npm install ${packageName}
\`\`\`
## Usage
\`\`\`typescript
import { hello } from '${packageName}';
console.log(hello('World'));
\`\`\`
## Development
\`\`\`bash
bun install
bun run test
bun run build
\`\`\`
## License
${license}
`;
writeFileSync(join(projectDir, 'README.md'), readme, 'utf-8');
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
console.log('Created files:');
console.log(' package.json');
console.log(' tsconfig.json');
console.log(' bunup.config.ts');
console.log(' biome.json');
if (!noEslint) console.log(' eslint.config.ts');
console.log(' vitest.config.ts');
console.log(' .gitignore');
console.log(' .changeset/config.json');
console.log(' README.md');
console.log(' src/index.ts');
console.log(' src/index.test.ts');
console.log('');
console.log('Next steps:');
console.log(` cd ${projectDir}`);
console.log(' bun install');
console.log(' bun add -d bunup typescript vitest @vitest/coverage-v8 @biomejs/biome @changesets/cli');
if (!noEslint) console.log(' bun add -d eslint typescript-eslint');
console.log(' bun run test');
console.log(' bun run build');
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"organizeImports": {
"enabled": true
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error"
},
"style": {
"noNonNullAssertion": "warn",
"useImportType": "error",
"useConsistentArrayType": {
"level": "error",
"options": { "syntax": "generic" }
},
"noNamespace": "error"
},
"complexity": {
"noBannedTypes": "error",
"useOptionalChain": "error"
},
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": "warn",
"useExhaustiveDependencies": "warn"
},
"nursery": {
"noFloatingPromises": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
}
}
import { defineConfig } from 'bunup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
});
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['dist/', 'node_modules/', '*.config.*'],
},
{
files: ['src/**/*.ts'],
extends: [tseslint.configs.strictTypeChecked],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Disabled — Biome covers these
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
// Type-aware rules — Biome cannot do these
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/strict-boolean-expressions': 'warn',
'@typescript-eslint/prefer-readonly': 'error',
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/return-await': ['error', 'always'],
},
},
);
node_modules/
dist/
*.tsbuildinfo
.env
.env.*
coverage/
{
"name": "{{packageName}}",
"version": "0.0.0",
"description": "{{description}}",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist"
],
"scripts": {
"build": "bunup",
"dev": "bunup --watch",
"lint": "biome check . && eslint src/",
"lint:fix": "biome check --write . && eslint src/ --fix",
"format": "biome format --write .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run build",
"changeset": "changeset",
"version": "changeset version",
"release": "bun run build && npm publish --provenance --access public"
},
"keywords": [],
"author": "{{author}}",
"license": "{{license}}",
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=20.19.0"
}
}
import { describe, it, expect } from 'vitest';
import { hello } from './index.js';
describe('{{packageName}}', () => {
it('greets by name', () => {
expect(hello('World')).toBe('Hello, World!');
});
});
/**
* {{packageName}}
*
* {{description}}
*/
export function hello(name: string): string {
return `Hello, ${name}!`;
}
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"target": "es2022",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
include: ['src/**/*.test.ts'],
typecheck: {
enabled: true,
},
},
});
Related skills
How it compares
Pick npm-package for Bun-first library scaffolding with dual-module output; use generic Node templates when you do not need Changesets or CJS interop guidance.
FAQ
What toolchain does npm-package recommend?
npm-package recommends Bun as runtime and package manager, Bunup for bundling ESM and CJS artifacts with TypeScript declarations, Biome v2 plus ESLint for linting, Vitest for tests, and Changesets for version bumps and changelogs.
Does npm-package handle ESM and CommonJS together?
npm-package documents package.exports conditional maps, Bunup dual builds, and common interop fixes for default versus named exports so libraries work in both import and require consumption paths.
When should you invoke npm-package?
npm-package triggers when a developer wants to create a new npm library, configure build and test tooling for an existing package, resolve module interop errors, or publish a TypeScript package to the npm registry.