@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.
- package/.prumo-templates/api/auth.md +51 -95
- package/.prumo-templates/api/config.md +30 -56
- package/.prumo-templates/api/errors.md +64 -44
- package/.prumo-templates/api/logging.md +27 -44
- package/.prumo-templates/api/observability.md +36 -62
- package/.prumo-templates/api/pagination.md +23 -38
- package/.prumo-templates/api/routes.md +67 -48
- package/.prumo-templates/api/structure.md +130 -0
- package/.prumo-templates/client/data.md +4 -3
- package/.prumo-templates/core/conventions.md +10 -7
- package/.prumo-templates/core/testing.md +43 -26
- package/.prumo-templates/core/tooling.md +33 -30
- package/.prumo-templates/database/entities.md +53 -61
- package/.prumo-templates/database/queries.md +34 -47
- package/.prumo-templates/database/transactions.md +28 -38
- package/.prumo-templates/mcp/server.md +54 -0
- package/.prumo-templates/monorepo/dependencies.md +3 -3
- package/.prumo-templates/monorepo/layout.md +1 -1
- package/README.md +6 -6
- package/dist/asker.js +4 -0
- package/dist/cli.js +7 -8
- package/dist/commands.js +2 -0
- package/dist/compose.js +6 -2
- package/dist/context.js +5 -0
- package/dist/generate.js +8 -0
- package/dist/mcp.js +100 -0
- package/dist/questions.js +21 -1
- package/dist/version.js +7 -0
- package/package.json +4 -3
- package/templates/api/.swcrc +13 -0
- package/templates/api/biome.jsonc +5 -3
- package/templates/api/package.json +21 -27
- package/templates/api/pnpm-workspace.yaml +0 -2
- package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
- package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
- package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
- package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
- package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
- package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
- package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
- package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
- package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
- package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
- package/templates/api/src/infra/auth/auth.cli.ts +5 -0
- package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
- package/templates/api/src/infra/config/env.ts +47 -0
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
- package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
- package/templates/api/src/infra/di/index.ts +32 -0
- package/templates/api/src/infra/di/users.di.ts +7 -0
- package/templates/api/src/infra/http/app.ts +99 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
- package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
- package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
- package/templates/api/src/infra/http/errors/http-error.ts +8 -0
- package/templates/api/src/infra/http/fastify.d.ts +17 -0
- package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
- package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
- package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
- package/templates/api/src/infra/http/server.ts +34 -0
- package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
- package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
- package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
- package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
- package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
- package/templates/api/test/infra/http/app.spec.ts +21 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
- package/templates/api/test/support/factories/profile.factory.ts +25 -0
- package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
- package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
- package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
- package/templates/api/tsconfig.json +3 -6
- package/templates/api/vitest.config.mts +4 -4
- package/templates/mobile/jest.config.js +1 -1
- package/templates/mobile/package.json +2 -2
- package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
- package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/mobile/tsconfig.json +2 -1
- package/templates/site/{src → test}/app/page.spec.ts +1 -1
- package/templates/site/tsconfig.json +2 -1
- package/templates/site/vitest.config.mts +4 -2
- package/templates/web/package.json +4 -2
- package/templates/web/src/features/auth/auth-client.ts +5 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
- package/templates/web/src/features/oauth/consent-form.tsx +63 -0
- package/templates/web/src/routeTree.gen.ts +21 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
- package/templates/web/{src → test}/app.spec.tsx +2 -2
- package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
- package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
- package/templates/web/tsconfig.json +2 -1
- package/templates/web/vite.config.ts +3 -3
- package/.prumo-templates/api/modules.md +0 -117
- package/templates/api/nest-cli.json +0 -10
- package/templates/api/src/app.module.ts +0 -46
- package/templates/api/src/auth/auth.constants.ts +0 -2
- package/templates/api/src/auth/auth.controller.ts +0 -19
- package/templates/api/src/auth/auth.guard.ts +0 -43
- package/templates/api/src/auth/auth.module.ts +0 -30
- package/templates/api/src/auth/auth.ts +0 -4
- package/templates/api/src/auth/current-user.decorator.ts +0 -9
- package/templates/api/src/auth/express.d.ts +0 -10
- package/templates/api/src/auth/public.decorator.ts +0 -4
- package/templates/api/src/config/env.module.ts +0 -11
- package/templates/api/src/config/env.ts +0 -81
- package/templates/api/src/errors/problem-details.filter.ts +0 -86
- package/templates/api/src/errors/validation-exception.factory.ts +0 -30
- package/templates/api/src/health/health.controller.ts +0 -32
- package/templates/api/src/health/health.module.ts +0 -9
- package/templates/api/src/main.ts +0 -43
- package/templates/api/src/mikro-orm.factory.ts +0 -14
- package/templates/api/src/request-context/request-context.module.ts +0 -9
- package/templates/api/src/request-context/request-context.service.ts +0 -17
- package/templates/api/src/request-context/request-id.middleware.ts +0 -19
- package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
- package/templates/api/src/users/entities/profile.entity.ts +0 -27
- package/templates/api/src/users/users.controller.ts +0 -23
- package/templates/api/src/users/users.module.ts +0 -10
- package/templates/api/src/users/users.service.spec.ts +0 -44
- package/templates/api/src/users/users.service.ts +0 -36
- package/templates/api/test/factories/profile.factory.ts +0 -26
- package/templates/api/tsconfig.build.json +0 -8
- /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
- /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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` |
|
|
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/
|
|
121
|
-
| [`docs/
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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/dist/version.js
ADDED
|
@@ -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.
|
|
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
|
-
"
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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": "
|
|
7
|
-
"start": "
|
|
8
|
-
"start:dev": "
|
|
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": "
|
|
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/
|
|
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
|
-
"@
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"@
|
|
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.
|
|
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,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>
|