@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.
- package/.prumo-templates/api/auth.md +51 -95
- package/.prumo-templates/api/config.md +30 -56
- package/.prumo-templates/api/errors.md +64 -44
- package/.prumo-templates/api/logging.md +27 -44
- package/.prumo-templates/api/observability.md +36 -62
- package/.prumo-templates/api/pagination.md +23 -38
- package/.prumo-templates/api/routes.md +67 -48
- package/.prumo-templates/api/structure.md +130 -0
- package/.prumo-templates/client/data.md +4 -3
- package/.prumo-templates/core/conventions.md +10 -7
- package/.prumo-templates/core/testing.md +43 -26
- package/.prumo-templates/core/tooling.md +33 -30
- package/.prumo-templates/database/entities.md +53 -61
- package/.prumo-templates/database/queries.md +34 -47
- package/.prumo-templates/database/transactions.md +28 -38
- package/.prumo-templates/mcp/server.md +54 -0
- package/.prumo-templates/monorepo/dependencies.md +3 -3
- package/.prumo-templates/monorepo/layout.md +1 -1
- package/README.md +12 -6
- package/dist/asker.js +4 -0
- package/dist/clean.js +2 -2
- package/dist/cli.js +4 -0
- package/dist/commands.js +2 -0
- package/dist/compose.js +12 -8
- package/dist/context.js +2 -0
- package/dist/generate.js +8 -0
- package/dist/mcp.js +100 -0
- package/dist/questions.js +21 -1
- package/package.json +4 -3
- package/templates/api/.swcrc +13 -0
- package/templates/api/README.md +10 -1
- package/templates/api/biome.jsonc +5 -3
- package/templates/api/package.json +22 -28
- package/templates/api/pnpm-workspace.yaml +0 -2
- package/templates/api/scripts/ports.mjs +508 -0
- package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
- package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
- package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
- package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
- package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
- package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
- package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
- package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
- package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
- package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
- package/templates/api/src/infra/auth/auth.cli.ts +5 -0
- package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
- package/templates/api/src/infra/config/env.ts +47 -0
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
- package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
- package/templates/api/src/infra/di/index.ts +32 -0
- package/templates/api/src/infra/di/users.di.ts +7 -0
- package/templates/api/src/infra/http/app.ts +99 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
- package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
- package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
- package/templates/api/src/infra/http/errors/http-error.ts +8 -0
- package/templates/api/src/infra/http/fastify.d.ts +17 -0
- package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
- package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
- package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
- package/templates/api/src/infra/http/server.ts +34 -0
- package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
- package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
- package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
- package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
- package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
- package/templates/api/test/infra/http/app.spec.ts +21 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
- package/templates/api/test/support/factories/profile.factory.ts +25 -0
- package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
- package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
- package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
- package/templates/api/tsconfig.json +3 -6
- package/templates/api/vitest.config.mts +4 -4
- package/templates/mobile/.env.example +3 -0
- package/templates/mobile/README.md +10 -0
- package/templates/mobile/jest.config.js +5 -1
- package/templates/mobile/package.json +3 -3
- package/templates/mobile/scripts/ports.mjs +508 -0
- package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
- package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/mobile/tsconfig.json +2 -1
- package/templates/site/.env.example +2 -0
- package/templates/site/README.md +12 -1
- package/templates/site/gitignore +1 -0
- package/templates/site/package.json +1 -1
- package/templates/site/scripts/ports.mjs +508 -0
- package/templates/site/{src → test}/app/page.spec.ts +1 -1
- package/templates/site/tsconfig.json +2 -1
- package/templates/site/vitest.config.mts +4 -2
- package/templates/web/.env.example +3 -0
- package/templates/web/README.md +10 -1
- package/templates/web/package.json +5 -3
- package/templates/web/scripts/ports.mjs +508 -0
- package/templates/web/src/features/auth/auth-client.ts +5 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
- package/templates/web/src/features/oauth/consent-form.tsx +63 -0
- package/templates/web/src/routeTree.gen.ts +21 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
- package/templates/web/{src → test}/app.spec.tsx +2 -2
- package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
- package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
- package/templates/web/tsconfig.json +2 -1
- package/templates/web/vite.config.ts +3 -3
- package/templates/workspace/README.md +7 -3
- package/templates/workspace/scripts/ports.mjs +508 -0
- package/.prumo-templates/api/modules.md +0 -117
- package/templates/api/nest-cli.json +0 -10
- package/templates/api/src/app.module.ts +0 -46
- package/templates/api/src/auth/auth.constants.ts +0 -2
- package/templates/api/src/auth/auth.controller.ts +0 -19
- package/templates/api/src/auth/auth.guard.ts +0 -43
- package/templates/api/src/auth/auth.module.ts +0 -30
- package/templates/api/src/auth/auth.ts +0 -4
- package/templates/api/src/auth/current-user.decorator.ts +0 -9
- package/templates/api/src/auth/express.d.ts +0 -10
- package/templates/api/src/auth/public.decorator.ts +0 -4
- package/templates/api/src/config/env.module.ts +0 -11
- package/templates/api/src/config/env.ts +0 -81
- package/templates/api/src/errors/problem-details.filter.ts +0 -86
- package/templates/api/src/errors/validation-exception.factory.ts +0 -30
- package/templates/api/src/health/health.controller.ts +0 -32
- package/templates/api/src/health/health.module.ts +0 -9
- package/templates/api/src/main.ts +0 -43
- package/templates/api/src/mikro-orm.factory.ts +0 -14
- package/templates/api/src/request-context/request-context.module.ts +0 -9
- package/templates/api/src/request-context/request-context.service.ts +0 -17
- package/templates/api/src/request-context/request-id.middleware.ts +0 -19
- package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
- package/templates/api/src/users/entities/profile.entity.ts +0 -27
- package/templates/api/src/users/users.controller.ts +0 -23
- package/templates/api/src/users/users.module.ts +0 -10
- package/templates/api/src/users/users.service.spec.ts +0 -44
- package/templates/api/src/users/users.service.ts +0 -36
- package/templates/api/test/factories/profile.factory.ts +0 -26
- package/templates/api/tsconfig.build.json +0 -8
- /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
- /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
|
@@ -0,0 +1,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,117 +0,0 @@
|
|
|
1
|
-
# Modules
|
|
2
|
-
|
|
3
|
-
## Rule
|
|
4
|
-
|
|
5
|
-
Create one module per resource: `users`, `orders`, `invoices`.
|
|
6
|
-
|
|
7
|
-
A module contains exactly what `nest g resource` produces: `*.module.ts`, `*.controller.ts`,
|
|
8
|
-
`*.service.ts`, `dto/`, `entities/` and the co-located spec files. Add nothing to that set, **except further
|
|
9
|
-
services**.
|
|
10
|
-
|
|
11
|
-
Add a service to a module when a group of its methods has a name of its own that the resource's name does
|
|
12
|
-
not cover: `order-refunds`, `order-shipping`. Generate it with `nest g service <name> <module> --flat`, beside
|
|
13
|
-
the resource's service. Do not split by line count, and do not create a `services/` directory or one service
|
|
14
|
-
per action.
|
|
15
|
-
|
|
16
|
-
Two services in one module never inject each other in both directions.
|
|
17
|
-
|
|
18
|
-
Export the service. Mark every method that serves only its own module `private`.
|
|
19
|
-
|
|
20
|
-
Reach another module through `imports: [OtherModule]` and inject what it exports. Reference another
|
|
21
|
-
module's entity when a relation requires it.
|
|
22
|
-
|
|
23
|
-
Two modules never depend on each other. When they appear to, move the common part into a third
|
|
24
|
-
module. Do not use `forwardRef`.
|
|
25
|
-
|
|
26
|
-
Do not use barrel files inside a module.
|
|
27
|
-
|
|
28
|
-
Anything serving more than one module becomes its own module, named for what it does:
|
|
29
|
-
`notifications`, `pdf`, `storage`. There is no `SharedModule`.
|
|
30
|
-
|
|
31
|
-
A pure function is a function in a file, imported directly. It gets no module.
|
|
32
|
-
|
|
33
|
-
## Rationale
|
|
34
|
-
|
|
35
|
-
Nest supplies the mechanism and decides none of this, so every rule above exists to remove a choice
|
|
36
|
-
that would otherwise be made differently in each module and by each assistant.
|
|
37
|
-
|
|
38
|
-
One module per resource is the shape `nest g resource` generates and the shape every Nest example
|
|
39
|
-
already has, so the generator and the convention never disagree and a generated module needs no
|
|
40
|
-
correction afterwards. Designing an anatomy instead is what previously cost thirteen files per
|
|
41
|
-
feature, five of which carried no guarantee.
|
|
42
|
-
|
|
43
|
-
`private` makes the compiler enforce the module's surface, at no cost. A cycle is refused because
|
|
44
|
-
Nest's own guidance is to avoid one: `forwardRef` is its escape hatch, and its documented caveats
|
|
45
|
-
include an indeterminate order of instantiation, the class of failure that appears once in
|
|
46
|
-
production and does not reproduce. Barrel files are refused because Nest names them as a way to
|
|
47
|
-
create a cycle nobody can see.
|
|
48
|
-
|
|
49
|
-
**The extra service exists for the module that is large and still one thing.** Splitting into another
|
|
50
|
-
module is right when a second responsibility has crept in, and wrong when it has not: two modules sharing the
|
|
51
|
-
same entities and invariants end up wanting to call each other, which is the cycle refused above. Nest's own
|
|
52
|
-
answer is several providers in one module, and `nest g service <name> <module>` both creates the file and
|
|
53
|
-
registers it, so the generator and the convention still agree.
|
|
54
|
-
|
|
55
|
-
The criterion is a name rather than a size because a line limit ages and invites gaming: somebody moves
|
|
56
|
-
arbitrary methods out to pass the number, and the result is worse than the long file. `--flat` keeps the
|
|
57
|
-
anatomy one level deep. One service per action is refused on evidence: it is what produced thirteen files
|
|
58
|
-
per feature.
|
|
59
|
-
|
|
60
|
-
**What this costs:** the split is a judgement, held by review. And a module with several services can form a
|
|
61
|
-
cycle inside itself, which is why the cycle rule now reaches between services as well as between modules.
|
|
62
|
-
|
|
63
|
-
A `SharedModule` has no admission criterion, so it only grows. Requiring a name forces the question
|
|
64
|
-
*what is this*, and a module nobody can name should not exist.
|
|
65
|
-
|
|
66
|
-
## Applies to
|
|
67
|
-
|
|
68
|
-
Every module under `src/`. The rules about reaching across modules apply wherever one module's code
|
|
69
|
-
refers to another's.
|
|
70
|
-
|
|
71
|
-
## Examples
|
|
72
|
-
|
|
73
|
-
Exporting the service, with internals closed:
|
|
74
|
-
|
|
75
|
-
```
|
|
76
|
-
✅ export class UsersService {
|
|
77
|
-
findById(id: string) {}
|
|
78
|
-
private normaliseEmail(v: string) {}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
❌ export class UsersService {
|
|
82
|
-
findById(id: string) {}
|
|
83
|
-
normaliseEmail(v: string) {} // reachable from any module that injects this
|
|
84
|
-
}
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
Growing a module that is still one thing:
|
|
88
|
-
|
|
89
|
-
```
|
|
90
|
-
✅ orders/orders.service.ts
|
|
91
|
-
orders/order-refunds.service.ts // nest g service order-refunds orders --flat
|
|
92
|
-
❌ orders/services/cancel-order.service.ts
|
|
93
|
-
❌ refunds/refunds.module.ts // needs orders' entities, and orders needs it back
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
Reaching another module:
|
|
97
|
-
|
|
98
|
-
```
|
|
99
|
-
✅ @Module({ imports: [UsersModule] })
|
|
100
|
-
❌ @Module({ imports: [forwardRef(() => UsersModule)] })
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
Importing inside a module:
|
|
104
|
-
|
|
105
|
-
```
|
|
106
|
-
✅ import { UsersService } from './users.service'
|
|
107
|
-
❌ import { UsersService } from './index'
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
## Enforcement
|
|
111
|
-
|
|
112
|
-
**Lint.** Barrel-file imports inside a module are verifiable and are caught by a lint rule.
|
|
113
|
-
|
|
114
|
-
**Review only.** Everything else. Nothing checks that a module maps to a resource, that an extra service
|
|
115
|
-
has a name of its own rather than an arbitrary slice, that a method that
|
|
116
|
-
should be `private` is, or that a `SharedModule` has not been created; a reviewer does. `private`
|
|
117
|
-
prevents accidental injection; it does not prevent someone widening the method next week to reach it.
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { MikroOrmModule } from '@mikro-orm/nestjs'
|
|
2
|
-
import { PostgreSqlDriver } from '@mikro-orm/postgresql'
|
|
3
|
-
import { type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common'
|
|
4
|
-
import { APP_FILTER } from '@nestjs/core'
|
|
5
|
-
import { LoggerModule } from 'nestjs-pino'
|
|
6
|
-
import { AuthModule } from '@/auth/auth.module'
|
|
7
|
-
import { Env } from '@/config/env'
|
|
8
|
-
import { EnvModule } from '@/config/env.module'
|
|
9
|
-
import { ProblemDetailsFilter } from '@/errors/problem-details.filter'
|
|
10
|
-
import { HealthModule } from '@/health/health.module'
|
|
11
|
-
import { createOrmConfig } from '@/mikro-orm.factory'
|
|
12
|
-
import { RequestContextModule } from '@/request-context/request-context.module'
|
|
13
|
-
import { RequestIdMiddleware } from '@/request-context/request-id.middleware'
|
|
14
|
-
import { UsersModule } from '@/users/users.module'
|
|
15
|
-
|
|
16
|
-
@Module({
|
|
17
|
-
imports: [
|
|
18
|
-
EnvModule,
|
|
19
|
-
LoggerModule.forRootAsync({
|
|
20
|
-
inject: [Env],
|
|
21
|
-
useFactory: (env: Env) => ({
|
|
22
|
-
pinoHttp: {
|
|
23
|
-
level: env.LOG_LEVEL,
|
|
24
|
-
redact: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
|
|
25
|
-
transport: env.NODE_ENV === 'development' ? { target: 'pino-pretty' } : undefined,
|
|
26
|
-
autoLogging: { ignore: (req) => req.url?.startsWith('/api/health') === true },
|
|
27
|
-
},
|
|
28
|
-
}),
|
|
29
|
-
}),
|
|
30
|
-
MikroOrmModule.forRootAsync({
|
|
31
|
-
inject: [Env],
|
|
32
|
-
driver: PostgreSqlDriver,
|
|
33
|
-
useFactory: (env: Env) => createOrmConfig(env),
|
|
34
|
-
}),
|
|
35
|
-
RequestContextModule,
|
|
36
|
-
AuthModule,
|
|
37
|
-
HealthModule,
|
|
38
|
-
UsersModule,
|
|
39
|
-
],
|
|
40
|
-
providers: [{ provide: APP_FILTER, useClass: ProblemDetailsFilter }],
|
|
41
|
-
})
|
|
42
|
-
export class AppModule implements NestModule {
|
|
43
|
-
configure(consumer: MiddlewareConsumer): void {
|
|
44
|
-
consumer.apply(RequestIdMiddleware).forRoutes('*path')
|
|
45
|
-
}
|
|
46
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { All, Controller, Inject, Req, Res, VERSION_NEUTRAL } from '@nestjs/common'
|
|
2
|
-
import { ApiExcludeController } from '@nestjs/swagger'
|
|
3
|
-
import { toNodeHandler } from 'better-auth/node'
|
|
4
|
-
import type { Request, Response } from 'express'
|
|
5
|
-
import { AUTH } from './auth.constants'
|
|
6
|
-
import type { Auth } from './auth.factory'
|
|
7
|
-
import { Public } from './public.decorator'
|
|
8
|
-
|
|
9
|
-
@ApiExcludeController()
|
|
10
|
-
@Controller({ path: 'auth', version: VERSION_NEUTRAL })
|
|
11
|
-
export class AuthController {
|
|
12
|
-
constructor(@Inject(AUTH) private readonly auth: Auth) {}
|
|
13
|
-
|
|
14
|
-
@Public()
|
|
15
|
-
@All('*path')
|
|
16
|
-
handle(@Req() request: Request, @Res() response: Response): Promise<void> {
|
|
17
|
-
return toNodeHandler(this.auth)(request, response)
|
|
18
|
-
}
|
|
19
|
-
}
|