Architecture

The resolve → preflight → generate → stitch → verify pipeline and the adapter contract. Edit on GitHub

groot architecture

Status: normative design for v0.2. The repository infrastructure (Phase 0) is live; the engine described here is being built against this document. Command surface details live in cli-spec.md; per-generator facts live in scaffold-flows.md.

Design philosophy

groot's core bet: orchestrate official generators instead of vendoring templates.

Scaffolding CLIs face a fork in the road:

StrategyExamplesStrengthWeakness
Vendored templates (ship your own files)create-better-t-stack (~daily releases to keep current), create-t3-turbo (template repo; README already trails upstream majors)Deterministic outputRots without industrial upkeep — every upstream release is your maintenance burden
Live official generators (run create-next-app et al. at scaffold time)grootOutput never rots — you always get today's frameworkN moving targets: upstream flags change under you

groot chooses live generators and mitigates the moving-target problem three ways:

  1. Pinned generator majors — groot invokes bunx create-next-app@16, bunx sv@0.16, etc. Bumping a generator major is an explicit, tested, changelogged groot release.
  2. Thin flag adapters — each generator's invocation lives in one small adapter module that can be updated independently when upstream changes flags.
  3. A written contract per generatorscaffold-flows.md records every verified flag, output shape, and monorepo caveat, with sources and a verification date. Drift is detected by re-verifying that document, not by archaeology.

Two deliberate exceptions where groot writes files directly instead of shelling out:

Pipeline

groot init is a strict five-stage pipeline. Every stage either succeeds completely or fails the run with a precise error; --dry-run prints the resolved plan (stages 1–2) without touching disk.

┌─────────────┐   ┌────────────┐   ┌──────────────┐   ┌───────────┐   ┌──────────┐
│ 1. RESOLVE  │ → │ 2. PREFLIGHT│ → │ 3. GENERATE  │ → │ 4. STITCH │ → │ 5. VERIFY│
│ prompts/flags│   │ env checks │   │ run official │   │ patch into│   │ install, │
│ → plan      │   │ dir policy │   │ generators   │   │ one       │   │ typecheck,│
│             │   │            │   │              │   │ workspace │   │ report   │
└─────────────┘   └────────────┘   └──────────────┘   └───────────┘   └──────────┘

1. Resolve

Merge CLI flags, interactive prompt answers (clack), and defaults into an immutable plan: the target directory, the selected scaffold for each slot (web / mobile / desktop / api / backend), assigned ports, and package names. The plan is serializable — --dry-run --json prints it for agents and CI.

2. Preflight

3. Generate

Run each selected generator with its silence flags (non-interactive, no git init, no install — see the adapter contract below). Order: trunk first (create-turbo plants the workspace, then groot removes the example apps it doesn't need), then branches (web → mobile → api → backend). Each generator writes into its final location (apps/web, apps/mobile, apps/api, packages/backend).

Not every generator can be silenced by flags: create-expo-app has no git-suppression flag and initializes a repo whenever it doesn't detect an enclosing one — which is always the case here, since the root git init happens in the verify stage, after generation. So immediately after each scaffold grows, groot removes any .git the generator created inside it — nested repos split workspace history; the root owns git. A .git that existed before the grow (--dir-conflict merge onto a directory the user already tracks) is the user's and is preserved. This guarantee covers both init and groot add, which share the per-scaffold grow path.

4. Stitch

The stitch stage is groot's real value — the deterministic patch set that turns N independent scaffolds into one workspace. Canonical reference: the diff between raw generator output and the equivalent app inside Turborepo's own basic/kitchen-sink examples.

Stitch operations (each implemented as a pure, unit-tested transform):

OperationDetail
Workspace globsRoot package.json gets "workspaces": ["apps/*", "packages/*"], "packageManager": "bun@<pinned>"
Package namingApps keep bare names (web, mobile, api); shared packages use @repo/* (@repo/backend, @repo/typescript-config)
Lockfile hygieneDelete any per-app lockfiles generators left behind; exactly one root bun.lock
Git hygienePer-app .gitignores stay in place (native git semantics); the root .gitignore is topped up to cover workspace basics; single git init + initial commit at the end (unless --no-git)
Port allocationDeterministic dev ports written into each app (flag or source, per adapter) — see table below
turbo.jsonRoot tasks (dev, build, lint, check-types) with correct outputs per framework
Shared TS configpackages/typescript-config kept from the trunk. In v0.2 apps keep their generated standalone tsconfigs (they work as-is); rewiring them to shared extends arrives with groot add/doctor (v0.3), where breakage can be detected and fixed
Env plumbing.env.example entries per scaffold, named for what each framework exposes to the client: NEXT_PUBLIC_CONVEX_URL (Next), PUBLIC_CONVEX_URL (SvelteKit, Astro), VITE_CONVEX_URL (TanStack Start, React Router, Vite), NUXT_PUBLIC_CONVEX_URL (Nuxt), EXPO_PUBLIC_CONVEX_URL (Expo), CONVEX_URL (bare React Native — no public-env mechanism, wired by the user's env lib). Supabase backends write the same-prefixed *SUPABASE_URL + *SUPABASE_ANON_KEY pairs
groot.jsonManifest recording what was scaffolded, by which generator version — the contract for groot add/doctor

5. Verify

Structural workspace checks (every package.json parses, package names are unique), then bun install at the root (unless --no-install), then git init + initial commit (unless --no-git; a missing git identity downgrades the commit to a printed hint, never a failure), then a summary report with next steps — including the deferred convex dev login step when Convex was selected. With --github, a post-verify step creates and pushes the repository via gh repo create --source=. --push (auth-checked first; absent/unauthenticated gh degrades to printed fallback commands, never a failure — docs/cli-spec.md#github-publishing). Deeper per-package checks (tsc --noEmit-level via turbo, boot probes) arrive with groot doctor (v0.3), which also exposes this stage standalone.

Port allocation

Next.js, Elysia, and Hono all default to port 3000 — the #1 papercut of multi-app workspaces. groot assigns deterministic ports at scaffold time:

AppPortMechanism
apps/web (Next.js)3000next dev default (kept)
apps/web (SvelteKit)5173Vite default (kept)
apps/web (TanStack Start)3000The template's own dev script (vite dev --port 3000) — same default as Next; add --path coexistence rides the collision warning
apps/web (Astro)4321astro dev built-in default (kept) — the only unique web port
apps/web (React Router)5173Vite default (kept) — shared with SvelteKit, like elysia/hono on 3001
apps/web (Nuxt)3000nuxt dev built-in default (kept) — shared with Next/TanStack
apps/web (Vite)5173Vite default (kept) — shared with SvelteKit/React Router
apps/api (Elysia / Hono)3001Written into source (.listen(3001) / export default { port: 3001, fetch }) — these templates have no port flag
apps/api (Fastify)3001Written into groot's src/server.ts overlay (listen({ port: 3001 })) — shared with Elysia/Hono per the same-slot rule
apps/mobile (Expo / Metro)8081Metro default (kept)
apps/mobile (React Native bare)8081Metro default (kept) — shared with Expo per the same-slot rule; the stitch adds monorepo watchFolders/module resolution
apps/desktop (Tauri)1420Template's Vite strictPort default, coupled to tauri.conf.json's devUrl (kept — unique in the matrix)
apps/desktop (Electron)electron-vite's renderer dev server is non-strict and self-wiring (it launches Electron with whatever port it resolved); groot declares none
packages/backend (Convex)Cloud dev deployment; no local port
packages/backend (Supabase)Local stack is Docker-managed on config.toml's 54321+ block, never started by groot — no declared port

Workspace conventions (what groot outputs)

Adapter contract

Every scaffold is described by one adapter module implementing:

interface ScaffoldAdapter {
  id: string;                    // "next", "sveltekit", "expo", "elysia", "hono", "convex"
  slot: "web" | "mobile" | "api" | "backend";
  /** Exact generator invocation with pinned major + silence flags, or null when groot writes files directly. */
  command(plan: Plan): GeneratorCommand | null;
  /** Files groot writes itself (Elysia, Convex — and small overlays for others). */
  writeFiles?(plan: Plan): FileSpec[];
  /** Pure post-generation transforms (rename package, rewire tsconfig, set port…). */
  patches(plan: Plan): Patch[];
  /** Doctor checks specific to this scaffold. */
  doctor?(workspace: Workspace): DoctorCheck[];
}

Rules for adapter authors:

  1. The generator invocation must be fully non-interactive and must not git-init, install, or write outside its target directory. Suppression flags per generator are documented in scaffold-flows.md.
  2. Patches must be pure functions of (generated tree, plan) — no network, no ambient state — so they're unit-testable without running generators.
  3. Update scaffold-flows.md (flags, caveats, sources, verification date) in the same PR as any adapter change.

Failure semantics

Security posture