@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 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, it offers to create one first. ';
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 one'));
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 API's database check is the exception: it may ask a question, which the interleaved output of a parallel
208
- // run would bury, so the root asks it first and the API's own check then finds nothing to do.
209
- const parallelDev = 'pnpm -r --parallel dev';
210
- const dev = types.includes('api')
211
- ? `node apps/api/scripts/database.mjs --check && ${parallelDev}`
212
- : parallelDev;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stjoseph/prumo",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Scaffolds a TypeScript project and ships it the conventions it follows, in a .prumo/ folder any AI assistant reads.",
5
5
  "keywords": [
6
6
  "scaffolding",
@@ -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`. Outside production, its documentation is at `/api/docs`.
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
+ }
@@ -1 +1,4 @@
1
+ # The port Metro serves on; `pnpm dev` moves it when it is taken.
2
+ METRO_PORT=8081
3
+
1
4
  EXPO_PUBLIC_API_URL=http://localhost:3000
@@ -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": "pnpm start",
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",