@stjoseph/prumo 0.1.1 → 0.3.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.
- package/.prumo-templates/api/auth.md +50 -48
- package/.prumo-templates/api/config.md +2 -2
- package/.prumo-templates/api/routes.md +6 -4
- package/.prumo-templates/api/structure.md +4 -1
- package/.prumo-templates/client/data.md +16 -18
- package/.prumo-templates/client/forms.md +2 -2
- package/.prumo-templates/core/testing.md +4 -5
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/mcp/server.md +39 -12
- package/.prumo-templates/mobile/routing.md +1 -1
- package/.prumo-templates/mobile/storage.md +7 -6
- package/.prumo-templates/monorepo/layout.md +4 -4
- package/.prumo-templates/multi-tenancy/client.md +6 -6
- package/.prumo-templates/multi-tenancy/isolation.md +17 -10
- package/README.md +19 -14
- package/dist/clean.js +13 -3
- package/dist/cli.js +38 -9
- package/dist/commands.js +7 -4
- package/dist/compose.js +4 -12
- package/dist/doctor.js +31 -17
- package/dist/generate.js +56 -9
- package/dist/mcp.js +15 -8
- package/dist/names.js +42 -0
- package/dist/questions.js +32 -16
- package/package.json +2 -2
- package/templates/api/.env.example +3 -5
- package/templates/api/README.md +5 -6
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
- package/templates/api/package.json +7 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
- package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
- package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
- package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
- package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
- package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
- package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
- package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
- package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
- package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
- package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
- package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
- package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
- package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
- package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
- package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
- package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
- package/templates/api/src/infra/config/env.ts +3 -7
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
- package/templates/api/src/infra/di/auth.di.ts +21 -0
- package/templates/api/src/infra/di/index.ts +4 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -0
- package/templates/api/src/infra/http/app.ts +11 -1
- package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
- package/templates/api/src/infra/http/fastify.d.ts +2 -3
- package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
- package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
- package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
- package/templates/api/src/infra/http/server.ts +1 -4
- package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
- package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
- package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
- package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
- package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
- package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
- package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
- package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
- package/templates/api/test/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
- package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
- package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
- package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +1 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +0 -11
- package/templates/mobile/src/api-contract/auth.ts +161 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- package/templates/mobile/src/app/_layout.tsx +5 -5
- package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
- package/templates/mobile/src/features/auth/token-store.ts +21 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
- package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/README.md +1 -1
- package/templates/site/package.json +1 -1
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/README.md +1 -1
- package/templates/web/package.json +1 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +0 -11
- package/templates/web/src/api-contract/auth.ts +161 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
- package/templates/web/src/features/oauth/consent-form.tsx +66 -35
- package/templates/web/src/main.tsx +6 -4
- package/templates/web/src/router.tsx +1 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
- package/templates/web/src/routes/_authenticated.tsx +1 -1
- package/templates/web/test/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/README.md +1 -1
- package/templates/workspace/package.json +1 -1
- package/templates/workspace/scripts/ports.mjs +1 -1
- package/templates/api/docker/init/01-auth-schema.sql +0 -1
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
- package/templates/api/src/infra/auth/auth.cli.ts +0 -5
- package/templates/api/src/infra/auth/auth.factory.ts +0 -77
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
- package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
- package/templates/mobile/src/features/auth/auth-client.ts +0 -27
- package/templates/mobile/src/features/auth/session.ts +0 -17
- package/templates/web/src/features/auth/auth-client.ts +0 -17
- package/templates/web/src/features/auth/session.ts +0 -17
package/README.md
CHANGED
|
@@ -15,8 +15,9 @@ turned into something that actually runs.
|
|
|
15
15
|
|
|
16
16
|
Put briefly, Prumo is the validation of a well built project without heavy architectural
|
|
17
17
|
bureaucracy, done up front instead of discovered three months in. Well built here does not mean
|
|
18
|
-
heavily layered. Every choice follows what the framework itself already indicates,
|
|
19
|
-
|
|
18
|
+
heavily layered. Every choice follows what the framework itself already indicates, and the one
|
|
19
|
+
structure of Prumo's own is the API's: ports and adapters, so the domain imports neither the
|
|
20
|
+
database nor the HTTP framework.
|
|
20
21
|
|
|
21
22
|
A new project receives two things. The first is code that already works: installed, wired together,
|
|
22
23
|
and proven by starting rather than by compiling. The second is a `.prumo/` folder holding the
|
|
@@ -31,7 +32,8 @@ workspace holding four applications, without either one carrying rules it has no
|
|
|
31
32
|
|
|
32
33
|
## Requirements
|
|
33
34
|
|
|
34
|
-
Node 22.
|
|
35
|
+
Node 22.18 or later, pnpm 10.26 or later, and Docker for anything with an API. `prumo doctor` checks
|
|
36
|
+
all of it. Prumo runs on macOS and Linux; Windows is not supported yet.
|
|
35
37
|
|
|
36
38
|
## Running it
|
|
37
39
|
|
|
@@ -45,7 +47,11 @@ published package is [`@stjoseph/prumo`](https://www.npmjs.com/package/@stjoseph
|
|
|
45
47
|
command it installs is `prumo`.
|
|
46
48
|
|
|
47
49
|
It asks what to build, writes the project into `my-app/`, runs `git init`, and installs. Nothing is
|
|
48
|
-
committed, so the first commit is yours and the diff shows everything Prumo wrote.
|
|
50
|
+
committed, so the first commit is yours and the diff shows everything Prumo wrote. `prumo new .`
|
|
51
|
+
writes into the current directory instead, which must be empty, and names the project after it. If
|
|
52
|
+
anything fails or is interrupted, Prumo removes what it wrote, so the same command can run again.
|
|
53
|
+
A project cannot be named `api`, `web`, `mobile` or `site`: those are the names its apps take inside
|
|
54
|
+
a workspace.
|
|
49
55
|
|
|
50
56
|
Every question also has a flag, which is how CI runs it without a terminal. Outside an interactive
|
|
51
57
|
terminal a missing answer is an error rather than a default, so nothing important gets decided by
|
|
@@ -59,7 +65,7 @@ npx @stjoseph/prumo new my-app --types api,web --single-tenant --no-mcp
|
|
|
59
65
|
|---|---|
|
|
60
66
|
| `--types api,web,mobile,site` | What the project contains. More than one type makes a workspace |
|
|
61
67
|
| `--alone` or `--monorepo` | How a single type is laid out |
|
|
62
|
-
| `--multi-tenant` or `--single-tenant` | Whether the application serves several tenants |
|
|
68
|
+
| `--multi-tenant` or `--single-tenant` | Whether the application serves several tenants. Multi-tenant ships the `multi-tenancy` conventions; the generated code itself is not tenant-aware yet |
|
|
63
69
|
| `--mcp` or `--no-mcp` | With an api and a web: whether AI assistants reach the API through MCP, signed in as the user |
|
|
64
70
|
| `--skip-install` | Stops after writing the files |
|
|
65
71
|
|
|
@@ -67,9 +73,9 @@ npx @stjoseph/prumo new my-app --types api,web --single-tenant --no-mcp
|
|
|
67
73
|
|
|
68
74
|
| Command | What it does |
|
|
69
75
|
|---|---|
|
|
70
|
-
| `prumo new [name]` | Generates a project |
|
|
76
|
+
| `prumo new [name \| .]` | Generates a project |
|
|
71
77
|
| `prumo db` | Inside a project with an API: creates the development database in Docker and writes its URL into `.env` |
|
|
72
|
-
| `prumo clean` | Removes what the project needed only once, today the database setup, once the database exists |
|
|
78
|
+
| `prumo clean` | Removes what the project needed only once, today the database setup, once the database exists. Only the Prumo version that generated the project cleans it |
|
|
73
79
|
| `prumo doctor` | Checks Node, pnpm, git and Docker on this machine |
|
|
74
80
|
| `prumo version` · `--version` · `-v` | Prints the CLI version |
|
|
75
81
|
| `prumo help [command]` · `--help` | Lists the commands, or describes one |
|
|
@@ -82,9 +88,9 @@ is asked, and the exit code is 0 only when `ok` is true.
|
|
|
82
88
|
|
|
83
89
|
| Type | What you get |
|
|
84
90
|
|---|---|
|
|
85
|
-
| `api` | Fastify with a domain/infra structure, tsyringe, Zod, MikroORM on PostgreSQL,
|
|
91
|
+
| `api` | Fastify with a domain/infra structure, tsyringe, Zod, MikroORM on PostgreSQL, its own JWT authentication, Swagger UI, Vitest with Testcontainers |
|
|
86
92
|
| `web` | Vite, React, TanStack Router and Query, Tailwind, shadcn/ui, forms validated with Zod |
|
|
87
|
-
| `mobile` | Expo with Expo Router, NativeWind, MMKV, and the
|
|
93
|
+
| `mobile` | Expo with Expo Router, NativeWind, MMKV, and the tokens kept in `expo-secure-store` |
|
|
88
94
|
| `site` | Next on the App Router, static by default, for pages that have to be indexed |
|
|
89
95
|
|
|
90
96
|
One type produces that project on its own. Several produce a pnpm workspace: each application under
|
|
@@ -100,13 +106,13 @@ a month apart are the same project.
|
|
|
100
106
|
|
|
101
107
|
| File | What it is |
|
|
102
108
|
|---|---|
|
|
103
|
-
| `.prumo/` | The conventions, in
|
|
109
|
+
| `.prumo/` | The conventions, in ten areas. Only the areas the answers call for are copied |
|
|
104
110
|
| `.prumo/INDEX.md` | One line per document. Generated by the tool, and safe to regenerate |
|
|
105
111
|
| `AGENTS.md` | The pointer into `.prumo/`, read by Codex, Cursor, Copilot, Gemini CLI and others |
|
|
106
112
|
| `CLAUDE.md` | An `@AGENTS.md` import, for Claude Code |
|
|
107
113
|
|
|
108
|
-
The
|
|
109
|
-
`multi-tenancy`. Only `core` ships unconditionally, because everything in it is true of every type;
|
|
114
|
+
The ten areas are `core`, `api`, `database`, `client`, `web`, `mobile`, `site`, `monorepo`,
|
|
115
|
+
`multi-tenancy` and `mcp`. Only `core` ships unconditionally, because everything in it is true of every type;
|
|
110
116
|
the rest follow the answers, since advice about a platform the project does not have is worse than
|
|
111
117
|
no advice at all.
|
|
112
118
|
|
|
@@ -129,8 +135,7 @@ without a token in the repository. Any other push to `main` publishes nothing.
|
|
|
129
135
|
|
|
130
136
|
## Status
|
|
131
137
|
|
|
132
|
-
Prumo is in beta
|
|
133
|
-
project is verified in CI, and what a generated project looks like can still change before 1.0.
|
|
138
|
+
Prumo is in beta: every template runs and every generated project is verified in CI, and what a generated project looks like can still change before 1.0.
|
|
134
139
|
Updating a project's `.prumo/` in place comes after 1.0.
|
|
135
140
|
|
|
136
141
|
MIT licensed. See [`LICENSE`](https://github.com/stjosephworks/prumo/blob/main/LICENSE).
|
package/dist/clean.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join, relative } from 'node:path';
|
|
4
|
-
import { apiPath, findProjectRoot } from "./database.js";
|
|
4
|
+
import { apiPath, findProjectRoot, readConfig } from "./database.js";
|
|
5
5
|
import { readJsonc, setJsonc } from "./jsonc.js";
|
|
6
6
|
import { CliError } from "./output.js";
|
|
7
|
+
import { cliVersion } from "./version.js";
|
|
7
8
|
const SAMPLE_URL = 'postgresql://app:app@localhost:5432/app';
|
|
8
9
|
export const API_DEV_PREFIX = 'node scripts/database.mjs --check && ';
|
|
9
10
|
export const ROOT_DEV_PREFIX = 'node apps/api/scripts/database.mjs --check && ';
|
|
10
11
|
export const SETUP_SCRIPT = 'node scripts/database.mjs';
|
|
11
12
|
export const EXAMPLE_BLOCK = '# MISSING until `pnpm db:setup` creates a database; `pnpm dev` offers to run it.\n' +
|
|
12
|
-
'DATABASE_URL=MISSING\
|
|
13
|
+
'DATABASE_URL=MISSING\n';
|
|
13
14
|
export const README_PARAGRAPH = '`DATABASE_URL` starts as `MISSING`. While it is, `pnpm dev` offers to create the database first: it asks for a name,\n' +
|
|
14
15
|
'starts Postgres from `docker-compose.yml` on port 5432 or the next free one (kept in `POSTGRES_PORT`), writes the URL\n' +
|
|
15
16
|
'into `.env`, and migrates. `pnpm db:setup` does the same at any time. Without a terminal every answer comes from a\n' +
|
|
@@ -50,7 +51,7 @@ function rulesFor(root, api, templates) {
|
|
|
50
51
|
const rules = [
|
|
51
52
|
devRule('api-dev-check', at('package.json'), API_DEV_PREFIX),
|
|
52
53
|
scriptRule('api-db-setup', at('package.json'), 'Remove the `db:setup` script', 'db:setup', (value) => value === SETUP_SCRIPT ? { status: 'pending' } : { status: 'modified', next: value }),
|
|
53
|
-
textRule('api-env-example', at('.env.example'), 'Put a sample database URL back in `.env.example`', EXAMPLE_BLOCK, `DATABASE_URL=${SAMPLE_URL}\
|
|
54
|
+
textRule('api-env-example', at('.env.example'), 'Put a sample database URL back in `.env.example`', EXAMPLE_BLOCK, `DATABASE_URL=${SAMPLE_URL}\n`, '=MISSING'),
|
|
54
55
|
textRule('api-readme-setup', at('README.md'), 'Replace the README paragraph about `MISSING`', README_PARAGRAPH, README_REPLACEMENT, 'db:setup'),
|
|
55
56
|
textRule('api-readme-row', at('README.md'), 'Remove `db:setup` from the README', README_ROW, '', '`pnpm db:setup`'),
|
|
56
57
|
];
|
|
@@ -65,6 +66,15 @@ export async function planClean({ cwd, templates, force, }) {
|
|
|
65
66
|
if (api === undefined) {
|
|
66
67
|
return { root, items: [], apply: async () => { } };
|
|
67
68
|
}
|
|
69
|
+
// "Exactly what was generated" is judged against this CLI's templates. Another version shipped other text, and
|
|
70
|
+
// every untouched file would read as changed by the team, so only the version that wrote the project cleans it.
|
|
71
|
+
const wrote = readConfig(root).prumo;
|
|
72
|
+
const running = cliVersion();
|
|
73
|
+
if (wrote !== running) {
|
|
74
|
+
throw new CliError('version_mismatch', wrote === undefined
|
|
75
|
+
? `This project predates Prumo 0.1.1, which began recording its version. Run \`prumo clean\` from the version that generated it, such as \`npx @stjoseph/prumo@0.1.0 clean\`.`
|
|
76
|
+
: `This project was generated by Prumo ${wrote}, and this is ${running}. Run \`npx @stjoseph/prumo@${wrote} clean\`.`);
|
|
77
|
+
}
|
|
68
78
|
const env = await readFile(join(api, '.env'), 'utf8').catch(() => undefined);
|
|
69
79
|
if (!force && env !== undefined && /^DATABASE_URL=MISSING$/m.test(env)) {
|
|
70
80
|
throw new CliError('database_missing', 'DATABASE_URL is still MISSING: set the database up before removing what sets it up, or pass --force.');
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
2
|
import { parseArgs } from 'node:util';
|
|
4
3
|
import * as prompt from '@clack/prompts';
|
|
5
4
|
import { terminalAsker } from "./asker.js";
|
|
@@ -7,9 +6,9 @@ import { assetsFor, requireAssets } from "./assets.js";
|
|
|
7
6
|
import { cleanText, planClean } from "./clean.js";
|
|
8
7
|
import { COMMANDS, findCommand, helpText } from "./commands.js";
|
|
9
8
|
import { runDatabase } from "./database.js";
|
|
10
|
-
import { doctor, doctorText } from "./doctor.js";
|
|
9
|
+
import { doctor, doctorText, systemProbe, toolChecks } from "./doctor.js";
|
|
11
10
|
import { generate } from "./generate.js";
|
|
12
|
-
import {
|
|
11
|
+
import { projectProblem } from "./names.js";
|
|
13
12
|
import { CliError, errorEnvelope, writeEnvelope } from "./output.js";
|
|
14
13
|
import { resolveAnswers } from "./questions.js";
|
|
15
14
|
import { cliVersion } from "./version.js";
|
|
@@ -43,6 +42,16 @@ async function runNew(args, json) {
|
|
|
43
42
|
if (extra.length > 0) {
|
|
44
43
|
throw new CliError('usage', `Unexpected argument: ${extra.join(' ')}`);
|
|
45
44
|
}
|
|
45
|
+
const install = !values['skip-install'];
|
|
46
|
+
// Before the first question and the first file: a missing tool would otherwise surface halfway through.
|
|
47
|
+
const tools = toolChecks(systemProbe, install ? ['git', 'pnpm'] : ['git']);
|
|
48
|
+
if (tools.some((check) => check.status !== 'ok')) {
|
|
49
|
+
throw new CliError('not_ready', 'This machine is missing something Prumo needs.', {
|
|
50
|
+
ready: false,
|
|
51
|
+
checks: tools,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const cwd = process.cwd();
|
|
46
55
|
const interactive = !json && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
47
56
|
const answers = await resolveAnswers({
|
|
48
57
|
name,
|
|
@@ -53,23 +62,22 @@ async function runNew(args, json) {
|
|
|
53
62
|
singleTenant: values['single-tenant'],
|
|
54
63
|
mcp: values.mcp,
|
|
55
64
|
noMcp: values['no-mcp'],
|
|
56
|
-
}, interactive ? terminalAsker(
|
|
65
|
+
}, interactive ? terminalAsker((input) => projectProblem(input, cwd)) : undefined, cwd);
|
|
57
66
|
if (!json && answers.types.length > 1) {
|
|
58
67
|
prompt.log.info(`${answers.types.length} types make a workspace: apps/ and packages/ under one root.`);
|
|
59
68
|
}
|
|
60
|
-
const target =
|
|
61
|
-
const install = !values['skip-install'];
|
|
69
|
+
const { target, ...chosen } = answers;
|
|
62
70
|
await generate({
|
|
63
71
|
...requireAssets(assetsFor(import.meta.dirname)),
|
|
64
72
|
target,
|
|
65
|
-
answers,
|
|
73
|
+
answers: chosen,
|
|
66
74
|
install,
|
|
67
75
|
childOutput: json ? 'stderr' : 'inherit',
|
|
68
76
|
});
|
|
69
77
|
if (!json) {
|
|
70
78
|
prompt.outro(`Created ${answers.name}.`);
|
|
71
79
|
}
|
|
72
|
-
return { data: { ...
|
|
80
|
+
return { data: { ...chosen, target, installed: install }, text: '' };
|
|
73
81
|
}
|
|
74
82
|
async function runClean(args, json) {
|
|
75
83
|
const { values } = parseArgs({
|
|
@@ -108,6 +116,19 @@ async function runClean(args, json) {
|
|
|
108
116
|
await plan.apply();
|
|
109
117
|
return result(true);
|
|
110
118
|
}
|
|
119
|
+
// A command that takes no options of its own still refuses one it does not know.
|
|
120
|
+
function noOptions(args, positionals = 0) {
|
|
121
|
+
const parsed = parseArgs({
|
|
122
|
+
args,
|
|
123
|
+
allowPositionals: true,
|
|
124
|
+
options: { json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
|
|
125
|
+
});
|
|
126
|
+
if (parsed.positionals.length > positionals) {
|
|
127
|
+
throw new CliError('usage', `Unexpected argument: ${parsed.positionals.slice(positionals).join(' ')}`);
|
|
128
|
+
}
|
|
129
|
+
return parsed.positionals;
|
|
130
|
+
}
|
|
131
|
+
const GLOBAL_OPTIONS = ['--json', '--help', '-h', '--version', '-v'];
|
|
111
132
|
async function main(argv) {
|
|
112
133
|
const json = argv.includes('--json');
|
|
113
134
|
const index = argv.findIndex((arg) => !arg.startsWith('-'));
|
|
@@ -116,19 +137,27 @@ async function main(argv) {
|
|
|
116
137
|
const wantsHelp = argv.includes('--help') || argv.includes('-h');
|
|
117
138
|
const name = command ?? (argv.includes('--version') || argv.includes('-v') ? 'version' : 'help');
|
|
118
139
|
try {
|
|
140
|
+
const unknown = argv
|
|
141
|
+
.slice(0, index === -1 ? argv.length : index)
|
|
142
|
+
.find((arg) => !GLOBAL_OPTIONS.includes(arg));
|
|
143
|
+
if (unknown !== undefined) {
|
|
144
|
+
throw new CliError('usage', `Unknown option "${unknown}". Run \`prumo help\`.`);
|
|
145
|
+
}
|
|
119
146
|
let result;
|
|
120
147
|
if (wantsHelp && command !== undefined && command !== 'help') {
|
|
121
148
|
result = help(command);
|
|
122
149
|
}
|
|
123
150
|
else if (name === 'help') {
|
|
124
|
-
const [topic] = args
|
|
151
|
+
const [topic] = noOptions(args, 1);
|
|
125
152
|
result = help(topic);
|
|
126
153
|
}
|
|
127
154
|
else if (name === 'version') {
|
|
155
|
+
noOptions(args);
|
|
128
156
|
const current = cliVersion();
|
|
129
157
|
result = { data: { version: current }, text: current };
|
|
130
158
|
}
|
|
131
159
|
else if (name === 'doctor') {
|
|
160
|
+
noOptions(args);
|
|
132
161
|
const report = await doctor();
|
|
133
162
|
if (!report.ready) {
|
|
134
163
|
throw new CliError('not_ready', 'This machine is missing something Prumo needs.', report);
|
package/dist/commands.js
CHANGED
|
@@ -5,14 +5,17 @@ const JSON_OPTION = {
|
|
|
5
5
|
export const COMMANDS = [
|
|
6
6
|
{
|
|
7
7
|
name: 'new',
|
|
8
|
-
usage: 'prumo new [name] [options]',
|
|
9
|
-
summary: 'Generate a project and the .prumo/ context it follows',
|
|
8
|
+
usage: 'prumo new [name | .] [options]',
|
|
9
|
+
summary: 'Generate a project and the .prumo/ context it follows; `.` generates into this empty directory',
|
|
10
10
|
options: [
|
|
11
11
|
{ flag: '--types <list>', description: 'Comma-separated: api, web, mobile, site' },
|
|
12
12
|
{ flag: '--alone', description: 'One project of one type' },
|
|
13
13
|
{ flag: '--monorepo', description: 'A workspace, even for a single type' },
|
|
14
|
-
{
|
|
15
|
-
|
|
14
|
+
{
|
|
15
|
+
flag: '--multi-tenant',
|
|
16
|
+
description: 'Ship the multi-tenancy conventions; the code itself is not tenant-aware',
|
|
17
|
+
},
|
|
18
|
+
{ flag: '--single-tenant', description: 'No multi-tenancy conventions' },
|
|
16
19
|
{ flag: '--mcp', description: 'An MCP server on the api, authorized through the web' },
|
|
17
20
|
{ flag: '--no-mcp', description: 'No MCP server' },
|
|
18
21
|
{ flag: '--skip-install', description: 'Do not run pnpm install' },
|
package/dist/compose.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { join, relative } from 'node:path';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
4
|
import { prependProperty, readJsonc, removeWhatBaseDeclares, setJsonc } from "./jsonc.js";
|
|
5
5
|
import { applyMcp } from "./mcp.js";
|
|
6
6
|
const CLIENTS = ['web', 'mobile'];
|
|
7
|
-
// One exception to one-version-per-dependency
|
|
7
|
+
// One exception to one-version-per-dependency: stable NativeWind needs Tailwind 3.
|
|
8
8
|
const NAMED_CATALOGS = [{ name: 'tailwind3', dependency: 'tailwindcss', major: '3' }];
|
|
9
9
|
const NOT_COPIED = new Set([
|
|
10
10
|
'node_modules',
|
|
@@ -36,7 +36,7 @@ export async function copyTemplate(source, destination) {
|
|
|
36
36
|
await cp(source, destination, {
|
|
37
37
|
recursive: true,
|
|
38
38
|
filter: (path) => !relative(source, path)
|
|
39
|
-
.split(
|
|
39
|
+
.split(sep)
|
|
40
40
|
.some((part) => NOT_COPIED.has(part) || NOT_COPIED_SUFFIXES.some((suffix) => part.endsWith(suffix))),
|
|
41
41
|
});
|
|
42
42
|
// Published, a template's .gitignore is `gitignore`, because npm deletes the dotted name from a tarball.
|
|
@@ -182,7 +182,7 @@ async function assertCatalogCovers(target, catalog) {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
|
-
export async function composeWorkspace({ templates, target, types,
|
|
185
|
+
export async function composeWorkspace({ templates, target, types, mcp = false, }) {
|
|
186
186
|
await mkdir(target, { recursive: true });
|
|
187
187
|
await copyTemplate(join(templates, 'workspace'), target);
|
|
188
188
|
const apps = [];
|
|
@@ -199,14 +199,6 @@ export async function composeWorkspace({ templates, target, types, mobileScheme
|
|
|
199
199
|
else {
|
|
200
200
|
await rm(join(target, 'packages'), { recursive: true, force: true });
|
|
201
201
|
}
|
|
202
|
-
if (types.includes('api') && types.includes('mobile')) {
|
|
203
|
-
await rewriteText(join(target, 'apps', 'api', '.env.example'), (text) => {
|
|
204
|
-
if (!text.includes('# MOBILE_APP_SCHEME=app\n')) {
|
|
205
|
-
throw new Error('apps/api/.env.example no longer carries the commented MOBILE_APP_SCHEME line');
|
|
206
|
-
}
|
|
207
|
-
return text.replace('# MOBILE_APP_SCHEME=app\n', `MOBILE_APP_SCHEME=${mobileScheme}\n`);
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
202
|
// One command for everything and one per app. Each app answers to `dev`, so the root never needs to know how.
|
|
211
203
|
// The database and the ports are the exception: both may ask a question, which the interleaved output of a
|
|
212
204
|
// parallel run would bury, so the root asks first and each app's own check then finds nothing to do.
|
package/dist/doctor.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
const NODE_FLOOR = [22,
|
|
2
|
+
const NODE_FLOOR = [22, 18, 0];
|
|
3
|
+
// Every generated workspace approves dependency builds with `allowBuilds`, which pnpm added in 10.26.0.
|
|
4
|
+
const PNPM_FLOOR = [10, 26, 0];
|
|
5
|
+
// On Windows pnpm is a .cmd shim, and Node starts one only through a shell.
|
|
6
|
+
export const SHELL = process.platform === 'win32';
|
|
3
7
|
export const systemProbe = {
|
|
4
8
|
nodeVersion: process.versions.node,
|
|
5
9
|
run: (command, args) => {
|
|
6
|
-
const result = spawnSync(command, args, { encoding: 'utf8', timeout: 10_000 });
|
|
10
|
+
const result = spawnSync(command, args, { encoding: 'utf8', timeout: 10_000, shell: SHELL });
|
|
7
11
|
return { ok: result.error === undefined && result.status === 0, stdout: result.stdout ?? '' };
|
|
8
12
|
},
|
|
9
13
|
};
|
|
@@ -20,6 +24,29 @@ function atLeast(version, floor) {
|
|
|
20
24
|
function firstLine(text) {
|
|
21
25
|
return text.trim().split('\n')[0] ?? '';
|
|
22
26
|
}
|
|
27
|
+
const TOOLS = {
|
|
28
|
+
pnpm: { why: 'installs and runs every generated project', floor: PNPM_FLOOR },
|
|
29
|
+
git: { why: 'every generated project starts as a repository', floor: undefined },
|
|
30
|
+
};
|
|
31
|
+
export function toolChecks(probe, tools) {
|
|
32
|
+
return tools.map((id) => {
|
|
33
|
+
const { why, floor } = TOOLS[id];
|
|
34
|
+
const result = probe.run(id, ['--version']);
|
|
35
|
+
const version = firstLine(result.stdout);
|
|
36
|
+
const base = { id, label: id, required: true };
|
|
37
|
+
if (!result.ok) {
|
|
38
|
+
return { ...base, status: 'fail', detail: `not found; it ${why}` };
|
|
39
|
+
}
|
|
40
|
+
if (floor !== undefined && !atLeast(version, floor)) {
|
|
41
|
+
return {
|
|
42
|
+
...base,
|
|
43
|
+
status: 'fail',
|
|
44
|
+
detail: `${version}; ${floor.join('.')} or later is required`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { ...base, status: 'ok', detail: version };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
23
50
|
export async function doctor(probe = systemProbe) {
|
|
24
51
|
const checks = [];
|
|
25
52
|
checks.push({
|
|
@@ -28,22 +55,9 @@ export async function doctor(probe = systemProbe) {
|
|
|
28
55
|
required: true,
|
|
29
56
|
...(atLeast(probe.nodeVersion, NODE_FLOOR)
|
|
30
57
|
? { status: 'ok', detail: probe.nodeVersion }
|
|
31
|
-
: { status: 'fail', detail: `${probe.nodeVersion}; 22.
|
|
58
|
+
: { status: 'fail', detail: `${probe.nodeVersion}; 22.18.0 or later is required` }),
|
|
32
59
|
});
|
|
33
|
-
|
|
34
|
-
['pnpm', 'pnpm', 'installs and runs every generated project'],
|
|
35
|
-
['git', 'git', 'every generated project starts as a repository'],
|
|
36
|
-
]) {
|
|
37
|
-
const result = probe.run(id, ['--version']);
|
|
38
|
-
checks.push({
|
|
39
|
-
id,
|
|
40
|
-
label,
|
|
41
|
-
required: true,
|
|
42
|
-
...(result.ok
|
|
43
|
-
? { status: 'ok', detail: firstLine(result.stdout) }
|
|
44
|
-
: { status: 'fail', detail: `not found; it ${why}` }),
|
|
45
|
-
});
|
|
46
|
-
}
|
|
60
|
+
checks.push(...toolChecks(probe, ['pnpm', 'git']));
|
|
47
61
|
const docker = probe.run('docker', ['--version']);
|
|
48
62
|
const dockerRunning = docker.ok && probe.run('docker', ['info']).ok;
|
|
49
63
|
checks.push({
|
package/dist/generate.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { composeWorkspace, copyTemplate } from "./compose.js";
|
|
7
7
|
import { writeContext } from "./context.js";
|
|
8
|
+
import { SHELL } from "./doctor.js";
|
|
8
9
|
import { setJsonc } from "./jsonc.js";
|
|
9
10
|
import { applyMcp } from "./mcp.js";
|
|
10
|
-
import { schemeFor } from "./names.js";
|
|
11
|
+
import { schemeFor, targetProblem } from "./names.js";
|
|
11
12
|
import { CliError } from "./output.js";
|
|
12
13
|
async function rewrite(path, change) {
|
|
13
14
|
await writeFile(path, change(await readFile(path, 'utf8')));
|
|
@@ -23,7 +24,7 @@ async function nameMobileApp(app, name) {
|
|
|
23
24
|
return setJsonc(result, ['expo', 'scheme'], schemeFor(name));
|
|
24
25
|
});
|
|
25
26
|
}
|
|
26
|
-
const SECRET_LINE = /^
|
|
27
|
+
const SECRET_LINE = /^JWT_SECRET=.*$/m;
|
|
27
28
|
// `.env` stays out of git, so the example is the committed truth and a fresh project gets a copy it can start with.
|
|
28
29
|
// Only the secret differs: a sample value is public, so every generated API draws its own.
|
|
29
30
|
async function writeLocalEnv(app, type) {
|
|
@@ -34,9 +35,9 @@ async function writeLocalEnv(app, type) {
|
|
|
34
35
|
let env = await readFile(example, 'utf8');
|
|
35
36
|
if (type === 'api') {
|
|
36
37
|
if (!SECRET_LINE.test(env)) {
|
|
37
|
-
throw new Error(`${example} no longer carries a
|
|
38
|
+
throw new Error(`${example} no longer carries a JWT_SECRET line`);
|
|
38
39
|
}
|
|
39
|
-
env = env.replace(SECRET_LINE, `
|
|
40
|
+
env = env.replace(SECRET_LINE, `JWT_SECRET=${randomBytes(32).toString('base64url')}`);
|
|
40
41
|
}
|
|
41
42
|
await writeFile(join(app, '.env'), env);
|
|
42
43
|
}
|
|
@@ -50,20 +51,67 @@ async function nameCompose(api, name) {
|
|
|
50
51
|
return text.replace(/^name: .*$/m, `name: ${name}`);
|
|
51
52
|
});
|
|
52
53
|
}
|
|
54
|
+
// Ctrl+C reaches the child and Prumo alike. While a listener exists Prumo is not killed outright, so the run that
|
|
55
|
+
// was stopped returns, its failure removes the half-written project, and the next run starts from nothing.
|
|
56
|
+
let interrupted = false;
|
|
57
|
+
function interrupt() {
|
|
58
|
+
interrupted = true;
|
|
59
|
+
}
|
|
60
|
+
function stopIfInterrupted() {
|
|
61
|
+
if (interrupted) {
|
|
62
|
+
throw new CliError('interrupted', 'Interrupted. Nothing was kept.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
53
65
|
function run(command, args, cwd, output) {
|
|
66
|
+
stopIfInterrupted();
|
|
54
67
|
// Under --json stdout belongs to the result document, so a child's output goes to stderr instead.
|
|
55
68
|
const result = spawnSync(command, args, {
|
|
56
69
|
cwd,
|
|
57
70
|
stdio: output === 'inherit' ? 'inherit' : ['ignore', 2, 2],
|
|
71
|
+
shell: SHELL,
|
|
58
72
|
});
|
|
73
|
+
// spawnSync holds the event loop, so the listener has not run yet; the child says it instead, either by dying of
|
|
74
|
+
// the signal or, as pnpm does, by catching it and exiting with 130.
|
|
75
|
+
if (result.signal === 'SIGINT' || result.status === 130) {
|
|
76
|
+
interrupted = true;
|
|
77
|
+
}
|
|
78
|
+
stopIfInterrupted();
|
|
59
79
|
if (result.status !== 0) {
|
|
60
|
-
|
|
80
|
+
const why = result.error?.message ??
|
|
81
|
+
(result.signal !== null ? `stopped by ${result.signal}` : `exit code ${result.status}`);
|
|
82
|
+
throw new Error(`${command} ${args.join(' ')} failed in ${cwd}: ${why}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Only what this run wrote goes: a directory it created, or the contents of the empty one it was given.
|
|
86
|
+
async function discard(target, created) {
|
|
87
|
+
if (created) {
|
|
88
|
+
await rm(target, { recursive: true, force: true });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const entry of await readdir(target).catch(() => [])) {
|
|
92
|
+
await rm(join(target, entry), { recursive: true, force: true });
|
|
61
93
|
}
|
|
62
94
|
}
|
|
63
95
|
export async function generate({ templates, knowledge, target, answers, install, childOutput = 'inherit', }) {
|
|
64
|
-
|
|
65
|
-
|
|
96
|
+
const occupied = targetProblem(target);
|
|
97
|
+
if (occupied !== undefined) {
|
|
98
|
+
throw new CliError('target_not_empty', occupied);
|
|
99
|
+
}
|
|
100
|
+
const created = !existsSync(target);
|
|
101
|
+
interrupted = false;
|
|
102
|
+
process.on('SIGINT', interrupt);
|
|
103
|
+
try {
|
|
104
|
+
await write({ templates, knowledge, target, answers, install, childOutput });
|
|
66
105
|
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
await discard(target, created);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
process.off('SIGINT', interrupt);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function write({ templates, knowledge, target, answers, install, childOutput, }) {
|
|
67
115
|
const [only] = answers.types;
|
|
68
116
|
if (answers.architecture === 'alone' && only !== undefined) {
|
|
69
117
|
await copyTemplate(join(templates, only), target);
|
|
@@ -82,7 +130,6 @@ export async function generate({ templates, knowledge, target, answers, install,
|
|
|
82
130
|
templates,
|
|
83
131
|
target,
|
|
84
132
|
types: answers.types,
|
|
85
|
-
mobileScheme: schemeFor(answers.name),
|
|
86
133
|
mcp: answers.mcp,
|
|
87
134
|
});
|
|
88
135
|
await nameProject(target, answers.name);
|
package/dist/mcp.js
CHANGED
|
@@ -4,22 +4,29 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { setJsonc } from "./jsonc.js";
|
|
5
5
|
// The api and web templates ship with MCP. A project that answered no receives them without it: these files are
|
|
6
6
|
// deleted, these dependencies removed, and every marked line or block cut. Every other type has nothing to cut.
|
|
7
|
-
|
|
7
|
+
// The API's MCP is its server and the OAuth authorization server that issues its tokens, with the table that
|
|
8
|
+
// holds an authorization in a migration of its own, so a project without MCP never creates it.
|
|
9
|
+
export const FILES = {
|
|
8
10
|
api: [
|
|
9
11
|
'src/infra/mcp',
|
|
12
|
+
'src/domain/oauth',
|
|
13
|
+
'src/infra/oauth',
|
|
14
|
+
'src/infra/di/oauth.di.ts',
|
|
15
|
+
'src/infra/http/fetch-bridge.ts',
|
|
10
16
|
'src/infra/http/controllers/mcp.controller.ts',
|
|
17
|
+
'src/infra/http/controllers/oauth.controller.ts',
|
|
18
|
+
'src/infra/database/mikroorm/entities/authorization.schema.ts',
|
|
19
|
+
'src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts',
|
|
20
|
+
'migrations/Migration20261002210631_create_authorization.ts',
|
|
21
|
+
'test/domain/oauth',
|
|
22
|
+
'test/infra/oauth',
|
|
11
23
|
'test/infra/http/controllers/mcp.controller.spec.ts',
|
|
12
24
|
],
|
|
13
25
|
web: ['src/features/oauth', 'src/routes/_authenticated/consent.tsx', 'test/features/oauth'],
|
|
14
26
|
};
|
|
15
27
|
const DEPENDENCIES = {
|
|
16
|
-
api: [
|
|
17
|
-
|
|
18
|
-
'@better-auth/mcp',
|
|
19
|
-
'@modelcontextprotocol/client',
|
|
20
|
-
'@modelcontextprotocol/server',
|
|
21
|
-
],
|
|
22
|
-
web: ['@better-auth/oauth-provider'],
|
|
28
|
+
api: ['@modelcontextprotocol/client', '@modelcontextprotocol/server'],
|
|
29
|
+
web: [],
|
|
23
30
|
};
|
|
24
31
|
const BLOCK_START = /^\s*\/\/ prumo:mcp$/;
|
|
25
32
|
const BLOCK_END = /^\s*\/\/ prumo:end-mcp$/;
|
package/dist/names.js
CHANGED
|
@@ -1,10 +1,52 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, resolve } from 'node:path';
|
|
1
3
|
const PROJECT_NAME = /^[a-z][a-z0-9-]*$/;
|
|
4
|
+
// npm refuses a longer package name.
|
|
5
|
+
const MAX_LENGTH = 214;
|
|
6
|
+
// In a workspace each app's package is named after its type, and `pnpm --filter <type>` would match the root too.
|
|
7
|
+
const RESERVED = ['api', 'web', 'mobile', 'site'];
|
|
2
8
|
export function validateProjectName(name) {
|
|
3
9
|
if (!PROJECT_NAME.test(name)) {
|
|
4
10
|
return 'Use lowercase letters, digits and hyphens, starting with a letter.';
|
|
5
11
|
}
|
|
12
|
+
if (name.length > MAX_LENGTH) {
|
|
13
|
+
return `Use at most ${MAX_LENGTH} characters.`;
|
|
14
|
+
}
|
|
15
|
+
if (RESERVED.includes(name)) {
|
|
16
|
+
return 'That is the name an app takes inside a workspace; choose another.';
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
// Why a directory cannot receive a project, or undefined when it can: it does not exist yet, or it is empty.
|
|
21
|
+
export function targetProblem(target) {
|
|
22
|
+
const stats = statSync(target, { throwIfNoEntry: false });
|
|
23
|
+
if (stats === undefined) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
if (!stats.isDirectory()) {
|
|
27
|
+
return `${target} already exists and is not a directory.`;
|
|
28
|
+
}
|
|
29
|
+
if (readdirSync(target).length > 0) {
|
|
30
|
+
return `${target} already exists and is not empty.`;
|
|
31
|
+
}
|
|
6
32
|
return undefined;
|
|
7
33
|
}
|
|
34
|
+
// `.` is the current directory, and the project takes its name; anything else is a new directory inside it.
|
|
35
|
+
export function projectAt(input, cwd) {
|
|
36
|
+
return input === '.'
|
|
37
|
+
? { name: basename(cwd), target: cwd }
|
|
38
|
+
: { name: input, target: resolve(cwd, input) };
|
|
39
|
+
}
|
|
40
|
+
export function projectProblem(input, cwd) {
|
|
41
|
+
const { name, target } = projectAt(input, cwd);
|
|
42
|
+
const invalid = validateProjectName(name);
|
|
43
|
+
if (invalid !== undefined) {
|
|
44
|
+
return input === '.'
|
|
45
|
+
? `This directory's name, "${name}", cannot name a project. ${invalid}`
|
|
46
|
+
: invalid;
|
|
47
|
+
}
|
|
48
|
+
return targetProblem(target);
|
|
49
|
+
}
|
|
8
50
|
export function schemeFor(name) {
|
|
9
51
|
return name.replaceAll('-', '');
|
|
10
52
|
}
|