@stjoseph/prumo 0.0.6 → 0.1.1

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 (140) 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 +6 -6
  20. package/dist/asker.js +4 -0
  21. package/dist/cli.js +7 -8
  22. package/dist/commands.js +2 -0
  23. package/dist/compose.js +6 -2
  24. package/dist/context.js +5 -0
  25. package/dist/generate.js +8 -0
  26. package/dist/mcp.js +100 -0
  27. package/dist/questions.js +21 -1
  28. package/dist/version.js +7 -0
  29. package/package.json +4 -3
  30. package/templates/api/.swcrc +13 -0
  31. package/templates/api/biome.jsonc +5 -3
  32. package/templates/api/package.json +21 -27
  33. package/templates/api/pnpm-workspace.yaml +0 -2
  34. package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
  35. package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
  36. package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
  37. package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
  38. package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
  39. package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
  40. package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
  41. package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
  42. package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
  43. package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
  44. package/templates/api/src/infra/auth/auth.cli.ts +5 -0
  45. package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
  46. package/templates/api/src/infra/config/env.ts +47 -0
  47. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
  48. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
  49. package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
  50. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
  51. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
  52. package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
  53. package/templates/api/src/infra/di/index.ts +32 -0
  54. package/templates/api/src/infra/di/users.di.ts +7 -0
  55. package/templates/api/src/infra/http/app.ts +99 -0
  56. package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
  57. package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
  58. package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
  59. package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
  60. package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
  61. package/templates/api/src/infra/http/errors/http-error.ts +8 -0
  62. package/templates/api/src/infra/http/fastify.d.ts +17 -0
  63. package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
  64. package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
  65. package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
  66. package/templates/api/src/infra/http/server.ts +34 -0
  67. package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
  68. package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
  69. package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
  70. package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
  71. package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
  72. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
  73. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
  74. package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
  75. package/templates/api/test/infra/http/app.spec.ts +21 -0
  76. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
  77. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
  78. package/templates/api/test/support/factories/profile.factory.ts +25 -0
  79. package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
  80. package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
  81. package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
  82. package/templates/api/tsconfig.json +3 -6
  83. package/templates/api/vitest.config.mts +4 -4
  84. package/templates/mobile/jest.config.js +1 -1
  85. package/templates/mobile/package.json +2 -2
  86. package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
  87. package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  88. package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
  89. package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  90. package/templates/mobile/tsconfig.json +2 -1
  91. package/templates/site/{src → test}/app/page.spec.ts +1 -1
  92. package/templates/site/tsconfig.json +2 -1
  93. package/templates/site/vitest.config.mts +4 -2
  94. package/templates/web/package.json +4 -2
  95. package/templates/web/src/features/auth/auth-client.ts +5 -0
  96. package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
  97. package/templates/web/src/features/oauth/consent-form.tsx +63 -0
  98. package/templates/web/src/routeTree.gen.ts +21 -2
  99. package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
  100. package/templates/web/{src → test}/app.spec.tsx +2 -2
  101. package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  102. package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
  103. package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  104. package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
  105. package/templates/web/tsconfig.json +2 -1
  106. package/templates/web/vite.config.ts +3 -3
  107. package/.prumo-templates/api/modules.md +0 -117
  108. package/templates/api/nest-cli.json +0 -10
  109. package/templates/api/src/app.module.ts +0 -46
  110. package/templates/api/src/auth/auth.constants.ts +0 -2
  111. package/templates/api/src/auth/auth.controller.ts +0 -19
  112. package/templates/api/src/auth/auth.guard.ts +0 -43
  113. package/templates/api/src/auth/auth.module.ts +0 -30
  114. package/templates/api/src/auth/auth.ts +0 -4
  115. package/templates/api/src/auth/current-user.decorator.ts +0 -9
  116. package/templates/api/src/auth/express.d.ts +0 -10
  117. package/templates/api/src/auth/public.decorator.ts +0 -4
  118. package/templates/api/src/config/env.module.ts +0 -11
  119. package/templates/api/src/config/env.ts +0 -81
  120. package/templates/api/src/errors/problem-details.filter.ts +0 -86
  121. package/templates/api/src/errors/validation-exception.factory.ts +0 -30
  122. package/templates/api/src/health/health.controller.ts +0 -32
  123. package/templates/api/src/health/health.module.ts +0 -9
  124. package/templates/api/src/main.ts +0 -43
  125. package/templates/api/src/mikro-orm.factory.ts +0 -14
  126. package/templates/api/src/request-context/request-context.module.ts +0 -9
  127. package/templates/api/src/request-context/request-context.service.ts +0 -17
  128. package/templates/api/src/request-context/request-id.middleware.ts +0 -19
  129. package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
  130. package/templates/api/src/users/entities/profile.entity.ts +0 -27
  131. package/templates/api/src/users/users.controller.ts +0 -23
  132. package/templates/api/src/users/users.module.ts +0 -10
  133. package/templates/api/src/users/users.service.spec.ts +0 -44
  134. package/templates/api/src/users/users.service.ts +0 -36
  135. package/templates/api/test/factories/profile.factory.ts +0 -26
  136. package/templates/api/tsconfig.build.json +0 -8
  137. /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
  138. /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  139. /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  140. /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,8 +118,8 @@ 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 |
122
123
 
123
124
  ## Releasing
124
125
 
@@ -130,7 +131,6 @@ without a token in the repository. Any other push to `main` publishes nothing.
130
131
 
131
132
  Prumo is in beta. `0.0.1` is the first published version: every template runs and every generated
132
133
  project is verified in CI, and what a generated project looks like can still change before 1.0.
133
- 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
134
- updating a project's `.prumo/` in place comes after 1.0.
134
+ Updating a project's `.prumo/` in place comes after 1.0.
135
135
 
136
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/cli.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
3
- import { join, resolve } from 'node:path';
2
+ import { resolve } from 'node:path';
4
3
  import { parseArgs } from 'node:util';
5
4
  import * as prompt from '@clack/prompts';
6
5
  import { terminalAsker } from "./asker.js";
@@ -13,11 +12,7 @@ import { generate } from "./generate.js";
13
12
  import { validateProjectName } from "./names.js";
14
13
  import { CliError, errorEnvelope, writeEnvelope } from "./output.js";
15
14
  import { resolveAnswers } from "./questions.js";
16
- function version() {
17
- // src/cli.ts and dist/cli.js both sit one level below package.json.
18
- const pkg = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8'));
19
- return pkg.version;
20
- }
15
+ import { cliVersion } from "./version.js";
21
16
  function help(name) {
22
17
  if (name === undefined) {
23
18
  return { data: { commands: COMMANDS }, text: helpText() };
@@ -38,6 +33,8 @@ async function runNew(args, json) {
38
33
  monorepo: { type: 'boolean', default: false },
39
34
  'multi-tenant': { type: 'boolean', default: false },
40
35
  'single-tenant': { type: 'boolean', default: false },
36
+ mcp: { type: 'boolean', default: false },
37
+ 'no-mcp': { type: 'boolean', default: false },
41
38
  'skip-install': { type: 'boolean', default: false },
42
39
  json: { type: 'boolean', default: false },
43
40
  },
@@ -54,6 +51,8 @@ async function runNew(args, json) {
54
51
  monorepo: values.monorepo,
55
52
  multiTenant: values['multi-tenant'],
56
53
  singleTenant: values['single-tenant'],
54
+ mcp: values.mcp,
55
+ noMcp: values['no-mcp'],
57
56
  }, interactive ? terminalAsker(validateProjectName) : undefined);
58
57
  if (!json && answers.types.length > 1) {
59
58
  prompt.log.info(`${answers.types.length} types make a workspace: apps/ and packages/ under one root.`);
@@ -126,7 +125,7 @@ async function main(argv) {
126
125
  result = help(topic);
127
126
  }
128
127
  else if (name === 'version') {
129
- const current = version();
128
+ const current = cliVersion();
130
129
  result = { data: { version: current }, text: current };
131
130
  }
132
131
  else if (name === 'doctor') {
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) {
package/dist/context.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { cliVersion } from "./version.js";
3
4
  const CLIENTS = ['web', 'mobile', 'site'];
4
5
  export function areasFor(answers) {
5
6
  const has = (type) => answers.types.includes(type);
@@ -10,6 +11,7 @@ export function areasFor(answers) {
10
11
  ...CLIENTS.filter(has),
11
12
  ...(answers.architecture === 'monorepo' ? ['monorepo'] : []),
12
13
  ...(answers.multiTenant ? ['multi-tenancy'] : []),
14
+ ...(answers.mcp ? ['mcp'] : []),
13
15
  ];
14
16
  }
15
17
  async function titleOf(path) {
@@ -27,10 +29,13 @@ export async function writeContext(knowledge, root, answers) {
27
29
  }
28
30
  }
29
31
  await writeFile(join(prumo, 'INDEX.md'), `# Conventions\n\n${lines.join('\n')}\n`);
32
+ // The version that wrote the project, so a tool reading it can tell a project made by an older Prumo.
30
33
  const config = {
34
+ prumo: cliVersion(),
31
35
  types: answers.types,
32
36
  architecture: answers.architecture,
33
37
  multiTenant: answers.multiTenant,
38
+ mcp: answers.mcp,
34
39
  };
35
40
  await writeFile(join(prumo, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
36
41
  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
  }
@@ -0,0 +1,7 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ /** This CLI's own version. src/*.ts and dist/*.js both sit one level below package.json. */
4
+ export function cliVersion() {
5
+ const pkg = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8'));
6
+ return pkg.version;
7
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@stjoseph/prumo",
3
- "version": "0.0.6",
3
+ "version": "0.1.1",
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
+ }
@@ -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",
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
9
  "dev": "node scripts/database.mjs --check && node scripts/ports.mjs -- pnpm start:dev",
10
- "start:prod": "node dist/main",
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.
@@ -0,0 +1,5 @@
1
+ export type DomainErrorKind = 'not_found' | 'conflict' | 'invalid' | 'forbidden' | 'unauthorized'
2
+
3
+ export abstract class DomainError extends Error {
4
+ abstract readonly kind: DomainErrorKind
5
+ }
@@ -0,0 +1,5 @@
1
+ export interface TransactionManager {
2
+ run<T>(work: () => Promise<T>): Promise<T>
3
+ }
4
+
5
+ export const TRANSACTION_MANAGER = Symbol('TransactionManager')
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod'
2
+ import { LOCALES } from '@/domain/users/entities/profile.entity'
3
+
4
+ export const profileResponseSchema = z.object({
5
+ id: z.uuid(),
6
+ userId: z.uuid(),
7
+ displayName: z.string(),
8
+ locale: z.enum(LOCALES),
9
+ timezone: z.string(),
10
+ createdAt: z.date(),
11
+ updatedAt: z.date(),
12
+ })
13
+
14
+ export type ProfileResponseDto = z.infer<typeof profileResponseSchema>
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod'
2
+ import { LOCALES } from '@/domain/users/entities/profile.entity'
3
+
4
+ export const updateProfileSchema = z.strictObject({
5
+ displayName: z.string().min(1).max(80).optional(),
6
+ locale: z.enum(LOCALES).optional(),
7
+ timezone: z.string().max(64).optional(),
8
+ })
9
+
10
+ export type UpdateProfileDto = z.infer<typeof updateProfileSchema>