@stjoseph/prumo 0.0.5 → 0.0.6
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/README.md +6 -0
- package/dist/clean.js +2 -2
- package/dist/compose.js +6 -6
- package/package.json +1 -1
- package/templates/api/README.md +10 -1
- package/templates/api/package.json +1 -1
- package/templates/api/scripts/ports.mjs +508 -0
- package/templates/mobile/.env.example +3 -0
- package/templates/mobile/README.md +10 -0
- package/templates/mobile/jest.config.js +4 -0
- package/templates/mobile/package.json +1 -1
- package/templates/mobile/scripts/ports.mjs +508 -0
- 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/web/.env.example +3 -0
- package/templates/web/README.md +10 -1
- package/templates/web/package.json +1 -1
- package/templates/web/scripts/ports.mjs +508 -0
- package/templates/workspace/README.md +7 -3
- package/templates/workspace/scripts/ports.mjs +508 -0
package/README.md
CHANGED
|
@@ -120,6 +120,12 @@ Once written, those documents belong to the team. Prumo does not come back and r
|
|
|
120
120
|
| [`docs/DECISIONS.md`](https://github.com/stjosephworks/prumo/blob/main/docs/DECISIONS.md) | Every decision, why it won, and what it costs |
|
|
121
121
|
| [`docs/OPEN-QUESTIONS.md`](https://github.com/stjosephworks/prumo/blob/main/docs/OPEN-QUESTIONS.md) | What has been raised and is still unresolved |
|
|
122
122
|
|
|
123
|
+
## Releasing
|
|
124
|
+
|
|
125
|
+
`main` publishes itself. A push to `main` runs the whole CI, and when `cli/package.json` carries a version the
|
|
126
|
+
registry does not have yet, the `publish` job sends it to npm through npm's trusted publishing, with provenance and
|
|
127
|
+
without a token in the repository. Any other push to `main` publishes nothing.
|
|
128
|
+
|
|
123
129
|
## Status
|
|
124
130
|
|
|
125
131
|
Prumo is in beta. `0.0.1` is the first published version: every template runs and every generated
|
package/dist/clean.js
CHANGED
|
@@ -17,7 +17,7 @@ export const README_PARAGRAPH = '`DATABASE_URL` starts as `MISSING`. While it is
|
|
|
17
17
|
const README_REPLACEMENT = 'Before the first `pnpm dev`, start Postgres with `docker compose up -d --wait` and run `pnpm db:migrate`.\n' +
|
|
18
18
|
'`.env.example` points at that service on port 5432; when `POSTGRES_PORT` changes, change both URLs with it.\n';
|
|
19
19
|
export const README_ROW = '| `pnpm db:setup` | Creates a development database in Docker, writes its URL into `.env`, and migrates |\n';
|
|
20
|
-
export const WORKSPACE_SENTENCE = 'If the workspace holds `api` and its database is not set up yet,
|
|
20
|
+
export const WORKSPACE_SENTENCE = 'If the workspace holds `api` and its database is not set up yet, `pnpm dev` offers to create the database first.\n\n';
|
|
21
21
|
function textRule(id, file, description, from, to, marker) {
|
|
22
22
|
return {
|
|
23
23
|
id,
|
|
@@ -55,7 +55,7 @@ function rulesFor(root, api, templates) {
|
|
|
55
55
|
textRule('api-readme-row', at('README.md'), 'Remove `db:setup` from the README', README_ROW, '', '`pnpm db:setup`'),
|
|
56
56
|
];
|
|
57
57
|
if (api !== root) {
|
|
58
|
-
rules.push(devRule('root-dev-check', 'package.json', ROOT_DEV_PREFIX), textRule('root-readme', 'README.md', 'Remove the database offer from the README', WORKSPACE_SENTENCE, '', 'offers to create
|
|
58
|
+
rules.push(devRule('root-dev-check', 'package.json', ROOT_DEV_PREFIX), textRule('root-readme', 'README.md', 'Remove the database offer from the README', WORKSPACE_SENTENCE, '', 'offers to create the database'));
|
|
59
59
|
}
|
|
60
60
|
return { rules, script: join(templates, 'api', 'scripts', 'database.mjs') };
|
|
61
61
|
}
|
package/dist/compose.js
CHANGED
|
@@ -204,12 +204,12 @@ export async function composeWorkspace({ templates, target, types, mobileScheme
|
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
206
|
// One command for everything and one per app. Each app answers to `dev`, so the root never needs to know how.
|
|
207
|
-
// The
|
|
208
|
-
// run would bury, so the root asks
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
207
|
+
// The database and the ports are the exception: both may ask a question, which the interleaved output of a
|
|
208
|
+
// parallel run would bury, so the root asks first and each app's own check then finds nothing to do.
|
|
209
|
+
// `pnpm -r --parallel` starts each app in its own process group, so Ctrl+C reaches pnpm and leaves the apps
|
|
210
|
+
// running. The ports script wraps the run, remembers what it started, and stops all of it on the way out.
|
|
211
|
+
const database = types.includes('api') ? 'node apps/api/scripts/database.mjs --check && ' : '';
|
|
212
|
+
const dev = `${database}node scripts/ports.mjs --all -- pnpm -r --parallel dev`;
|
|
213
213
|
await rewriteText(join(target, 'package.json'), (text) => types.reduce((pkg, type) => setJsonc(pkg, ['scripts', type], `pnpm --filter ${type} dev`), setJsonc(text, ['scripts', 'dev'], dev)));
|
|
214
214
|
const manifests = await Promise.all(apps.map(async (app) => readJsonc(await readFile(join(app, 'package.json'), 'utf8'))));
|
|
215
215
|
const catalogs = collectCatalog(manifests);
|
package/package.json
CHANGED
package/templates/api/README.md
CHANGED
|
@@ -19,7 +19,16 @@ starts Postgres from `docker-compose.yml` on port 5432 or the next free one (kep
|
|
|
19
19
|
into `.env`, and migrates. `pnpm db:setup` does the same at any time. Without a terminal every answer comes from a
|
|
20
20
|
flag: `--name`, `--port`, `--skip-migrate`, and `--json` for one JSON result on stdout.
|
|
21
21
|
|
|
22
|
-
The API listens on `http://localhost:3000
|
|
22
|
+
The API listens on the port in `PORT`, `http://localhost:3000` by default. Outside production, its documentation is
|
|
23
|
+
at `/api/docs`.
|
|
24
|
+
|
|
25
|
+
Before it starts, `pnpm dev` checks that the port is free. When something else holds it, it says what does, by
|
|
26
|
+
name and pid, and asks whether to stop that process or to move this app to the next free port. Moving it writes the
|
|
27
|
+
new port to `.env`, along with every URL that pointed at the old one. Without a terminal, pass `--kill` or
|
|
28
|
+
`--change` to `scripts/ports.mjs`, or the command stops rather than choosing for you.
|
|
29
|
+
|
|
30
|
+
Ctrl+C stops everything `pnpm dev` started, not only what the terminal signals: the script remembers the processes
|
|
31
|
+
it spawned, stops them, and frees the port. Anything it did not start is named and left alone.
|
|
23
32
|
|
|
24
33
|
`pnpm db:migrate` builds first, then runs Better Auth's migrations and then the application's. Before
|
|
25
34
|
migrating, Better Auth logs `Database schema mismatch` because its tables do not exist yet; the migration
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"build": "nest build",
|
|
7
7
|
"start": "nest start",
|
|
8
8
|
"start:dev": "nest start --watch",
|
|
9
|
-
"dev": "node scripts/database.mjs --check && pnpm start:dev",
|
|
9
|
+
"dev": "node scripts/database.mjs --check && node scripts/ports.mjs -- pnpm start:dev",
|
|
10
10
|
"start:prod": "node dist/main",
|
|
11
11
|
"lint": "biome check",
|
|
12
12
|
"typecheck": "tsc --noEmit",
|
|
@@ -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
|
+
}
|
|
@@ -15,6 +15,16 @@ pnpm dev
|
|
|
15
15
|
`.env` comes with the generated project, copied from `.env.example`; `EXPO_PUBLIC_API_URL` points at the API. It is
|
|
16
16
|
not committed, so on a fresh clone run `cp .env.example .env`.
|
|
17
17
|
|
|
18
|
+
Metro serves on the port in `METRO_PORT`, 8081 by default.
|
|
19
|
+
|
|
20
|
+
Before it starts, `pnpm dev` checks that the port is free. When something else holds it, it says what does, by
|
|
21
|
+
name and pid, and asks whether to stop that process or to move this app to the next free port. Moving it writes the
|
|
22
|
+
new port to `.env`, along with every URL that pointed at the old one. Without a terminal, pass `--kill` or
|
|
23
|
+
`--change` to `scripts/ports.mjs`, or the command stops rather than choosing for you.
|
|
24
|
+
|
|
25
|
+
Ctrl+C stops everything `pnpm dev` started, not only what the terminal signals: the script remembers the processes
|
|
26
|
+
it spawned, stops them, and frees the port. Anything it did not start is named and left alone.
|
|
27
|
+
|
|
18
28
|
On an Android emulator `localhost` is the emulator itself; point `EXPO_PUBLIC_API_URL` at your machine's address.
|
|
19
29
|
|
|
20
30
|
## Everyday commands
|
|
@@ -12,6 +12,10 @@ if (!nodeModules.includes('(?!(') || babel === undefined) {
|
|
|
12
12
|
module.exports = {
|
|
13
13
|
preset: 'jest-expo',
|
|
14
14
|
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
|
|
15
|
+
// A first render brings React Native and Babel's transform with it, which costs seconds on a cold cache: inside a
|
|
16
|
+
// workspace the contract is a package to transform too, and Jest's 5s default was crossed in CI while the same
|
|
17
|
+
// suite passed alone. The budget is the machine's slowness, not the test's.
|
|
18
|
+
testTimeout: 30_000,
|
|
15
19
|
// Better Auth ships ESM only, partly as .mjs, so it joins the packages jest-expo transforms, with the same Babel.
|
|
16
20
|
transform: { '\\.mjs$': babel },
|
|
17
21
|
transformIgnorePatterns: [
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"main": "expo-router/entry",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"start": "expo start",
|
|
8
|
-
"dev": "
|
|
8
|
+
"dev": "node scripts/ports.mjs -- expo start",
|
|
9
9
|
"ios": "expo start --ios",
|
|
10
10
|
"android": "expo start --android",
|
|
11
11
|
"bundle": "expo export --platform ios --platform android",
|