@stjoseph/prumo 0.0.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.prumo-templates/api/auth.md +51 -95
  2. package/.prumo-templates/api/config.md +30 -56
  3. package/.prumo-templates/api/errors.md +64 -44
  4. package/.prumo-templates/api/logging.md +27 -44
  5. package/.prumo-templates/api/observability.md +36 -62
  6. package/.prumo-templates/api/pagination.md +23 -38
  7. package/.prumo-templates/api/routes.md +67 -48
  8. package/.prumo-templates/api/structure.md +130 -0
  9. package/.prumo-templates/client/data.md +4 -3
  10. package/.prumo-templates/core/conventions.md +10 -7
  11. package/.prumo-templates/core/testing.md +43 -26
  12. package/.prumo-templates/core/tooling.md +33 -30
  13. package/.prumo-templates/database/entities.md +53 -61
  14. package/.prumo-templates/database/queries.md +34 -47
  15. package/.prumo-templates/database/transactions.md +28 -38
  16. package/.prumo-templates/mcp/server.md +54 -0
  17. package/.prumo-templates/monorepo/dependencies.md +3 -3
  18. package/.prumo-templates/monorepo/layout.md +1 -1
  19. package/README.md +12 -6
  20. package/dist/asker.js +4 -0
  21. package/dist/clean.js +2 -2
  22. package/dist/cli.js +4 -0
  23. package/dist/commands.js +2 -0
  24. package/dist/compose.js +12 -8
  25. package/dist/context.js +2 -0
  26. package/dist/generate.js +8 -0
  27. package/dist/mcp.js +100 -0
  28. package/dist/questions.js +21 -1
  29. package/package.json +4 -3
  30. package/templates/api/.swcrc +13 -0
  31. package/templates/api/README.md +10 -1
  32. package/templates/api/biome.jsonc +5 -3
  33. package/templates/api/package.json +22 -28
  34. package/templates/api/pnpm-workspace.yaml +0 -2
  35. package/templates/api/scripts/ports.mjs +508 -0
  36. package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
  37. package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
  38. package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
  39. package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
  40. package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
  41. package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
  42. package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
  43. package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
  44. package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
  45. package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
  46. package/templates/api/src/infra/auth/auth.cli.ts +5 -0
  47. package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
  48. package/templates/api/src/infra/config/env.ts +47 -0
  49. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
  50. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
  51. package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
  52. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
  53. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
  54. package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
  55. package/templates/api/src/infra/di/index.ts +32 -0
  56. package/templates/api/src/infra/di/users.di.ts +7 -0
  57. package/templates/api/src/infra/http/app.ts +99 -0
  58. package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
  59. package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
  60. package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
  61. package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
  62. package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
  63. package/templates/api/src/infra/http/errors/http-error.ts +8 -0
  64. package/templates/api/src/infra/http/fastify.d.ts +17 -0
  65. package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
  66. package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
  67. package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
  68. package/templates/api/src/infra/http/server.ts +34 -0
  69. package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
  70. package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
  71. package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
  72. package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
  73. package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
  74. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
  75. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
  76. package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
  77. package/templates/api/test/infra/http/app.spec.ts +21 -0
  78. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
  79. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
  80. package/templates/api/test/support/factories/profile.factory.ts +25 -0
  81. package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
  82. package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
  83. package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
  84. package/templates/api/tsconfig.json +3 -6
  85. package/templates/api/vitest.config.mts +4 -4
  86. package/templates/mobile/.env.example +3 -0
  87. package/templates/mobile/README.md +10 -0
  88. package/templates/mobile/jest.config.js +5 -1
  89. package/templates/mobile/package.json +3 -3
  90. package/templates/mobile/scripts/ports.mjs +508 -0
  91. package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
  92. package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  93. package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
  94. package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  95. package/templates/mobile/tsconfig.json +2 -1
  96. package/templates/site/.env.example +2 -0
  97. package/templates/site/README.md +12 -1
  98. package/templates/site/gitignore +1 -0
  99. package/templates/site/package.json +1 -1
  100. package/templates/site/scripts/ports.mjs +508 -0
  101. package/templates/site/{src → test}/app/page.spec.ts +1 -1
  102. package/templates/site/tsconfig.json +2 -1
  103. package/templates/site/vitest.config.mts +4 -2
  104. package/templates/web/.env.example +3 -0
  105. package/templates/web/README.md +10 -1
  106. package/templates/web/package.json +5 -3
  107. package/templates/web/scripts/ports.mjs +508 -0
  108. package/templates/web/src/features/auth/auth-client.ts +5 -0
  109. package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
  110. package/templates/web/src/features/oauth/consent-form.tsx +63 -0
  111. package/templates/web/src/routeTree.gen.ts +21 -2
  112. package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
  113. package/templates/web/{src → test}/app.spec.tsx +2 -2
  114. package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  115. package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
  116. package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  117. package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
  118. package/templates/web/tsconfig.json +2 -1
  119. package/templates/web/vite.config.ts +3 -3
  120. package/templates/workspace/README.md +7 -3
  121. package/templates/workspace/scripts/ports.mjs +508 -0
  122. package/.prumo-templates/api/modules.md +0 -117
  123. package/templates/api/nest-cli.json +0 -10
  124. package/templates/api/src/app.module.ts +0 -46
  125. package/templates/api/src/auth/auth.constants.ts +0 -2
  126. package/templates/api/src/auth/auth.controller.ts +0 -19
  127. package/templates/api/src/auth/auth.guard.ts +0 -43
  128. package/templates/api/src/auth/auth.module.ts +0 -30
  129. package/templates/api/src/auth/auth.ts +0 -4
  130. package/templates/api/src/auth/current-user.decorator.ts +0 -9
  131. package/templates/api/src/auth/express.d.ts +0 -10
  132. package/templates/api/src/auth/public.decorator.ts +0 -4
  133. package/templates/api/src/config/env.module.ts +0 -11
  134. package/templates/api/src/config/env.ts +0 -81
  135. package/templates/api/src/errors/problem-details.filter.ts +0 -86
  136. package/templates/api/src/errors/validation-exception.factory.ts +0 -30
  137. package/templates/api/src/health/health.controller.ts +0 -32
  138. package/templates/api/src/health/health.module.ts +0 -9
  139. package/templates/api/src/main.ts +0 -43
  140. package/templates/api/src/mikro-orm.factory.ts +0 -14
  141. package/templates/api/src/request-context/request-context.module.ts +0 -9
  142. package/templates/api/src/request-context/request-context.service.ts +0 -17
  143. package/templates/api/src/request-context/request-id.middleware.ts +0 -19
  144. package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
  145. package/templates/api/src/users/entities/profile.entity.ts +0 -27
  146. package/templates/api/src/users/users.controller.ts +0 -23
  147. package/templates/api/src/users/users.module.ts +0 -10
  148. package/templates/api/src/users/users.service.spec.ts +0 -44
  149. package/templates/api/src/users/users.service.ts +0 -36
  150. package/templates/api/test/factories/profile.factory.ts +0 -26
  151. package/templates/api/tsconfig.build.json +0 -8
  152. /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
  153. /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  154. /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  155. /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
@@ -4,11 +4,11 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "vite",
7
+ "dev": "node scripts/ports.mjs -- vite",
8
8
  "build": "vite build",
9
9
  "preview": "vite preview",
10
10
  "lint": "biome check",
11
- "typecheck": "tsc --noEmit",
11
+ "typecheck": "tsr generate && tsc --noEmit",
12
12
  "test": "vitest run",
13
13
  "test:watch": "vitest",
14
14
  "test:cov": "vitest run --coverage",
@@ -16,11 +16,12 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@base-ui/react": "1.8.0",
19
+ "@better-auth/oauth-provider": "1.7.6",
19
20
  "@fontsource-variable/geist": "5.3.0",
20
21
  "@hookform/resolvers": "5.9.1",
21
22
  "@tanstack/react-query": "5.102.8",
22
23
  "@tanstack/react-router": "1.170.36",
23
- "better-auth": "1.7.4",
24
+ "better-auth": "1.7.6",
24
25
  "class-variance-authority": "0.7.1",
25
26
  "cn": "0.3.0",
26
27
  "lucide-react": "1.45.0",
@@ -34,6 +35,7 @@
34
35
  "devDependencies": {
35
36
  "@biomejs/biome": "2.5.13",
36
37
  "@tailwindcss/vite": "4.3.3",
38
+ "@tanstack/router-cli": "1.167.36",
37
39
  "@tanstack/router-plugin": "1.168.38",
38
40
  "@testing-library/jest-dom": "7.0.1",
39
41
  "@testing-library/react": "16.3.3",
@@ -0,0 +1,508 @@
1
+ // Makes sure the ports the apps need are free before anything starts, and runs the dev command with the port it
2
+ // resolved. The same file ships in every app and at a workspace root; the copies are identical on purpose.
3
+ //
4
+ // node scripts/ports.mjs --all every app of a workspace, before its parallel run
5
+ // node scripts/ports.mjs -- vite this app: free its port, run the command, and stop its tree at the end
6
+ // node scripts/ports.mjs --check this app: free its port and stop
7
+ //
8
+ // A port in use is a question, never a silent choice: stop what holds it, or move this app to the next free port.
9
+ // Moving it rewrites the port in .env, and with it every URL that pointed at the old one.
10
+ import { spawn, spawnSync } from 'node:child_process'
11
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
12
+ import { connect } from 'node:net'
13
+ import { basename, dirname, join, resolve } from 'node:path'
14
+ import { createInterface } from 'node:readline/promises'
15
+ import { parseArgs } from 'node:util'
16
+
17
+ // Where each app keeps its port, what it falls back to, and how its dev command is told about it.
18
+ const APPS = {
19
+ api: { key: 'PORT', fallback: 3000, flag: [] },
20
+ web: { key: 'WEB_PORT', fallback: 5173, flag: ['--port', '--strictPort'] },
21
+ site: { key: 'SITE_PORT', fallback: 3200, flag: ['--port'] },
22
+ mobile: { key: 'METRO_PORT', fallback: 8081, flag: ['--port'] },
23
+ }
24
+
25
+ // A port that moves takes these with it: the key to write, in which app, from which app's port.
26
+ const FOLLOWERS = {
27
+ api: [
28
+ { app: 'api', key: 'BETTER_AUTH_URL' },
29
+ { app: 'web', key: 'VITE_API_URL' },
30
+ { app: 'mobile', key: 'EXPO_PUBLIC_API_URL' },
31
+ ],
32
+ web: [{ app: 'api', key: 'WEB_ORIGIN' }],
33
+ site: [],
34
+ mobile: [],
35
+ }
36
+
37
+ const PORTS_TRIED = 20
38
+ const windows = process.platform === 'win32'
39
+
40
+ const { values, positionals } = parseArgs({
41
+ allowPositionals: true,
42
+ options: {
43
+ all: { type: 'boolean', default: false },
44
+ check: { type: 'boolean', default: false },
45
+ kill: { type: 'boolean', default: false },
46
+ change: { type: 'boolean', default: false },
47
+ json: { type: 'boolean', default: false },
48
+ },
49
+ })
50
+
51
+ const json = values.json
52
+ const interactive = !json && process.stdin.isTTY === true && process.stdout.isTTY === true
53
+ const here = resolve(import.meta.dirname, '..')
54
+
55
+ class Failure extends Error {
56
+ constructor(code, message) {
57
+ super(message)
58
+ this.code = code
59
+ }
60
+ }
61
+
62
+ function say(message) {
63
+ ;(json ? process.stderr : process.stdout).write(`${message}\n`)
64
+ }
65
+
66
+ async function choose(question, answers) {
67
+ const readline = createInterface({ input: process.stdin, output: process.stdout })
68
+ const answer = (await readline.question(`${question} [${answers.join('/')}]: `))
69
+ .trim()
70
+ .toLowerCase()
71
+ readline.close()
72
+
73
+ return answers.find((option) => option.startsWith(answer === '' ? answers[0] : answer))
74
+ }
75
+
76
+ function envPath(directory) {
77
+ return join(directory, '.env')
78
+ }
79
+
80
+ function readEnv(directory) {
81
+ const path = envPath(directory)
82
+
83
+ return existsSync(path) ? readFileSync(path, 'utf8') : undefined
84
+ }
85
+
86
+ function envValue(env, key) {
87
+ return new RegExp(`^${key}=(.*)$`, 'm').exec(env ?? '')?.[1]?.trim()
88
+ }
89
+
90
+ function writeEnvValue(directory, key, value) {
91
+ const env = readEnv(directory)
92
+
93
+ if (env === undefined) {
94
+ return
95
+ }
96
+
97
+ const line = new RegExp(`^${key}=.*$`, 'm')
98
+ writeFileSync(
99
+ envPath(directory),
100
+ line.test(env)
101
+ ? env.replace(line, `${key}=${value}`)
102
+ : `${env.replace(/\n?$/, '\n')}${key}=${value}\n`,
103
+ )
104
+ }
105
+
106
+ // The project root holds .prumo/; an app of a workspace sits two levels below it.
107
+ function projectRoot() {
108
+ let directory = here
109
+
110
+ while (!existsSync(join(directory, '.prumo')) && dirname(directory) !== directory) {
111
+ directory = dirname(directory)
112
+ }
113
+
114
+ return existsSync(join(directory, '.prumo')) ? directory : here
115
+ }
116
+
117
+ const root = projectRoot()
118
+ const workspace = existsSync(join(root, 'apps'))
119
+
120
+ function directoryOf(type) {
121
+ return workspace ? join(root, 'apps', type) : root
122
+ }
123
+
124
+ function typeOf(directory) {
125
+ const name = basename(directory)
126
+
127
+ if (APPS[name] !== undefined) {
128
+ return name
129
+ }
130
+
131
+ // An alone project is named by its author, so only .prumo/config.json says what it is.
132
+ const config = join(root, '.prumo', 'config.json')
133
+ const types = existsSync(config) ? JSON.parse(readFileSync(config, 'utf8')).types : []
134
+
135
+ return types[0]
136
+ }
137
+
138
+ function typesToCheck() {
139
+ if (!values.all) {
140
+ return [typeOf(here)]
141
+ }
142
+
143
+ return Object.keys(APPS).filter((type) => existsSync(join(directoryOf(type), 'package.json')))
144
+ }
145
+
146
+ function portOf(type) {
147
+ const app = APPS[type]
148
+
149
+ return Number(envValue(readEnv(directoryOf(type)), app.key) ?? app.fallback)
150
+ }
151
+
152
+ function listening(host, port) {
153
+ return new Promise((done) => {
154
+ const socket = connect({ host, port, timeout: 1000 })
155
+ const finish = (open) => {
156
+ socket.destroy()
157
+ done(open)
158
+ }
159
+ socket.once('connect', () => finish(true))
160
+ socket.once('timeout', () => finish(false))
161
+ socket.once('error', () => finish(false))
162
+ })
163
+ }
164
+
165
+ async function taken(port) {
166
+ // A server may be bound to only one of the two loopback addresses.
167
+ return (await listening('127.0.0.1', port)) || (await listening('::1', port))
168
+ }
169
+
170
+ // Who holds the port, as pid and command, so that nothing is ever killed unnamed.
171
+ function holders(port) {
172
+ if (windows) {
173
+ const lines = spawnSync('netstat', ['-ano'], { encoding: 'utf8' }).stdout ?? ''
174
+ const pids = new Set(
175
+ lines
176
+ .split('\n')
177
+ .filter((line) => /LISTENING/.test(line) && new RegExp(`[:.]${port}\\s`).test(line))
178
+ .map((line) => line.trim().split(/\s+/).at(-1))
179
+ .filter((pid) => pid !== undefined && pid !== '0'),
180
+ )
181
+
182
+ return [...pids].map((pid) => ({ pid: Number(pid), command: commandOf(pid) }))
183
+ }
184
+
185
+ const found = spawnSync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' })
186
+ const pids = new Set((found.stdout ?? '').split('\n').filter(Boolean))
187
+
188
+ return [...pids].map((pid) => ({ pid: Number(pid), command: commandOf(pid) }))
189
+ }
190
+
191
+ function commandOf(pid) {
192
+ const result = windows
193
+ ? spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'], { encoding: 'utf8' })
194
+ : spawnSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8' })
195
+ const output = (result.stdout ?? '').trim()
196
+
197
+ return windows
198
+ ? (output.split(',')[0]?.replaceAll('"', '') ?? 'unknown')
199
+ : basename(output || 'unknown')
200
+ }
201
+
202
+ async function freed(port) {
203
+ for (let attempt = 0; attempt < 30; attempt += 1) {
204
+ if (!(await taken(port))) {
205
+ return true
206
+ }
207
+ await new Promise((done) => setTimeout(done, 100))
208
+ }
209
+
210
+ return false
211
+ }
212
+
213
+ async function stopHolders(port, found) {
214
+ for (const { pid } of found) {
215
+ if (windows) {
216
+ spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
217
+ } else {
218
+ // The whole group, so that a shell's children go with it; a lone process when it leads no group.
219
+ try {
220
+ process.kill(-pid, 'SIGTERM')
221
+ } catch {
222
+ try {
223
+ process.kill(pid, 'SIGTERM')
224
+ } catch {}
225
+ }
226
+ }
227
+ }
228
+
229
+ if (await freed(port)) {
230
+ say(`Port ${port} is free.`)
231
+ return
232
+ }
233
+
234
+ for (const { pid } of found) {
235
+ try {
236
+ process.kill(windows ? pid : -pid, 'SIGKILL')
237
+ } catch {}
238
+ }
239
+
240
+ if (!(await freed(port))) {
241
+ throw new Failure('port_busy', `Port ${port} is still in use by ${describe(found)}.`)
242
+ }
243
+
244
+ say(`Port ${port} is free.`)
245
+ }
246
+
247
+ function describe(found) {
248
+ return (
249
+ found.map(({ pid, command }) => `${command} (pid ${pid})`).join(', ') || 'an unknown process'
250
+ )
251
+ }
252
+
253
+ async function nextFree(from) {
254
+ for (let port = from + 1; port < from + PORTS_TRIED; port += 1) {
255
+ if (!(await taken(port))) {
256
+ return port
257
+ }
258
+ }
259
+
260
+ throw new Failure('port_busy', `No free port between ${from + 1} and ${from + PORTS_TRIED - 1}.`)
261
+ }
262
+
263
+ function movePort(type, port) {
264
+ const app = APPS[type]
265
+
266
+ writeEnvValue(directoryOf(type), app.key, port)
267
+
268
+ for (const follower of FOLLOWERS[type]) {
269
+ const directory = directoryOf(follower.app)
270
+
271
+ if (existsSync(join(directory, 'package.json'))) {
272
+ writeEnvValue(directory, follower.key, `http://localhost:${port}`)
273
+ }
274
+ }
275
+ }
276
+
277
+ async function settle(type) {
278
+ const port = portOf(type)
279
+
280
+ if (!(await taken(port))) {
281
+ return port
282
+ }
283
+
284
+ const found = holders(port)
285
+
286
+ say(`${type} wants port ${port}, held by ${describe(found)}.`)
287
+
288
+ const moved = async () => {
289
+ const free = await nextFree(port)
290
+ movePort(type, free)
291
+ say(`${type} moved to port ${free}, written to .env.`)
292
+ return free
293
+ }
294
+
295
+ if (values.kill) {
296
+ await stopHolders(port, found)
297
+ return port
298
+ }
299
+ if (values.change) {
300
+ return moved()
301
+ }
302
+ if (!interactive) {
303
+ throw new Failure(
304
+ 'port_busy',
305
+ `Port ${port} is in use by ${describe(found)}. Pass --kill to stop it, or --change to move ${type} to the next free port.`,
306
+ )
307
+ }
308
+
309
+ const answer = await choose('Stop that process, or move this app?', ['stop', 'move', 'cancel'])
310
+
311
+ if (answer === 'stop') {
312
+ await stopHolders(port, found)
313
+ return port
314
+ }
315
+ if (answer === 'move') {
316
+ return moved()
317
+ }
318
+
319
+ throw new Failure('declined', 'Nothing was started.')
320
+ }
321
+
322
+ // Every process below one, as the system sees them right now.
323
+ function processTree(pid) {
324
+ const listed = windows
325
+ ? spawnSync(
326
+ 'powershell',
327
+ [
328
+ '-NoProfile',
329
+ '-Command',
330
+ 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }',
331
+ ],
332
+ { encoding: 'utf8' },
333
+ )
334
+ : spawnSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8' })
335
+ const children = new Map()
336
+
337
+ for (const line of (listed.stdout ?? '').split('\n')) {
338
+ const [child, parent] = line.trim().split(/\s+/).map(Number)
339
+
340
+ if (Number.isInteger(child) && Number.isInteger(parent)) {
341
+ children.set(parent, [...(children.get(parent) ?? []), child])
342
+ }
343
+ }
344
+
345
+ const tree = []
346
+ const pending = [pid]
347
+
348
+ while (pending.length > 0) {
349
+ const current = pending.shift()
350
+
351
+ tree.push(current)
352
+ pending.push(...(children.get(current) ?? []))
353
+ }
354
+
355
+ // Deepest first, so a parent does not restart what was just stopped.
356
+ return tree.reverse()
357
+ }
358
+
359
+ function signal(pid, sign) {
360
+ try {
361
+ process.kill(pid, sign)
362
+ } catch {}
363
+ }
364
+
365
+ // `pnpm -r --parallel` starts each app in a process group of its own, so the terminal's Ctrl+C reaches pnpm and
366
+ // nothing below it. Whatever was started here is therefore stopped from here, and never anything else: the tree is
367
+ // read before the first signal, while the processes still point at their parents.
368
+ async function shutdown(child, ports, known) {
369
+ const tree = [...new Set([...processTree(child.pid), ...known])].reverse()
370
+ const stopped = []
371
+
372
+ if (windows) {
373
+ spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
374
+ } else {
375
+ for (const pid of tree) {
376
+ signal(pid, 'SIGTERM')
377
+ }
378
+ }
379
+
380
+ for (const port of ports) {
381
+ if (!(await freed(port))) {
382
+ const ours = holders(port).filter((holder) => tree.includes(holder.pid))
383
+ const theirs = holders(port).filter((holder) => !tree.includes(holder.pid))
384
+
385
+ for (const { pid, command } of ours) {
386
+ signal(pid, 'SIGKILL')
387
+ stopped.push(`${command} (pid ${pid}) on port ${port}`)
388
+ }
389
+
390
+ if (theirs.length > 0) {
391
+ say(`Port ${port} is still held by ${describe(theirs)}, which this project did not start.`)
392
+ }
393
+ }
394
+ }
395
+
396
+ if (stopped.length > 0) {
397
+ say(`Stopped what was left behind: ${stopped.join(', ')}.`)
398
+ }
399
+
400
+ return stopped
401
+ }
402
+
403
+ async function supervise(child, ports) {
404
+ // Ctrl+C reaches the child through the terminal; this process only waits, and then makes sure nothing survived.
405
+ // Once the child is gone, its own children answer to the system and no longer to it, so who they were is
406
+ // remembered while they are still running: on a schedule, and again the moment the signal arrives.
407
+ const known = new Set([child.pid])
408
+ const remember = () => {
409
+ for (const pid of processTree(child.pid)) {
410
+ known.add(pid)
411
+ }
412
+ }
413
+
414
+ let interrupted = false
415
+ const interrupt = (sign) => {
416
+ interrupted = true
417
+ remember()
418
+
419
+ // The terminal signals the whole group, but a parent that signals this process alone would leave the child
420
+ // running and this one waiting on it forever.
421
+ for (const pid of [...known].reverse()) {
422
+ signal(pid, sign)
423
+ }
424
+ }
425
+
426
+ const onInterrupt = () => interrupt('SIGINT')
427
+ const onTerminate = () => interrupt('SIGTERM')
428
+
429
+ process.on('SIGINT', onInterrupt)
430
+ process.on('SIGTERM', onTerminate)
431
+
432
+ remember()
433
+ const watch = setInterval(remember, 2000)
434
+
435
+ let patience
436
+ const code = await new Promise((done) => {
437
+ child.once('error', () => done(1))
438
+ child.once('exit', (status, sign) => done(sign === null ? (status ?? 1) : 0))
439
+
440
+ // A child that will not go after being asked does not hold this process hostage; shutdown kills it.
441
+ patience = setInterval(() => {
442
+ if (interrupted) {
443
+ clearInterval(patience)
444
+ patience = setTimeout(() => done(130), 10_000)
445
+ }
446
+ }, 200)
447
+ })
448
+
449
+ clearInterval(patience)
450
+ clearTimeout(patience)
451
+ clearInterval(watch)
452
+
453
+ const stopped = await shutdown(child, ports, known)
454
+
455
+ process.off('SIGINT', onInterrupt)
456
+ process.off('SIGTERM', onTerminate)
457
+ process.exitCode = interrupted ? 0 : code
458
+
459
+ return stopped
460
+ }
461
+
462
+ async function main() {
463
+ const ports = {}
464
+
465
+ for (const type of typesToCheck()) {
466
+ if (type !== undefined) {
467
+ ports[type] = await settle(type)
468
+ }
469
+ }
470
+
471
+ const [command, ...args] = positionals
472
+
473
+ if (command === undefined) {
474
+ return { ports }
475
+ }
476
+
477
+ // Only an app tells its command which port to use. A workspace root wraps `pnpm`, which would pass a stray
478
+ // `--port` on to every app it runs.
479
+ const [type] = values.all ? [] : Object.keys(ports)
480
+ const flag = APPS[type ?? '']?.flag ?? []
481
+ const withPort = flag.flatMap((entry) =>
482
+ entry === '--port' ? ['--port', String(ports[type])] : [entry],
483
+ )
484
+ const child = spawn(command, [...args, ...withPort], { stdio: 'inherit', shell: windows })
485
+ const stopped = await supervise(child, Object.values(ports))
486
+
487
+ return { ports, ran: [command, ...args, ...withPort].join(' '), stopped }
488
+ }
489
+
490
+ try {
491
+ const data = await main()
492
+
493
+ if (json) {
494
+ process.stdout.write(`${JSON.stringify({ ok: true, command: 'ports', data })}\n`)
495
+ }
496
+ } catch (error) {
497
+ const code = error instanceof Failure ? error.code : 'unexpected'
498
+ const message = error instanceof Error ? error.message : String(error)
499
+
500
+ if (json) {
501
+ process.stdout.write(
502
+ `${JSON.stringify({ ok: false, command: 'ports', error: { code, message } })}\n`,
503
+ )
504
+ } else {
505
+ process.stderr.write(`${message}\n`)
506
+ }
507
+ process.exitCode = 1
508
+ }
@@ -1,3 +1,4 @@
1
+ import { oauthProviderClient } from '@better-auth/oauth-provider/client' // prumo:mcp
1
2
  import { createAuthClient } from 'better-auth/react'
2
3
  import type { Transport } from '@/api-contract'
3
4
 
@@ -8,5 +9,9 @@ export function createAuth({ baseUrl, fetch }: { baseUrl: string; fetch: Transpo
8
9
  baseURL: baseUrl,
9
10
  basePath: '/api/auth',
10
11
  fetchOptions: { customFetchImpl: fetch },
12
+ // prumo:mcp
13
+ // Carries the signed query of an MCP client's authorization through sign-in and consent.
14
+ plugins: [oauthProviderClient()],
15
+ // prumo:end-mcp
11
16
  })
12
17
  }
@@ -31,13 +31,20 @@ export function SignInForm({ auth, redirect }: { auth: AuthClient; redirect: str
31
31
  const { errors, isSubmitting } = form.formState
32
32
 
33
33
  async function onSubmit(values: SignInValues) {
34
- const { error } = await auth.signIn.email(values)
34
+ const result = await auth.signIn.email(values)
35
35
 
36
- if (error !== null) {
37
- applyServerError(ApiError.fromAuthError(error), ['email', 'password'], form.setError)
36
+ if (result.error !== null) {
37
+ applyServerError(ApiError.fromAuthError(result.error), ['email', 'password'], form.setError)
38
38
  return
39
39
  }
40
40
 
41
+ // prumo:mcp
42
+ // Signing in during an MCP client's authorization: Better Auth has already sent the browser on.
43
+ if (result.data.redirect) {
44
+ return
45
+ }
46
+
47
+ // prumo:end-mcp
41
48
  // Invalidating is not enough: nothing observes the session, and the protected route's ensureQueryData
42
49
  // would keep returning the cached null. Refetching replaces it before the route reads it.
43
50
  await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
@@ -0,0 +1,63 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { useState } from 'react'
3
+ import { Button } from '@/components/ui/button'
4
+ import type { AuthClient } from '@/features/auth/auth-client'
5
+
6
+ type PublicClient = { client_name?: string }
7
+
8
+ // Better Auth checks the signed query on submit; what is shown here comes from the same URL.
9
+ export function ConsentForm({
10
+ auth,
11
+ clientId,
12
+ scopes,
13
+ }: {
14
+ auth: AuthClient
15
+ clientId: string
16
+ scopes: string[]
17
+ }) {
18
+ const [failed, setFailed] = useState(false)
19
+ const client = useQuery({
20
+ queryKey: ['oauth-client', clientId],
21
+ queryFn: async () => {
22
+ const { data } = await auth.$fetch<PublicClient>('/oauth2/public-client', {
23
+ query: { client_id: clientId },
24
+ })
25
+
26
+ return data
27
+ },
28
+ })
29
+ const name = client.data?.client_name ?? clientId
30
+
31
+ async function answer(accept: boolean) {
32
+ // On success Better Auth sends the browser back to the client, so nothing follows here.
33
+ const { error } = await auth.$fetch('/oauth2/consent', { method: 'POST', body: { accept } })
34
+
35
+ setFailed(error !== null)
36
+ }
37
+
38
+ return (
39
+ <div className="flex flex-col gap-6">
40
+ <p>
41
+ <strong>{name}</strong> wants to act on your behalf.
42
+ </p>
43
+ {scopes.length > 0 && (
44
+ <ul className="list-disc pl-6 text-sm text-muted-foreground">
45
+ {scopes.map((scope) => (
46
+ <li key={scope}>{scope}</li>
47
+ ))}
48
+ </ul>
49
+ )}
50
+ {failed && (
51
+ <p role="alert" className="text-sm text-destructive">
52
+ The authorization could not be completed. Start again from the application.
53
+ </p>
54
+ )}
55
+ <div className="flex gap-3">
56
+ <Button onClick={() => answer(true)}>Allow</Button>
57
+ <Button variant="outline" onClick={() => answer(false)}>
58
+ Deny
59
+ </Button>
60
+ </div>
61
+ </div>
62
+ )
63
+ }