ADR 0002: Monorepo Tooling — pnpm Workspaces + Turborepo

Field Value
Status Accepted
Date Pre-alpha
Deciders Project maintainers
Supersedes
Superseded by
Related ADRs 0001 (Runtime + language), 0004 (Distribution)

Summary

aidokit uses pnpm workspaces for package management and Turborepo for build orchestration. The repository is a single monorepo containing ~10 packages at v0.1 growing to ~15 packages by v1.0. Both tools are pinned via the packageManager field. No other monorepo solution (Nx, Moon, Bazel, Lerna, Bun workspaces, raw npm workspaces) was deemed appropriate.


Context

aidokit ships as a family of packages with strong interdependencies:

@aidokit/cli          ─┐
@aidokit/core         ─┼─ tightly coupled; cross-package type imports
@aidokit/shared-docs  ─┘

@aidokit/adapter-claude-code  ┐
@aidokit/adapter-codex        ┴─ each depends on @aidokit/core

@aidokit/stack-pack-node-ts   ┐
@aidokit/stack-pack-python    ┼─ each depends on @aidokit/core
@aidokit/stack-pack-react     ┘

@aidokit/stack-detect         ┐
@aidokit/prereq-check         ┼─ utility packages
@aidokit/mcp-catalog          ┘

The monorepo needs to:

  1. Link packages locally so a change in @aidokit/core is immediately visible to @aidokit/cli without publishing
  2. Order builds topologically@aidokit/core builds before @aidokit/cli
  3. Cache build outputs — re-building unchanged packages wastes CI time and developer time
  4. Parallelize where safe — independent packages (e.g., two stack packs) can build concurrently
  5. Manage one lockfile — a single source of truth for every dependency version
  6. Catch phantom dependencies — code in @aidokit/cli accidentally importing from @aidokit/core's transitive deps should fail at install, not at runtime in production
  7. Stay simple enough for solo or small-team development; not require a dedicated build engineer
  8. Publish individual packages to npm with proper semver and tags

Beyond technical needs, the team must be able to onboard a contributor in under an hour. Heavy build systems (Bazel, Nx with all plugins) work against this.


Decision

Package manager: pnpm 9.x or later

Build orchestrator: Turborepo 2.x or later

Repository layout

aidokit/
├── package.json                 ← workspace root, devDeps + scripts only
├── pnpm-workspace.yaml          ← packages declared here
├── turbo.json                   ← pipeline definition
├── tsconfig.base.json           ← shared TS config extended by packages
├── .npmrc                       ← pnpm-specific config
├── .changeset/                  ← versioning + changelog tool (Changesets)
├── packages/
│   ├── cli/
│   ├── core/
│   ├── shared-docs/
│   ├── adapter-claude-code/
│   ├── adapter-codex/
│   ├── stack-detect/
│   ├── prereq-check/
│   ├── mcp-catalog/
│   ├── stack-pack-node-ts/
│   ├── stack-pack-python/
│   └── stack-pack-react/
├── examples/                    ← example bootstrapped projects (used in CI)
└── scripts/                     ← repo automation

Versioning and publishing


Consequences

Positive

Negative

Neutral


Alternatives considered

Alternative 1: npm workspaces (no build orchestrator)

Pros

Cons

Reason rejected: missing build caching alone makes this untenable at our scale. CI times would balloon.

Alternative 2: Yarn workspaces (Classic 1.x)

Pros

Cons

Reason rejected: deprecated; no migration path forward.

Alternative 3: Yarn Berry (Yarn 2+)

Pros

Cons

Reason rejected: pnpm offers the same benefits with fewer compatibility surprises.

Alternative 4: Nx

Pros

Cons

Reason rejected: too much ceremony for a small open-source CLI project. We want to optimize for "any TypeScript developer can contribute without learning Nx first." Turborepo's simpler model wins here.

Alternative 5: Moon

Pros

Cons

Reason rejected: Turborepo solves the same problem with more community resources. If aidokit ever adds Python or Go packages alongside TS, revisit Moon.

Alternative 6: Bazel

Pros

Cons

Reason rejected: complete overkill. The complexity tax is prohibitive at our scale.

Alternative 7: Lerna

Pros

Cons

Reason rejected: effectively merged into Nx; no reason to use it standalone.

Alternative 8: Bun workspaces

Pros

Cons

Reason rejected for v1.0: ADR 0001 puts Bun on the post-1.0 revisit list; the monorepo tooling choice should follow the runtime choice.

Alternative 9: Custom scripts (no monorepo tool)

Pros

Cons

Reason rejected: reinventing what Turborepo gives us for free.


Implementation notes

Root package.json

{
  "name": "aidokit",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "packageManager": "pnpm@9.x.x",
  "engines": {
    "node": ">=20.0.0",
    "pnpm": ">=9.0.0"
  },
  "scripts": {
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "typecheck": "turbo run typecheck",
    "dev": "turbo run dev",
    "clean": "turbo run clean && rm -rf node_modules .turbo",
    "changeset": "changeset",
    "changeset:version": "changeset version",
    "changeset:publish": "pnpm build && changeset publish"
  },
  "devDependencies": {
    "@changesets/cli": "^2.x",
    "turbo": "^2.x",
    "typescript": "^5.4",
    "vitest": "^1.x"
  }
}

pnpm-workspace.yaml

packages:
  - 'packages/*'

turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json", "package.json"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**", "test/**", "tsconfig.json"],
      "outputs": []
    },
    "lint": {
      "inputs": ["src/**", "test/**", ".eslintrc.*"],
      "outputs": []
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json"],
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "clean": {
      "cache": false
    }
  }
}

.npmrc

# Default: isolated (pnpm's strict mode)
node-linker=isolated

# Use pnpm's lockfile only
package-lock=false

# Don't auto-install peer deps; surface them so we catch issues
auto-install-peers=false

# Strict resolution
strict-peer-dependencies=true

# Save exact versions (no carets)
save-exact=true

Package package.json template

{
  "name": "@aidokit/<package>",
  "version": "0.1.0",
  "type": "module",
  "engines": { "node": ">=20.0.0" },
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist", "README.md", "CHANGELOG.md"],
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "test": "vitest run",
    "lint": "eslint src test",
    "typecheck": "tsc --noEmit",
    "dev": "tsc -p tsconfig.json --watch",
    "clean": "rm -rf dist .turbo"
  },
  "dependencies": {
    "@aidokit/core": "workspace:*"
  }
}

Cross-package dependencies

Always use workspace:* for internal packages:

"dependencies": {
  "@aidokit/core": "workspace:*",
  "@aidokit/shared-docs": "workspace:*"
}

On publish, Changesets/pnpm rewrites workspace:* to the actual semver of the published package version.

Gitignore additions

node_modules/
.turbo/
dist/
*.tsbuildinfo
.changeset/*.md
!.changeset/README.md

CI workflow snippet (GitHub Actions)

- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
  with: { version: 9 }
- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint typecheck test build

Validation

This decision is validated when:


References