@stjoseph/prumo 0.0.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.prumo-templates/api/auth.md +51 -95
  2. package/.prumo-templates/api/config.md +30 -56
  3. package/.prumo-templates/api/errors.md +64 -44
  4. package/.prumo-templates/api/logging.md +27 -44
  5. package/.prumo-templates/api/observability.md +36 -62
  6. package/.prumo-templates/api/pagination.md +23 -38
  7. package/.prumo-templates/api/routes.md +67 -48
  8. package/.prumo-templates/api/structure.md +130 -0
  9. package/.prumo-templates/client/data.md +4 -3
  10. package/.prumo-templates/core/conventions.md +10 -7
  11. package/.prumo-templates/core/testing.md +43 -26
  12. package/.prumo-templates/core/tooling.md +33 -30
  13. package/.prumo-templates/database/entities.md +53 -61
  14. package/.prumo-templates/database/queries.md +34 -47
  15. package/.prumo-templates/database/transactions.md +28 -38
  16. package/.prumo-templates/mcp/server.md +54 -0
  17. package/.prumo-templates/monorepo/dependencies.md +3 -3
  18. package/.prumo-templates/monorepo/layout.md +1 -1
  19. package/README.md +12 -6
  20. package/dist/asker.js +4 -0
  21. package/dist/clean.js +2 -2
  22. package/dist/cli.js +4 -0
  23. package/dist/commands.js +2 -0
  24. package/dist/compose.js +12 -8
  25. package/dist/context.js +2 -0
  26. package/dist/generate.js +8 -0
  27. package/dist/mcp.js +100 -0
  28. package/dist/questions.js +21 -1
  29. package/package.json +4 -3
  30. package/templates/api/.swcrc +13 -0
  31. package/templates/api/README.md +10 -1
  32. package/templates/api/biome.jsonc +5 -3
  33. package/templates/api/package.json +22 -28
  34. package/templates/api/pnpm-workspace.yaml +0 -2
  35. package/templates/api/scripts/ports.mjs +508 -0
  36. package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
  37. package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
  38. package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
  39. package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
  40. package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
  41. package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
  42. package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
  43. package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
  44. package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
  45. package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
  46. package/templates/api/src/infra/auth/auth.cli.ts +5 -0
  47. package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
  48. package/templates/api/src/infra/config/env.ts +47 -0
  49. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
  50. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
  51. package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
  52. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
  53. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
  54. package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
  55. package/templates/api/src/infra/di/index.ts +32 -0
  56. package/templates/api/src/infra/di/users.di.ts +7 -0
  57. package/templates/api/src/infra/http/app.ts +99 -0
  58. package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
  59. package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
  60. package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
  61. package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
  62. package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
  63. package/templates/api/src/infra/http/errors/http-error.ts +8 -0
  64. package/templates/api/src/infra/http/fastify.d.ts +17 -0
  65. package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
  66. package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
  67. package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
  68. package/templates/api/src/infra/http/server.ts +34 -0
  69. package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
  70. package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
  71. package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
  72. package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
  73. package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
  74. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
  75. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
  76. package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
  77. package/templates/api/test/infra/http/app.spec.ts +21 -0
  78. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
  79. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
  80. package/templates/api/test/support/factories/profile.factory.ts +25 -0
  81. package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
  82. package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
  83. package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
  84. package/templates/api/tsconfig.json +3 -6
  85. package/templates/api/vitest.config.mts +4 -4
  86. package/templates/mobile/.env.example +3 -0
  87. package/templates/mobile/README.md +10 -0
  88. package/templates/mobile/jest.config.js +5 -1
  89. package/templates/mobile/package.json +3 -3
  90. package/templates/mobile/scripts/ports.mjs +508 -0
  91. package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
  92. package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  93. package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
  94. package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  95. package/templates/mobile/tsconfig.json +2 -1
  96. package/templates/site/.env.example +2 -0
  97. package/templates/site/README.md +12 -1
  98. package/templates/site/gitignore +1 -0
  99. package/templates/site/package.json +1 -1
  100. package/templates/site/scripts/ports.mjs +508 -0
  101. package/templates/site/{src → test}/app/page.spec.ts +1 -1
  102. package/templates/site/tsconfig.json +2 -1
  103. package/templates/site/vitest.config.mts +4 -2
  104. package/templates/web/.env.example +3 -0
  105. package/templates/web/README.md +10 -1
  106. package/templates/web/package.json +5 -3
  107. package/templates/web/scripts/ports.mjs +508 -0
  108. package/templates/web/src/features/auth/auth-client.ts +5 -0
  109. package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
  110. package/templates/web/src/features/oauth/consent-form.tsx +63 -0
  111. package/templates/web/src/routeTree.gen.ts +21 -2
  112. package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
  113. package/templates/web/{src → test}/app.spec.tsx +2 -2
  114. package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  115. package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
  116. package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  117. package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
  118. package/templates/web/tsconfig.json +2 -1
  119. package/templates/web/vite.config.ts +3 -3
  120. package/templates/workspace/README.md +7 -3
  121. package/templates/workspace/scripts/ports.mjs +508 -0
  122. package/.prumo-templates/api/modules.md +0 -117
  123. package/templates/api/nest-cli.json +0 -10
  124. package/templates/api/src/app.module.ts +0 -46
  125. package/templates/api/src/auth/auth.constants.ts +0 -2
  126. package/templates/api/src/auth/auth.controller.ts +0 -19
  127. package/templates/api/src/auth/auth.guard.ts +0 -43
  128. package/templates/api/src/auth/auth.module.ts +0 -30
  129. package/templates/api/src/auth/auth.ts +0 -4
  130. package/templates/api/src/auth/current-user.decorator.ts +0 -9
  131. package/templates/api/src/auth/express.d.ts +0 -10
  132. package/templates/api/src/auth/public.decorator.ts +0 -4
  133. package/templates/api/src/config/env.module.ts +0 -11
  134. package/templates/api/src/config/env.ts +0 -81
  135. package/templates/api/src/errors/problem-details.filter.ts +0 -86
  136. package/templates/api/src/errors/validation-exception.factory.ts +0 -30
  137. package/templates/api/src/health/health.controller.ts +0 -32
  138. package/templates/api/src/health/health.module.ts +0 -9
  139. package/templates/api/src/main.ts +0 -43
  140. package/templates/api/src/mikro-orm.factory.ts +0 -14
  141. package/templates/api/src/request-context/request-context.module.ts +0 -9
  142. package/templates/api/src/request-context/request-context.service.ts +0 -17
  143. package/templates/api/src/request-context/request-id.middleware.ts +0 -19
  144. package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
  145. package/templates/api/src/users/entities/profile.entity.ts +0 -27
  146. package/templates/api/src/users/users.controller.ts +0 -23
  147. package/templates/api/src/users/users.module.ts +0 -10
  148. package/templates/api/src/users/users.service.spec.ts +0 -44
  149. package/templates/api/src/users/users.service.ts +0 -36
  150. package/templates/api/test/factories/profile.factory.ts +0 -26
  151. package/templates/api/tsconfig.build.json +0 -8
  152. /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
  153. /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  154. /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  155. /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
@@ -0,0 +1,54 @@
1
+ # MCP server
2
+
3
+ ## Rule
4
+
5
+ Serve MCP at `POST /api/mcp`, in `src/infra/http/controllers/mcp.controller.ts`. Protect it with
6
+ `requireMcpAuth` from `@better-auth/mcp`, with the same `resource` as `mcp()` in `auth.factory.ts`:
7
+ `mcpResource(env)`. The route is `public: true` for the session hook, because it answers a bearer access
8
+ token, never a cookie.
9
+
10
+ Build the server per request in `src/infra/mcp/mcp.server.ts` with `createMcpHandler` from
11
+ `@modelcontextprotocol/server`, `legacy: 'reject'`. The user is the token's `sub`, passed through
12
+ `authInfo.extra.userId`.
13
+
14
+ Write a module's tools in `src/infra/mcp/tools/<module>.tool.ts`. A tool resolves a use case and calls
15
+ `execute()`, exactly as a route handler does. Take its input schema from the module's `dto/`, and pass its
16
+ result through the module's output schema with `.parse()` before returning it.
17
+
18
+ Keep Better Auth the authorization server: `jwt()`, `mcp()` and `cimd()` in `auth.factory.ts`, discovery at
19
+ `/.well-known/*`, sign-in and consent on the web app. Do not enable dynamic client registration.
20
+
21
+ ## Rationale
22
+
23
+ MCP is a second edge of the product, beside HTTP, so it reaches the domain the same way: through use cases,
24
+ with the user's identity as an argument. A tool that queried a repository would bypass the rules a route
25
+ obeys.
26
+
27
+ `.parse()` is required here and not in a route because Fastify drops a field the response schema does not
28
+ list and MCP does not: a tool returns whatever it is handed, so an entity passed straight through publishes
29
+ every column.
30
+
31
+ `legacy: 'reject'` and CIMD follow the MCP 2026-07-28 profile, which Better Auth's plugin targets. The
32
+ protocol is stateless, so a server per request costs nothing and knows only its caller. Dynamic client
33
+ registration stays off because that profile deprecates it.
34
+
35
+ ## Applies to
36
+
37
+ `src/infra/mcp/`, `mcp.controller.ts`, the MCP part of `auth.factory.ts`, and the web app's `/consent` route.
38
+
39
+ ## Examples
40
+
41
+ ```
42
+ ✅ async () => asResult(await container.resolve(FindProfileUseCase).execute(userId))
43
+ ❌ async () => ({ structuredContent: await em.findOne(Profile, { userId }) })
44
+
45
+ ✅ structuredContent: profileResponseSchema.parse(profile)
46
+ ❌ structuredContent: profile every column leaves
47
+ ```
48
+
49
+ ## Enforcement
50
+
51
+ **Tests.** `mcp.controller.spec.ts` walks the whole authorization, consent included, connects the SDK's
52
+ own client, and asserts a tool returns only the public fields.
53
+
54
+ **Review only.** That a new tool goes through a use case and parses its output.
@@ -38,9 +38,9 @@ elsewhere, and an import breaks with nothing having changed. The repetition this
38
38
  every entry is the identical line `"react": "catalog:"`, not a version to keep in sync.
39
39
 
40
40
  An app depending on another app is tempting (importing a type from the API looks like the way to stop
41
- duplicating the contract), and it is wrong twice. **The type is the wrong one:** a response is an entity
42
- passed through `ClassSerializerInterceptor`, so every excluded property is absent from the JSON and present
43
- on the type, promising fields the payload never carries. **And an app is deployable:** the dependency means
41
+ duplicating the contract), and it is wrong twice. **The type is the wrong one:** a response passes through
42
+ the route's output schema, which drops every field it does not list, so a property can be present on the
43
+ type and absent from the JSON, promising fields the payload never carries. **And an app is deployable:** the dependency means
44
44
  building one requires the other, CI needs both for either, and the boundary between two things with
45
45
  separate lifecycles stops existing.
46
46
 
@@ -82,7 +82,7 @@ with generic code, which is the code that belonged in the package.
82
82
  **A Vite app and a Next app consume the package as source with no configuration**: Next 16 needs no
83
83
  `transpilePackages`; a page importing it builds, stays static, and renders the imported value. Its `exports` points at `src/index.ts`,
84
84
  `tsc` with `moduleResolution: bundler` typechecks through it, and `vite build` bundles it, verified in a composed
85
- workspace. The API does not import the package, so Nest never compiles it.
85
+ workspace. The API does not import the package, so its SWC build never compiles it.
86
86
 
87
87
  **Expo resolves workspace packages automatically from SDK 52**, with no `watchFolders` or
88
88
  `nodeModulesPaths` configuration. Older guidance about configuring Metro by hand no longer applies. Verified with
package/README.md CHANGED
@@ -52,7 +52,7 @@ terminal a missing answer is an error rather than a default, so nothing importan
52
52
  silence:
53
53
 
54
54
  ```sh
55
- npx @stjoseph/prumo new my-app --types api,web --single-tenant
55
+ npx @stjoseph/prumo new my-app --types api,web --single-tenant --no-mcp
56
56
  ```
57
57
 
58
58
  | Flag | What it answers |
@@ -60,6 +60,7 @@ npx @stjoseph/prumo new my-app --types api,web --single-tenant
60
60
  | `--types api,web,mobile,site` | What the project contains. More than one type makes a workspace |
61
61
  | `--alone` or `--monorepo` | How a single type is laid out |
62
62
  | `--multi-tenant` or `--single-tenant` | Whether the application serves several tenants |
63
+ | `--mcp` or `--no-mcp` | With an api and a web: whether AI assistants reach the API through MCP, signed in as the user |
63
64
  | `--skip-install` | Stops after writing the files |
64
65
 
65
66
  ## Commands
@@ -81,7 +82,7 @@ is asked, and the exit code is 0 only when `ok` is true.
81
82
 
82
83
  | Type | What you get |
83
84
  |---|---|
84
- | `api` | NestJS, MikroORM on PostgreSQL, Better Auth in a schema of its own, Swagger, Vitest with Testcontainers |
85
+ | `api` | Fastify with a domain/infra structure, tsyringe, Zod, MikroORM on PostgreSQL, Better Auth in a schema of its own, Swagger UI, Vitest with Testcontainers |
85
86
  | `web` | Vite, React, TanStack Router and Query, Tailwind, shadcn/ui, forms validated with Zod |
86
87
  | `mobile` | Expo with Expo Router, NativeWind, MMKV, and the session kept in `expo-secure-store` |
87
88
  | `site` | Next on the App Router, static by default, for pages that have to be indexed |
@@ -117,14 +118,19 @@ Once written, those documents belong to the team. Prumo does not come back and r
117
118
  |---|---|
118
119
  | [`docs/stack.md`](https://github.com/stjosephworks/prumo/blob/main/docs/stack.md) | What is locked, what was deliberately dropped, and the version floors |
119
120
  | [`docs/structure.md`](https://github.com/stjosephworks/prumo/blob/main/docs/structure.md) | The `.prumo/` layout, and the rule deciding which areas a project receives |
120
- | [`docs/DECISIONS.md`](https://github.com/stjosephworks/prumo/blob/main/docs/DECISIONS.md) | Every decision, why it won, and what it costs |
121
- | [`docs/OPEN-QUESTIONS.md`](https://github.com/stjosephworks/prumo/blob/main/docs/OPEN-QUESTIONS.md) | What has been raised and is still unresolved |
121
+ | [`docs/document-template.md`](https://github.com/stjosephworks/prumo/blob/main/docs/document-template.md) | The shape every convention document fills |
122
+ | [`docs/maintaining-templates.md`](https://github.com/stjosephworks/prumo/blob/main/docs/maintaining-templates.md) | How a template is changed, and how the change is proved |
123
+
124
+ ## Releasing
125
+
126
+ `main` publishes itself. A push to `main` runs the whole CI, and when `cli/package.json` carries a version the
127
+ registry does not have yet, the `publish` job sends it to npm through npm's trusted publishing, with provenance and
128
+ without a token in the repository. Any other push to `main` publishes nothing.
122
129
 
123
130
  ## Status
124
131
 
125
132
  Prumo is in beta. `0.0.1` is the first published version: every template runs and every generated
126
133
  project is verified in CI, and what a generated project looks like can still change before 1.0.
127
- What is known to be missing is listed in [`docs/OPEN-QUESTIONS.md`](https://github.com/stjosephworks/prumo/blob/main/docs/OPEN-QUESTIONS.md), and
128
- updating a project's `.prumo/` in place comes after 1.0.
134
+ Updating a project's `.prumo/` in place comes after 1.0.
129
135
 
130
136
  MIT licensed. See [`LICENSE`](https://github.com/stjosephworks/prumo/blob/main/LICENSE).
package/dist/asker.js CHANGED
@@ -24,5 +24,9 @@ export function terminalAsker(validateName) {
24
24
  initialValue: 'alone',
25
25
  })),
26
26
  multiTenant: async () => answered(await prompt.confirm({ message: 'Is it multi-tenant?', initialValue: false })),
27
+ mcp: async () => answered(await prompt.confirm({
28
+ message: 'Let AI assistants use the API through MCP, signed in as the user?',
29
+ initialValue: false,
30
+ })),
27
31
  };
28
32
  }
package/dist/clean.js CHANGED
@@ -17,7 +17,7 @@ export const README_PARAGRAPH = '`DATABASE_URL` starts as `MISSING`. While it is
17
17
  const README_REPLACEMENT = 'Before the first `pnpm dev`, start Postgres with `docker compose up -d --wait` and run `pnpm db:migrate`.\n' +
18
18
  '`.env.example` points at that service on port 5432; when `POSTGRES_PORT` changes, change both URLs with it.\n';
19
19
  export const README_ROW = '| `pnpm db:setup` | Creates a development database in Docker, writes its URL into `.env`, and migrates |\n';
20
- export const WORKSPACE_SENTENCE = 'If the workspace holds `api` and its database is not set up yet, it offers to create one first. ';
20
+ export const WORKSPACE_SENTENCE = 'If the workspace holds `api` and its database is not set up yet, `pnpm dev` offers to create the database first.\n\n';
21
21
  function textRule(id, file, description, from, to, marker) {
22
22
  return {
23
23
  id,
@@ -55,7 +55,7 @@ function rulesFor(root, api, templates) {
55
55
  textRule('api-readme-row', at('README.md'), 'Remove `db:setup` from the README', README_ROW, '', '`pnpm db:setup`'),
56
56
  ];
57
57
  if (api !== root) {
58
- rules.push(devRule('root-dev-check', 'package.json', ROOT_DEV_PREFIX), textRule('root-readme', 'README.md', 'Remove the database offer from the README', WORKSPACE_SENTENCE, '', 'offers to create one'));
58
+ rules.push(devRule('root-dev-check', 'package.json', ROOT_DEV_PREFIX), textRule('root-readme', 'README.md', 'Remove the database offer from the README', WORKSPACE_SENTENCE, '', 'offers to create the database'));
59
59
  }
60
60
  return { rules, script: join(templates, 'api', 'scripts', 'database.mjs') };
61
61
  }
package/dist/cli.js CHANGED
@@ -38,6 +38,8 @@ async function runNew(args, json) {
38
38
  monorepo: { type: 'boolean', default: false },
39
39
  'multi-tenant': { type: 'boolean', default: false },
40
40
  'single-tenant': { type: 'boolean', default: false },
41
+ mcp: { type: 'boolean', default: false },
42
+ 'no-mcp': { type: 'boolean', default: false },
41
43
  'skip-install': { type: 'boolean', default: false },
42
44
  json: { type: 'boolean', default: false },
43
45
  },
@@ -54,6 +56,8 @@ async function runNew(args, json) {
54
56
  monorepo: values.monorepo,
55
57
  multiTenant: values['multi-tenant'],
56
58
  singleTenant: values['single-tenant'],
59
+ mcp: values.mcp,
60
+ noMcp: values['no-mcp'],
57
61
  }, interactive ? terminalAsker(validateProjectName) : undefined);
58
62
  if (!json && answers.types.length > 1) {
59
63
  prompt.log.info(`${answers.types.length} types make a workspace: apps/ and packages/ under one root.`);
package/dist/commands.js CHANGED
@@ -13,6 +13,8 @@ export const COMMANDS = [
13
13
  { flag: '--monorepo', description: 'A workspace, even for a single type' },
14
14
  { flag: '--multi-tenant', description: 'Tenant-aware api, web and mobile' },
15
15
  { flag: '--single-tenant', description: 'No tenancy' },
16
+ { flag: '--mcp', description: 'An MCP server on the api, authorized through the web' },
17
+ { flag: '--no-mcp', description: 'No MCP server' },
16
18
  { flag: '--skip-install', description: 'Do not run pnpm install' },
17
19
  JSON_OPTION,
18
20
  ],
package/dist/compose.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join, relative } from 'node:path';
4
4
  import { prependProperty, readJsonc, removeWhatBaseDeclares, setJsonc } from "./jsonc.js";
5
+ import { applyMcp } from "./mcp.js";
5
6
  const CLIENTS = ['web', 'mobile'];
6
7
  // One exception to one-version-per-dependency, recorded in DECISIONS.md: stable NativeWind needs Tailwind 3.
7
8
  const NAMED_CATALOGS = [{ name: 'tailwind3', dependency: 'tailwindcss', major: '3' }];
@@ -181,12 +182,15 @@ async function assertCatalogCovers(target, catalog) {
181
182
  }
182
183
  }
183
184
  }
184
- export async function composeWorkspace({ templates, target, types, mobileScheme = 'app', }) {
185
+ export async function composeWorkspace({ templates, target, types, mobileScheme = 'app', mcp = false, }) {
185
186
  await mkdir(target, { recursive: true });
186
187
  await copyTemplate(join(templates, 'workspace'), target);
187
188
  const apps = [];
188
189
  for (const type of types) {
189
- apps.push(await copyApp(templates, target, type));
190
+ const app = await copyApp(templates, target, type);
191
+ // Before the catalog is collected, so a dependency that leaves never reaches it.
192
+ await applyMcp(app, type, mcp);
193
+ apps.push(app);
190
194
  }
191
195
  const clients = CLIENTS.filter((client) => types.includes(client));
192
196
  if (clients.length > 0) {
@@ -204,12 +208,12 @@ export async function composeWorkspace({ templates, target, types, mobileScheme
204
208
  });
205
209
  }
206
210
  // One command for everything and one per app. Each app answers to `dev`, so the root never needs to know how.
207
- // The API's database check is the exception: it may ask a question, which the interleaved output of a parallel
208
- // run would bury, so the root asks it first and the API's own check then finds nothing to do.
209
- const parallelDev = 'pnpm -r --parallel dev';
210
- const dev = types.includes('api')
211
- ? `node apps/api/scripts/database.mjs --check && ${parallelDev}`
212
- : parallelDev;
211
+ // The database and the ports are the exception: both may ask a question, which the interleaved output of a
212
+ // parallel run would bury, so the root asks first and each app's own check then finds nothing to do.
213
+ // `pnpm -r --parallel` starts each app in its own process group, so Ctrl+C reaches pnpm and leaves the apps
214
+ // running. The ports script wraps the run, remembers what it started, and stops all of it on the way out.
215
+ const database = types.includes('api') ? 'node apps/api/scripts/database.mjs --check && ' : '';
216
+ const dev = `${database}node scripts/ports.mjs --all -- pnpm -r --parallel dev`;
213
217
  await rewriteText(join(target, 'package.json'), (text) => types.reduce((pkg, type) => setJsonc(pkg, ['scripts', type], `pnpm --filter ${type} dev`), setJsonc(text, ['scripts', 'dev'], dev)));
214
218
  const manifests = await Promise.all(apps.map(async (app) => readJsonc(await readFile(join(app, 'package.json'), 'utf8'))));
215
219
  const catalogs = collectCatalog(manifests);
package/dist/context.js CHANGED
@@ -10,6 +10,7 @@ export function areasFor(answers) {
10
10
  ...CLIENTS.filter(has),
11
11
  ...(answers.architecture === 'monorepo' ? ['monorepo'] : []),
12
12
  ...(answers.multiTenant ? ['multi-tenancy'] : []),
13
+ ...(answers.mcp ? ['mcp'] : []),
13
14
  ];
14
15
  }
15
16
  async function titleOf(path) {
@@ -31,6 +32,7 @@ export async function writeContext(knowledge, root, answers) {
31
32
  types: answers.types,
32
33
  architecture: answers.architecture,
33
34
  multiTenant: answers.multiTenant,
35
+ mcp: answers.mcp,
34
36
  };
35
37
  await writeFile(join(prumo, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
36
38
  await writeFile(join(root, 'AGENTS.md'), `# ${answers.name}\n\nThis project's conventions live in \`.prumo/\`. Read [\`.prumo/INDEX.md\`](.prumo/INDEX.md) before changing how something is done.\n`);
package/dist/generate.js CHANGED
@@ -6,6 +6,7 @@ import { join } from 'node:path';
6
6
  import { composeWorkspace, copyTemplate } from "./compose.js";
7
7
  import { writeContext } from "./context.js";
8
8
  import { setJsonc } from "./jsonc.js";
9
+ import { applyMcp } from "./mcp.js";
9
10
  import { schemeFor } from "./names.js";
10
11
  import { CliError } from "./output.js";
11
12
  async function rewrite(path, change) {
@@ -66,6 +67,7 @@ export async function generate({ templates, knowledge, target, answers, install,
66
67
  const [only] = answers.types;
67
68
  if (answers.architecture === 'alone' && only !== undefined) {
68
69
  await copyTemplate(join(templates, only), target);
70
+ await applyMcp(target, only, answers.mcp);
69
71
  await nameProject(target, answers.name);
70
72
  await writeLocalEnv(target, only);
71
73
  if (only === 'api') {
@@ -81,6 +83,7 @@ export async function generate({ templates, knowledge, target, answers, install,
81
83
  target,
82
84
  types: answers.types,
83
85
  mobileScheme: schemeFor(answers.name),
86
+ mcp: answers.mcp,
84
87
  });
85
88
  await nameProject(target, answers.name);
86
89
  for (const type of answers.types) {
@@ -99,5 +102,10 @@ export async function generate({ templates, knowledge, target, answers, install,
99
102
  run('pnpm', ['install'], target, childOutput);
100
103
  // Rewriting the contract import changes import grouping and line length; only the formatter can settle both.
101
104
  run('pnpm', ['exec', 'biome', 'check', '--write'], target, childOutput);
105
+ // The route tree is generated from the route files, and cutting MCP removed one.
106
+ if (answers.types.includes('web')) {
107
+ const web = answers.architecture === 'alone' ? target : join(target, 'apps', 'web');
108
+ run('pnpm', ['exec', 'tsr', 'generate'], web, childOutput);
109
+ }
102
110
  }
103
111
  }
package/dist/mcp.js ADDED
@@ -0,0 +1,100 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { setJsonc } from "./jsonc.js";
5
+ // The api and web templates ship with MCP. A project that answered no receives them without it: these files are
6
+ // deleted, these dependencies removed, and every marked line or block cut. Every other type has nothing to cut.
7
+ const FILES = {
8
+ api: [
9
+ 'src/infra/mcp',
10
+ 'src/infra/http/controllers/mcp.controller.ts',
11
+ 'test/infra/http/controllers/mcp.controller.spec.ts',
12
+ ],
13
+ web: ['src/features/oauth', 'src/routes/_authenticated/consent.tsx', 'test/features/oauth'],
14
+ };
15
+ const DEPENDENCIES = {
16
+ api: [
17
+ '@better-auth/cimd',
18
+ '@better-auth/mcp',
19
+ '@modelcontextprotocol/client',
20
+ '@modelcontextprotocol/server',
21
+ ],
22
+ web: ['@better-auth/oauth-provider'],
23
+ };
24
+ const BLOCK_START = /^\s*\/\/ prumo:mcp$/;
25
+ const BLOCK_END = /^\s*\/\/ prumo:end-mcp$/;
26
+ // A marked import keeps its marker as a trailing comment, which the import sorter carries along.
27
+ const MARKED_LINE = /\s*\/\/ prumo:mcp$/;
28
+ export function cutMcp(text, keep, file = 'a file') {
29
+ const kept = [];
30
+ let inBlock = false;
31
+ for (const line of text.split('\n')) {
32
+ if (BLOCK_START.test(line)) {
33
+ if (inBlock) {
34
+ throw new Error(`${file} opens a prumo:mcp block inside another`);
35
+ }
36
+ inBlock = true;
37
+ }
38
+ else if (BLOCK_END.test(line)) {
39
+ if (!inBlock) {
40
+ throw new Error(`${file} closes a prumo:mcp block it never opened`);
41
+ }
42
+ inBlock = false;
43
+ }
44
+ else if (MARKED_LINE.test(line)) {
45
+ if (keep) {
46
+ kept.push(line.replace(MARKED_LINE, ''));
47
+ }
48
+ }
49
+ else if (keep || !inBlock) {
50
+ kept.push(line);
51
+ }
52
+ }
53
+ if (inBlock) {
54
+ throw new Error(`${file} leaves a prumo:mcp block open`);
55
+ }
56
+ return kept.join('\n');
57
+ }
58
+ async function sourceFiles(directory) {
59
+ if (!existsSync(directory)) {
60
+ return [];
61
+ }
62
+ const entries = await readdir(directory, { withFileTypes: true, recursive: true });
63
+ return entries
64
+ .filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name))
65
+ .map((entry) => join(entry.parentPath, entry.name));
66
+ }
67
+ export async function applyMcp(app, type, enabled) {
68
+ const files = FILES[type];
69
+ const dependencies = DEPENDENCIES[type];
70
+ if (files === undefined || dependencies === undefined) {
71
+ return;
72
+ }
73
+ if (!enabled) {
74
+ for (const file of files) {
75
+ await rm(join(app, file), { recursive: true, force: true });
76
+ }
77
+ const manifest = join(app, 'package.json');
78
+ let text = await readFile(manifest, 'utf8');
79
+ for (const section of ['dependencies', 'devDependencies']) {
80
+ for (const dependency of dependencies) {
81
+ text = setJsonc(text, [section, dependency], undefined);
82
+ }
83
+ }
84
+ await writeFile(manifest, text);
85
+ }
86
+ const sources = [
87
+ ...(await sourceFiles(join(app, 'src'))),
88
+ ...(await sourceFiles(join(app, 'test'))),
89
+ ];
90
+ for (const source of sources) {
91
+ const text = await readFile(source, 'utf8');
92
+ const cut = cutMcp(text, enabled, source);
93
+ if (!enabled && dependencies.some((dependency) => cut.includes(`'${dependency}`))) {
94
+ throw new Error(`${source} still imports an MCP package outside a prumo:mcp marker`);
95
+ }
96
+ if (cut !== text) {
97
+ await writeFile(source, cut);
98
+ }
99
+ }
100
+ }
package/dist/questions.js CHANGED
@@ -17,6 +17,9 @@ export async function resolveAnswers(flags, asker) {
17
17
  if (flags.alone && flags.monorepo) {
18
18
  throw new CliError('invalid_input', 'Choose --alone or --monorepo, not both.');
19
19
  }
20
+ if (flags.mcp && flags.noMcp) {
21
+ throw new CliError('invalid_input', 'Choose --mcp or --no-mcp, not both.');
22
+ }
20
23
  if (flags.multiTenant && flags.singleTenant) {
21
24
  throw new CliError('invalid_input', 'Choose --multi-tenant or --single-tenant, not both.');
22
25
  }
@@ -60,5 +63,22 @@ export async function resolveAnswers(flags, asker) {
60
63
  missing('--multi-tenant or --single-tenant');
61
64
  }
62
65
  }
63
- return { name, types, architecture, multiTenant };
66
+ // MCP authorizes through a sign-in and a consent page, which only a web app can serve.
67
+ const mcpCapable = types.includes('api') && types.includes('web');
68
+ let mcp = false;
69
+ if (!mcpCapable) {
70
+ if (flags.mcp) {
71
+ throw new CliError('invalid_input', '--mcp needs both api and web in --types.');
72
+ }
73
+ }
74
+ else if (flags.mcp || flags.noMcp) {
75
+ mcp = flags.mcp;
76
+ }
77
+ else if (asker !== undefined) {
78
+ mcp = await asker.mcp();
79
+ }
80
+ else {
81
+ missing('--mcp or --no-mcp');
82
+ }
83
+ return { name, types, architecture, multiTenant, mcp };
64
84
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@stjoseph/prumo",
3
- "version": "0.0.5",
3
+ "version": "0.1.0",
4
4
  "description": "Scaffolds a TypeScript project and ships it the conventions it follows, in a .prumo/ folder any AI assistant reads.",
5
5
  "keywords": [
6
6
  "scaffolding",
7
7
  "cli",
8
8
  "typescript",
9
- "nestjs",
9
+ "fastify",
10
+ "mcp",
10
11
  "expo",
11
12
  "monorepo",
12
13
  "conventions",
@@ -35,7 +36,7 @@
35
36
  "scripts": {
36
37
  "lint": "biome check",
37
38
  "typecheck": "tsc --noEmit",
38
- "test": "vitest run --dir src",
39
+ "test": "vitest run --dir test",
39
40
  "prumo": "node src/cli.ts",
40
41
  "build": "tsc -p tsconfig.build.json && node scripts/copy-assets.mjs",
41
42
  "prepack": "pnpm build"
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://swc.rs/schema.json",
3
+ "jsc": {
4
+ "parser": { "syntax": "typescript", "decorators": true },
5
+ "transform": { "legacyDecorator": true, "decoratorMetadata": true },
6
+ "target": "es2023",
7
+ "keepClassNames": true,
8
+ "baseUrl": "./",
9
+ "paths": { "@/*": ["./src/*"] }
10
+ },
11
+ "module": { "type": "commonjs" },
12
+ "sourceMaps": true
13
+ }
@@ -19,7 +19,16 @@ starts Postgres from `docker-compose.yml` on port 5432 or the next free one (kep
19
19
  into `.env`, and migrates. `pnpm db:setup` does the same at any time. Without a terminal every answer comes from a
20
20
  flag: `--name`, `--port`, `--skip-migrate`, and `--json` for one JSON result on stdout.
21
21
 
22
- The API listens on `http://localhost:3000`. Outside production, its documentation is at `/api/docs`.
22
+ The API listens on the port in `PORT`, `http://localhost:3000` by default. Outside production, its documentation is
23
+ at `/api/docs`.
24
+
25
+ Before it starts, `pnpm dev` checks that the port is free. When something else holds it, it says what does, by
26
+ name and pid, and asks whether to stop that process or to move this app to the next free port. Moving it writes the
27
+ new port to `.env`, along with every URL that pointed at the old one. Without a terminal, pass `--kill` or
28
+ `--change` to `scripts/ports.mjs`, or the command stops rather than choosing for you.
29
+
30
+ Ctrl+C stops everything `pnpm dev` started, not only what the terminal signals: the script remembers the processes
31
+ it spawned, stops them, and frees the port. Anything it did not start is named and left alone.
23
32
 
24
33
  `pnpm db:migrate` builds first, then runs Better Auth's migrations and then the application's. Before
25
34
  migrating, Better Auth logs `Database schema mismatch` because its tables do not exist yet; the migration
@@ -6,13 +6,15 @@
6
6
  "linter": {
7
7
  "rules": {
8
8
  "preset": "recommended",
9
- // Nest injects by the emitted constructor type; `import type` erases it and breaks injection silently.
10
- "style": { "useImportType": "off" }
9
+ // tsyringe injects by the emitted constructor type; `import type` erases it and breaks injection silently.
10
+ "style": { "useImportType": "off" },
11
+ // A file that only re-exports is how an invisible import cycle forms.
12
+ "performance": { "noBarrelFile": "error" }
11
13
  }
12
14
  },
13
15
  "javascript": {
14
16
  "formatter": { "quoteStyle": "single", "semicolons": "asNeeded" },
15
- // Nest decorates constructor and handler parameters; Biome cannot parse those without this.
17
+ // Ports are injected with `@inject(TOKEN)` on constructor parameters; Biome cannot parse those without this.
16
18
  "parser": { "unsafeParameterDecoratorsEnabled": true }
17
19
  }
18
20
  }
@@ -3,57 +3,51 @@
3
3
  "version": "0.0.1",
4
4
  "private": true,
5
5
  "scripts": {
6
- "build": "nest build",
7
- "start": "nest start",
8
- "start:dev": "nest start --watch",
9
- "dev": "node scripts/database.mjs --check && pnpm start:dev",
10
- "start:prod": "node dist/main",
6
+ "build": "swc src -d dist --strip-leading-paths --delete-dir-on-start --ignore \"**/*.spec.ts,**/*.d.ts\"",
7
+ "start": "node dist/infra/http/server.js",
8
+ "start:dev": "node --watch -r @swc-node/register src/infra/http/server.ts",
9
+ "dev": "node scripts/database.mjs --check && node scripts/ports.mjs -- pnpm start:dev",
11
10
  "lint": "biome check",
12
11
  "typecheck": "tsc --noEmit",
13
12
  "test": "vitest run",
14
13
  "test:watch": "vitest",
15
14
  "test:cov": "vitest run --coverage",
16
15
  "db:setup": "node scripts/database.mjs",
17
- "db:migrate": "nest build && auth migrate --config dist/auth/auth.js --yes && mikro-orm migration:up",
16
+ "db:migrate": "pnpm build && auth migrate --config dist/infra/auth/auth.cli.js --yes && mikro-orm migration:up",
18
17
  "db:migration:create": "mikro-orm migration:create",
19
18
  "prepare": "node .githooks/install.mjs"
20
19
  },
21
20
  "dependencies": {
22
- "@better-auth/expo": "1.7.4",
21
+ "@better-auth/cimd": "1.7.6",
22
+ "@better-auth/expo": "1.7.6",
23
+ "@better-auth/mcp": "1.7.6",
24
+ "@fastify/cors": "11.3.0",
25
+ "@fastify/swagger": "9.9.0",
26
+ "@fastify/swagger-ui": "6.1.1",
23
27
  "@mikro-orm/core": "7.2.0",
24
28
  "@mikro-orm/migrations": "7.2.0",
25
- "@mikro-orm/nestjs": "7.1.0",
26
29
  "@mikro-orm/postgresql": "7.2.0",
27
- "@nestjs/common": "12.0.1",
28
- "@nestjs/config": "12.0.0",
29
- "@nestjs/core": "12.0.1",
30
- "@nestjs/platform-express": "12.0.1",
31
- "@nestjs/swagger": "12.0.1",
32
- "@nestjs/terminus": "12.0.0",
33
- "better-auth": "1.7.4",
34
- "class-transformer": "0.5.1",
35
- "class-validator": "0.15.1",
36
- "nestjs-pino": "5.1.0",
30
+ "@modelcontextprotocol/server": "2.2.0",
31
+ "better-auth": "1.7.6",
32
+ "fastify": "5.12.5",
33
+ "fastify-type-provider-zod": "7.0.0",
37
34
  "pg": "8.23.0",
38
- "pino": "10.3.1",
39
- "pino-http": "11.0.0",
40
35
  "reflect-metadata": "0.2.2",
41
- "rxjs": "7.8.2"
36
+ "tsyringe": "4.10.0",
37
+ "zod": "4.6.4"
42
38
  },
43
39
  "devDependencies": {
44
40
  "@biomejs/biome": "2.5.13",
45
41
  "@mikro-orm/cli": "7.2.0",
46
- "@nestjs/cli": "12.0.0",
47
- "@nestjs/schematics": "12.0.1",
48
- "@nestjs/testing": "12.0.1",
42
+ "@modelcontextprotocol/client": "2.2.0",
49
43
  "@swc-node/register": "1.12.1",
44
+ "@swc/cli": "0.8.1",
50
45
  "@swc/core": "1.16.2",
51
46
  "@testcontainers/postgresql": "12.1.0",
52
- "@types/express": "5.0.6",
53
47
  "@types/node": "24.13.4",
54
48
  "@types/pg": "8.23.1",
55
49
  "@vitest/coverage-v8": "4.1.11",
56
- "auth": "1.7.4",
50
+ "auth": "1.7.6",
57
51
  "pino-pretty": "13.1.3",
58
52
  "typescript": "6.0.3",
59
53
  "unplugin-swc": "1.6.0",
@@ -64,8 +58,8 @@
64
58
  },
65
59
  "mikro-orm": {
66
60
  "configPaths": [
67
- "./src/mikro-orm.config.ts",
68
- "./dist/mikro-orm.config.js"
61
+ "./src/infra/database/mikroorm/mikro-orm.config.ts",
62
+ "./dist/infra/database/mikroorm/mikro-orm.config.js"
69
63
  ]
70
64
  }
71
65
  }
@@ -1,6 +1,4 @@
1
1
  allowBuilds:
2
- # Its install script only sends usage telemetry; it arrives through @nestjs/swagger's swagger-ui-dist.
3
- '@scarf/scarf': false
4
2
  # Its platform binary arrives as an optional dependency, so the install script is not needed.
5
3
  '@swc/core': false
6
4
  # These three arrive through Testcontainers, and the suite runs without their native builds.