home / skills / jwynia / agent-skills / npm-package

This skill scaffolds, builds, tests, and publishes npm packages using Bun with strict TypeScript, exports maps, and Biome + Vitest tooling.

npx playbooks add skill jwynia/agent-skills --skill npm-package

Review the files below or copy the command above to add this skill to your agents.

Files (14)
SKILL.md
8.3 KB
---
name: npm-package
description: "Build and publish npm packages using Bun as the primary toolchain with npm-compatible output. Use when the user wants to create a new npm library, set up a TypeScript package for publishing, configure build/test/lint tooling for a package, fix CJS/ESM interop issues, or publish to npm. Covers scaffolding, strict TypeScript, Biome + ESLint linting, Vitest testing, Bunup bundling, and publishing workflows. Keywords: npm, package, library, publish, bun, bunup, esm, cjs, exports, typescript, biome, vitest, changesets."
---

# 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-cli` skill)
- 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:

```bash
bun run <skill-path>/scripts/scaffold.ts ./my-package \
  --name my-package \
  --description "What this package does" \
  --author "Your Name" \
  --license MIT
```

Options:
- `--dual` — Generate dual CJS/ESM output (default: ESM-only)
- `--no-eslint` — Skip ESLint, use Biome only

Then install dependencies:

```bash
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-eslint
```

## Project 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
└── LICENSE
```

## Critical 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)** — `exports` map 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, `files` field, 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

```bash
# 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 --provenance
```

## Adding 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:

```json
{
  "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](./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 --emitDeclarationOnly` separately.
- **`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 publish` for provenance signing.
- **`bun publish` uses `NPM_CONFIG_TOKEN`**, not `NODE_AUTH_TOKEN`. CI pipelines may need adjustment.

Overview

This skill helps you build and publish npm libraries using Bun as the primary toolchain while producing npm-compatible output. It scaffolds TypeScript packages, configures Biome/ESLint linting, Vitest testing, Bunup bundling, and a Changesets-based release flow. The output is configured for correct ESM/CJS interop, declaration files, and safe npm publishing.

How this skill works

The skill generates a single-package scaffold with strict TypeScript (nodenext, strict), Biome for formatting/linting, optional ESLint for type-aware rules, and Vitest for isolated tests. Bunup is used to bundle code and emit .d.ts files, with options for ESM-only or dual ESM/CJS output. It also includes recommended package.json, exports map, files whitelist, and workflows for changesets and provenance-signed npm publish.

When to use it

  • Creating a new npm library or converting an existing repo into a publishable package
  • Setting up strict TypeScript, linting, and testing for a package intended for npm
  • Fixing CJS/ESM interop, exports maps, or missing TypeScript declaration issues
  • Preparing a package for release with Changesets and npm provenance signing
  • Adding subpath exports or switching to dual ESM/CJS output for older consumers

Best practices

  • Always set "type": "module" and use the exports field (not main) for entry control
  • Use module: "nodenext" and strict TypeScript options; enable declarationMap and noUncheckedIndexedAccess
  • Ship only dist via files: ["dist"] and run npm pack --dry-run before every publish
  • Prefer named exports and import type for types; ban any except with documented justification
  • Build with Bunup, test with Vitest, and lint with Biome plus ESLint for type-aware rules

Example use cases

  • Scaffold a new TypeScript npm library with Bun-native build and type declarations
  • Resolve consumer errors caused by incorrect exports maps or missing .d.ts files
  • Add a utils subpath export and include it in bunup entries and package exports
  • Enable dual ESM/CJS output for projects that must support Node < 20.19.0
  • Automate release: create changeset, run versioning, build, and publish with provenance

FAQ

Why use Bunup instead of bun build or another bundler?

Bunup integrates with tsc to emit .d.ts files, handles correct file extensions and externals, and produces npm-friendly output that bun build alone does not guarantee.

When should I produce dual ESM/CJS output?

Produce dual output only if you need to support older Node versions or CJS-only consumers; dual packages increase maintenance and require a careful exports map (semver-major when changing).