Files
agentic-dev/turbo/generators/config.ts
Danijel Martinek bec10fb171 fix(turbo-gen): annotate answers param + add tsconfig for generators dir
Two diagnostics from the IDE on turbo/generators/config.ts:

1. `Cannot find module '@turbo/gen'` — there was no tsconfig in
   turbo/generators/, so the IDE was opening config.ts in loose mode
   without proper module resolution context. Added a small
   tsconfig.json that extends tsconfig.base, sets NodeNext module
   resolution, and includes only ./**/*.ts (templates excluded).

2. `Parameter 'answers' implicitly has an 'any' type` — annotated
   the printNextSteps custom-action function param as
   `Record<string, unknown>` (Plop's runtime answers shape). The
   inner cast to the typed Answers shape was already in place.

`npx tsc --noEmit -p turbo/generators/tsconfig.json` is now clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:11:40 +02:00

343 lines
13 KiB
TypeScript

import type { PlopTypes } from "@turbo/gen";
/**
* Turbo generator: `feature`
*
* Scaffolds a Lazar-conformant feature package under `packages/<name>/`
* matching the shape of the existing `navigation` reference feature.
*
* Phase 1 scope (intentionally limited):
* - Single entity, single use case (`getX`)
* - Skips Payload CMS collection/global templates (integrations/cms/**)
* - Skips UI query helpers (ui/query.ts) — emits an empty barrel
* - Skips faker-driven factories — emits empty stubs
* - Skips multi-entity / multi-use-case
* - Skips aggregator wiring (core-api/root.ts, core-cms/**, apps/web-next/server/bind-production.ts)
*
* After running, the developer must hand-wire the new feature into:
* - apps/web-next/src/server/bind-production.ts (bindAll dispatcher)
* - packages/core-api/src/root.ts (mount on app router)
* - packages/core-cms/... (when CMS templates are added later)
*
* The generator prints these manual steps when it finishes.
*/
export default function generator(plop: PlopTypes.NodePlopAPI): void {
plop.setGenerator("feature", {
description:
"Scaffold a Lazar-conformant feature package (single entity / single use case)",
prompts: [
{
type: "input",
name: "name",
message:
"Feature package name (kebab-case, becomes @repo/<name> and packages/<name>/):",
validate: (input: string) => {
if (!input) return "Required";
if (!/^[a-z][a-z0-9-]*$/.test(input)) {
return "Must be kebab-case (lowercase letters, digits, hyphens; must start with a letter)";
}
return true;
},
},
{
type: "input",
name: "entity",
message:
"Entity name (PascalCase singular, e.g. 'Widget' for a getWidget use case):",
validate: (input: string) => {
if (!input) return "Required";
if (!/^[A-Z][A-Za-z0-9]*$/.test(input)) {
return "Must be PascalCase (e.g. Widget, BlogPost)";
}
return true;
},
},
{
type: "input",
name: "entityPlural",
message:
"Entity plural slug (kebab-case, used for Payload collection slug; e.g. 'widgets'):",
validate: (input: string) => {
if (!input) return "Required";
if (!/^[a-z][a-z0-9-]*$/.test(input)) {
return "Must be kebab-case";
}
return true;
},
},
],
actions: [
// Top-level package files
{
type: "add",
path: "packages/{{kebabCase name}}/package.json",
templateFile: "templates/feature/package.json.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/tsconfig.json",
templateFile: "templates/feature/tsconfig.json.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/vitest.config.ts",
templateFile: "templates/feature/vitest.config.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/eslint.config.js",
templateFile: "templates/feature/eslint.config.js.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/turbo.json",
templateFile: "templates/feature/turbo.json.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/AGENTS.md",
templateFile: "templates/feature/AGENTS.md.hbs",
},
// Public surface
{
type: "add",
path: "packages/{{kebabCase name}}/src/index.ts",
templateFile: "templates/feature/src/index.ts.hbs",
},
// Entities — models + errors
{
type: "add",
path: "packages/{{kebabCase name}}/src/entities/models/{{kebabCase entity}}.ts",
templateFile: "templates/feature/src/entities/models/entity.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/entities/models/{{kebabCase entity}}.test.ts",
templateFile: "templates/feature/src/entities/models/entity.test.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/entities/errors/common.ts",
templateFile: "templates/feature/src/entities/errors/common.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/entities/errors/{{kebabCase entity}}.ts",
templateFile: "templates/feature/src/entities/errors/entity.ts.hbs",
},
// Application layer
{
type: "add",
path: "packages/{{kebabCase name}}/src/application/repositories/{{kebabCase entity}}.repository.interface.ts",
templateFile:
"templates/feature/src/application/repositories/entity.repository.interface.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/application/use-cases/get-{{kebabCase entity}}.use-case.ts",
templateFile:
"templates/feature/src/application/use-cases/get-entity.use-case.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/application/use-cases/get-{{kebabCase entity}}.use-case.test.ts",
templateFile:
"templates/feature/src/application/use-cases/get-entity.use-case.test.ts.hbs",
},
// Infrastructure
{
type: "add",
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.ts",
templateFile:
"templates/feature/src/infrastructure/repositories/entity.repository.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.test.ts",
templateFile:
"templates/feature/src/infrastructure/repositories/entity.repository.test.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.mock.ts",
templateFile:
"templates/feature/src/infrastructure/repositories/entity.repository.mock.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.mock.test.ts",
templateFile:
"templates/feature/src/infrastructure/repositories/entity.repository.mock.test.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/infrastructure/repositories/{{kebabCase entity}}.repository.span.test.ts",
templateFile:
"templates/feature/src/infrastructure/repositories/entity.repository.span.test.ts.hbs",
},
// Interface adapters
{
type: "add",
path: "packages/{{kebabCase name}}/src/interface-adapters/controllers/get-{{kebabCase entity}}.controller.ts",
templateFile:
"templates/feature/src/interface-adapters/controllers/get-entity.controller.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/interface-adapters/controllers/get-{{kebabCase entity}}.controller.test.ts",
templateFile:
"templates/feature/src/interface-adapters/controllers/get-entity.controller.test.ts.hbs",
},
// DI
{
type: "add",
path: "packages/{{kebabCase name}}/src/di/symbols.ts",
templateFile: "templates/feature/src/di/symbols.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/di/module.ts",
templateFile: "templates/feature/src/di/module.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/di/container.ts",
templateFile: "templates/feature/src/di/container.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/di/container.test.ts",
templateFile: "templates/feature/src/di/container.test.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/di/bind-production.ts",
templateFile: "templates/feature/src/di/bind-production.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/di/bind-dev-seed.ts",
templateFile: "templates/feature/src/di/bind-dev-seed.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/di/bind-dev-seed.test.ts",
templateFile: "templates/feature/src/di/bind-dev-seed.test.ts.hbs",
},
// Integrations: api
{
type: "add",
path: "packages/{{kebabCase name}}/src/integrations/api/procedures.ts",
templateFile: "templates/feature/src/integrations/api/procedures.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/integrations/api/router.ts",
templateFile: "templates/feature/src/integrations/api/router.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/integrations/api/router.test.ts",
templateFile: "templates/feature/src/integrations/api/router.test.ts.hbs",
},
// Seeds + factories + contracts (Phase 1: minimal stubs that still typecheck/test)
{
type: "add",
path: "packages/{{kebabCase name}}/src/__seeds__/dev.ts",
templateFile: "templates/feature/src/__seeds__/dev.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/__factories__/index.ts",
templateFile: "templates/feature/src/__factories__/index.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/__factories__/{{kebabCase entity}}.factory.ts",
templateFile: "templates/feature/src/__factories__/entity.factory.ts.hbs",
},
{
type: "add",
path: "packages/{{kebabCase name}}/src/__contracts__/{{kebabCase entity}}-repository.contract.ts",
templateFile:
"templates/feature/src/__contracts__/entity-repository.contract.ts.hbs",
},
// ui — empty barrel for Phase 1 (./ui subpath kept reserved)
{
type: "add",
path: "packages/{{kebabCase name}}/src/ui/index.ts",
templateFile: "templates/feature/src/ui/index.ts.hbs",
},
// Final manual-wiring instructions printed to the user
function printNextSteps(answers: Record<string, unknown>): string {
const a = answers as { name: string; entity: string; entityPlural: string };
const kebab = a.name;
const constSym = a.name.toUpperCase().replace(/-/g, "_");
const pkg = `@repo/${kebab}`;
return [
"",
"─────────────────────────────────────────────────────────────",
`Feature package ${pkg} scaffolded.`,
"",
"Next steps (manual aggregator wiring — not generated):",
"",
` 1. pnpm install # link the new workspace package`,
"",
` 2. apps/web-next/src/server/bind-production.ts:`,
` - import { bindProduction${cap(a.name)} } from "${pkg}/di/bind-production";`,
` - import { bindDevSeed${cap(a.name)} } from "${pkg}/di/bind-dev-seed";`,
` - call bindProduction${cap(a.name)}(config, tracer, logger) (production branch)`,
` - call await bindDevSeed${cap(a.name)}(tracer, logger) (dev-seed branch)`,
"",
` 3. packages/core-api/src/root.ts:`,
` - import { ${camel(a.name)}Router } from "${pkg}/api";`,
` - mount it on the app router (e.g. ${camel(a.name)}: ${camel(a.name)}Router)`,
"",
` 4. packages/core-api/package.json:`,
` - add "${pkg}": "workspace:*" to dependencies`,
"",
` 5. apps/web-next/package.json:`,
` - add "${pkg}": "workspace:*" to dependencies`,
"",
` 6. (Later) Add Payload CMS collection/global at:`,
` packages/${kebab}/src/integrations/cms/collections/${a.entityPlural}.ts`,
` and register in packages/core-cms/...`,
"",
` 7. Verify: pnpm --filter ${pkg} lint typecheck test`,
"",
`Reference symbols exported by this package:`,
` - ${constSym}_SYMBOLS (from ${pkg}/* internal)`,
` - bindProduction${cap(a.name)}, bindDevSeed${cap(a.name)}`,
` - ${camel(a.name)}Router (tRPC)`,
"─────────────────────────────────────────────────────────────",
"",
].join("\n");
},
],
});
}
// Local helpers used inside the printNextSteps action — Plop's helpers aren't
// available outside template strings, so we replicate the bits we need.
function cap(input: string): string {
return input
.split(/[-_\s]+/)
.filter(Boolean)
.map((s) => s[0]!.toUpperCase() + s.slice(1))
.join("");
}
function camel(input: string): string {
const p = cap(input);
return p ? p[0]!.toLowerCase() + p.slice(1) : "";
}