@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.
Files changed (173) hide show
  1. package/.prumo-templates/api/auth.md +50 -48
  2. package/.prumo-templates/api/config.md +2 -2
  3. package/.prumo-templates/api/routes.md +6 -4
  4. package/.prumo-templates/api/structure.md +4 -1
  5. package/.prumo-templates/client/data.md +16 -18
  6. package/.prumo-templates/client/forms.md +2 -2
  7. package/.prumo-templates/core/testing.md +4 -5
  8. package/.prumo-templates/database/testing.md +6 -8
  9. package/.prumo-templates/mcp/server.md +39 -12
  10. package/.prumo-templates/mobile/routing.md +1 -1
  11. package/.prumo-templates/mobile/storage.md +7 -6
  12. package/.prumo-templates/monorepo/layout.md +4 -4
  13. package/.prumo-templates/multi-tenancy/client.md +6 -6
  14. package/.prumo-templates/multi-tenancy/isolation.md +17 -10
  15. package/README.md +19 -14
  16. package/dist/clean.js +13 -3
  17. package/dist/cli.js +38 -9
  18. package/dist/commands.js +7 -4
  19. package/dist/compose.js +4 -12
  20. package/dist/doctor.js +31 -17
  21. package/dist/generate.js +56 -9
  22. package/dist/mcp.js +15 -8
  23. package/dist/names.js +42 -0
  24. package/dist/questions.js +32 -16
  25. package/package.json +2 -2
  26. package/templates/api/.env.example +3 -5
  27. package/templates/api/README.md +5 -6
  28. package/templates/api/docker-compose.yml +0 -1
  29. package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
  30. package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
  31. package/templates/api/package.json +7 -8
  32. package/templates/api/scripts/database.mjs +3 -11
  33. package/templates/api/scripts/ports.mjs +1 -1
  34. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  35. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  36. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  37. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  38. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  39. package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
  40. package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
  41. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  42. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  43. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  44. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  45. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  46. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  47. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  48. package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
  49. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  50. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  51. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
  52. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
  53. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  54. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
  55. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
  56. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  57. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
  58. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
  59. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  60. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
  61. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  62. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  63. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  64. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  65. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  66. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  67. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
  68. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  69. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
  70. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  71. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  72. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
  73. package/templates/api/src/infra/config/env.ts +3 -7
  74. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
  75. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  76. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
  77. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
  78. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
  79. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  80. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
  81. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  82. package/templates/api/src/infra/di/auth.di.ts +21 -0
  83. package/templates/api/src/infra/di/index.ts +4 -10
  84. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  85. package/templates/api/src/infra/http/app.ts +11 -1
  86. package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
  87. package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
  88. package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
  89. package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
  90. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  91. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  92. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  93. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  94. package/templates/api/src/infra/http/server.ts +1 -4
  95. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  96. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  97. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  98. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  99. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  100. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  101. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
  102. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
  103. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  104. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  105. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  106. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  107. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
  108. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
  109. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
  110. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  111. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  112. package/templates/api/test/support/factories/user.factory.ts +9 -11
  113. package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
  114. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  115. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  116. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  117. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  118. package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
  119. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  120. package/templates/api/test/support/setup.ts +6 -28
  121. package/templates/mobile/README.md +1 -1
  122. package/templates/mobile/jest.config.js +0 -17
  123. package/templates/mobile/package.json +1 -4
  124. package/templates/mobile/scripts/ports.mjs +1 -1
  125. package/templates/mobile/src/api-contract/api-error.ts +0 -11
  126. package/templates/mobile/src/api-contract/auth.ts +161 -0
  127. package/templates/mobile/src/api-contract/index.ts +10 -0
  128. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  129. package/templates/mobile/src/app/_layout.tsx +5 -5
  130. package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
  131. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  132. package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
  133. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  134. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  135. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  136. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  137. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  138. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  139. package/templates/site/README.md +1 -1
  140. package/templates/site/package.json +1 -1
  141. package/templates/site/scripts/ports.mjs +1 -1
  142. package/templates/web/README.md +1 -1
  143. package/templates/web/package.json +1 -3
  144. package/templates/web/scripts/ports.mjs +1 -1
  145. package/templates/web/src/api-contract/api-error.ts +0 -11
  146. package/templates/web/src/api-contract/auth.ts +161 -0
  147. package/templates/web/src/api-contract/index.ts +10 -0
  148. package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
  149. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  150. package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
  151. package/templates/web/src/features/oauth/consent-form.tsx +66 -35
  152. package/templates/web/src/main.tsx +6 -4
  153. package/templates/web/src/router.tsx +1 -2
  154. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  155. package/templates/web/src/routes/_authenticated.tsx +1 -1
  156. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  157. package/templates/web/test/app.spec.tsx +47 -22
  158. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  159. package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
  160. package/templates/web/test/support/render-app.tsx +4 -4
  161. package/templates/workspace/README.md +1 -1
  162. package/templates/workspace/package.json +1 -1
  163. package/templates/workspace/scripts/ports.mjs +1 -1
  164. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  165. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  166. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  167. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  168. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  169. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  170. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  171. package/templates/mobile/src/features/auth/session.ts +0 -17
  172. package/templates/web/src/features/auth/auth-client.ts +0 -17
  173. 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, so there are no
19
- ports, no adapters, and no in house architecture to learn before writing the first endpoint.
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.17 or later, pnpm, and Docker for anything with an API. `prumo doctor` checks all of it.
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, Better Auth in a schema of its own, Swagger UI, Vitest with Testcontainers |
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 session kept in `expo-secure-store` |
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 nine areas. Only the areas the answers call for are copied |
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 nine areas are `core`, `api`, `database`, `client`, `web`, `mobile`, `site`, `monorepo` and
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. `0.0.1` is the first published version: every template runs and every generated
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\nAUTH_DATABASE_URL=MISSING\n';
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}\nAUTH_DATABASE_URL=${SAMPLE_URL}\n`, '=MISSING'),
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 { validateProjectName } from "./names.js";
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(validateProjectName) : undefined);
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 = resolve(answers.name);
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: { ...answers, target, installed: install }, text: '' };
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.filter((arg) => !arg.startsWith('-'));
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
- { flag: '--multi-tenant', description: 'Tenant-aware api, web and mobile' },
15
- { flag: '--single-tenant', description: 'No tenancy' },
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, recorded in DECISIONS.md: stable NativeWind needs Tailwind 3.
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, mobileScheme = 'app', mcp = false, }) {
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, 17, 0];
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.17.0 or later is required` }),
58
+ : { status: 'fail', detail: `${probe.nodeVersion}; 22.18.0 or later is required` }),
32
59
  });
33
- for (const [id, label, why] of [
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 = /^BETTER_AUTH_SECRET=.*$/m;
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 BETTER_AUTH_SECRET line`);
38
+ throw new Error(`${example} no longer carries a JWT_SECRET line`);
38
39
  }
39
- env = env.replace(SECRET_LINE, `BETTER_AUTH_SECRET=${randomBytes(32).toString('base64url')}`);
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
- throw new Error(`${command} ${args.join(' ')} failed in ${cwd}`);
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
- if (existsSync(target) && (await readdir(target)).length > 0) {
65
- throw new CliError('target_not_empty', `${target} already exists and is not empty.`);
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
- const FILES = {
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
- '@better-auth/cimd',
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
  }