@stjoseph/prumo 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.prumo-templates/core/testing.md +14 -4
  2. package/README.md +23 -3
  3. package/dist/command.js +17 -0
  4. package/dist/compose.js +4 -1
  5. package/dist/doctor.js +18 -11
  6. package/dist/generate.js +2 -4
  7. package/dist/names.js +13 -1
  8. package/dist/pnpm-extended.js +159 -0
  9. package/package.json +5 -2
  10. package/templates/api/.gitattributes +2 -0
  11. package/templates/api/README.md +2 -0
  12. package/templates/api/scripts/database.mjs +2 -1
  13. package/templates/api/scripts/ports.mjs +249 -36
  14. package/templates/mobile/.gitattributes +2 -0
  15. package/templates/mobile/scripts/ports.mjs +249 -36
  16. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +3 -2
  17. package/templates/mobile/test/features/auth/email-flows.spec.tsx +3 -2
  18. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +3 -2
  19. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +3 -2
  20. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +3 -2
  21. package/templates/mobile/test/features/profile/profile-form.spec.tsx +3 -4
  22. package/templates/mobile/test/support/query-client.ts +14 -0
  23. package/templates/site/.gitattributes +2 -0
  24. package/templates/site/scripts/ports.mjs +249 -36
  25. package/templates/web/.gitattributes +2 -0
  26. package/templates/web/scripts/ports.mjs +249 -36
  27. package/templates/web/test/features/oauth/consent-form.spec.tsx +3 -2
  28. package/templates/web/test/features/profile/profile-form.spec.tsx +3 -2
  29. package/templates/web/test/support/query-client.ts +14 -0
  30. package/templates/web/test/support/render-app.tsx +2 -2
  31. package/templates/web/vite.config.ts +10 -3
  32. package/templates/workspace/.gitattributes +2 -0
  33. package/templates/workspace/README.md +2 -0
  34. package/templates/workspace/scripts/ports.mjs +249 -36
@@ -171,13 +171,23 @@ async function taken(port) {
171
171
  function holders(port) {
172
172
  if (windows) {
173
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
- )
174
+ const pids = new Set()
175
+
176
+ // Windows translates the state column, so a listener is told by its remote address instead: only a socket that
177
+ // is listening has none, which netstat writes as port 0.
178
+ for (const line of lines.split('\n')) {
179
+ const [protocol, local, remote, ...rest] = line.trim().split(/\s+/)
180
+ const pid = rest.at(-1)
181
+
182
+ if (
183
+ protocol === 'TCP' &&
184
+ local?.endsWith(`:${port}`) &&
185
+ remote?.endsWith(':0') &&
186
+ pid !== '0'
187
+ ) {
188
+ pids.add(pid)
189
+ }
190
+ }
181
191
 
182
192
  return [...pids].map((pid) => ({ pid: Number(pid), command: commandOf(pid) }))
183
193
  }
@@ -194,9 +204,8 @@ function commandOf(pid) {
194
204
  : spawnSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8' })
195
205
  const output = (result.stdout ?? '').trim()
196
206
 
197
- return windows
198
- ? (output.split(',')[0]?.replaceAll('"', '') ?? 'unknown')
199
- : basename(output || 'unknown')
207
+ // tasklist answers a pid it does not know with a translated sentence, never with a quoted CSV row.
208
+ return windows ? (/^"([^"]*)"/.exec(output)?.[1] ?? 'unknown') : basename(output || 'unknown')
200
209
  }
201
210
 
202
211
  async function freed(port) {
@@ -213,7 +222,7 @@ async function freed(port) {
213
222
  async function stopHolders(port, found) {
214
223
  for (const { pid } of found) {
215
224
  if (windows) {
216
- spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
225
+ terminate(processTree([pid]))
217
226
  } else {
218
227
  // The whole group, so that a shell's children go with it; a lone process when it leads no group.
219
228
  try {
@@ -319,41 +328,197 @@ async function settle(type) {
319
328
  throw new Failure('declined', 'Nothing was started.')
320
329
  }
321
330
 
322
- // Every process below one, as the system sees them right now.
323
- function processTree(pid) {
331
+ // Windows has a precise answer to "what did this command start": a job object. A process created by a member of a job
332
+ // is a member too, whatever became of its parent, so the job's member list holds everything the command started,
333
+ // with no reading of the process tree to come too late. A PowerShell helper creates the job, puts this process in it
334
+ // before the command starts, and lists the members when asked.
335
+ const JOB_HELPER = `
336
+ $ErrorActionPreference = 'Stop'
337
+ Add-Type -TypeDefinition @'
338
+ using System;
339
+ using System.Runtime.InteropServices;
340
+ public static class PrumoJob {
341
+ [DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr CreateJobObject(IntPtr attributes, string name);
342
+ [DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr OpenProcess(uint access, bool inherit, uint id);
343
+ [DllImport("kernel32.dll", SetLastError = true)] public static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
344
+ [DllImport("kernel32.dll", SetLastError = true)] public static extern bool SetConsoleCtrlHandler(IntPtr handler, bool ignore);
345
+ [DllImport("kernel32.dll", SetLastError = true)] static extern bool QueryInformationJobObject(IntPtr job, int kind, IntPtr information, int length, IntPtr returned);
346
+ public static string Members(IntPtr job) {
347
+ int length = 8 + IntPtr.Size * 16384;
348
+ IntPtr list = Marshal.AllocHGlobal(length);
349
+ try {
350
+ if (!QueryInformationJobObject(job, 3, list, length, IntPtr.Zero)) { return ""; }
351
+ int count = Marshal.ReadInt32(list, 4);
352
+ string[] ids = new string[count];
353
+ for (int i = 0; i < count; i++) { ids[i] = Marshal.ReadIntPtr(list, 8 + i * IntPtr.Size).ToInt64().ToString(); }
354
+ return String.Join(" ", ids);
355
+ } finally {
356
+ Marshal.FreeHGlobal(list);
357
+ }
358
+ }
359
+ }
360
+ '@
361
+ # Ctrl+C is for the command; the helper has to outlive it to list what is left.
362
+ [void][PrumoJob]::SetConsoleCtrlHandler([IntPtr]::Zero, $true)
363
+ $job = [PrumoJob]::CreateJobObject([IntPtr]::Zero, $null)
364
+ $member = [PrumoJob]::OpenProcess(0x0101, $false, [uint32]$env:PRUMO_JOB_MEMBER)
365
+ if ($job -eq [IntPtr]::Zero -or $member -eq [IntPtr]::Zero -or -not [PrumoJob]::AssignProcessToJobObject($job, $member)) { exit 1 }
366
+ [Console]::Out.WriteLine('ready')
367
+ while ($null -ne [Console]::In.ReadLine()) { [Console]::Out.WriteLine([PrumoJob]::Members($job)) }
368
+ `
369
+
370
+ // The job, or undefined where there is none: elsewhere than Windows, or when the helper could not make one, and then
371
+ // the process tree read on a schedule is what is left.
372
+ async function startJob() {
373
+ if (!windows) {
374
+ return undefined
375
+ }
376
+
377
+ const helper = spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', JOB_HELPER], {
378
+ stdio: ['pipe', 'pipe', 'ignore'],
379
+ windowsHide: true,
380
+ env: { ...process.env, PRUMO_JOB_MEMBER: String(process.pid) },
381
+ })
382
+ const lines = []
383
+ const waiting = []
384
+ let pending = ''
385
+
386
+ helper.on('error', () => {})
387
+ helper.stdin.on('error', () => {})
388
+ helper.stdout.setEncoding('utf8')
389
+ helper.stdout.on('data', (chunk) => {
390
+ const parts = (pending + chunk).split('\n')
391
+
392
+ pending = parts.pop() ?? ''
393
+ for (const line of parts) {
394
+ const resolve = waiting.shift()
395
+
396
+ if (resolve === undefined) {
397
+ lines.push(line.trim())
398
+ } else {
399
+ resolve(line.trim())
400
+ }
401
+ }
402
+ })
403
+
404
+ // A timer keeps this process alive while it waits, since the helper itself does not.
405
+ const next = (ms) =>
406
+ new Promise((resolve) => {
407
+ const line = lines.shift()
408
+
409
+ if (line !== undefined) {
410
+ resolve(line)
411
+ return
412
+ }
413
+
414
+ const timer = setTimeout(() => resolve(undefined), ms)
415
+
416
+ waiting.push((value) => {
417
+ clearTimeout(timer)
418
+ resolve(value)
419
+ })
420
+ })
421
+
422
+ if ((await next(60_000)) !== 'ready') {
423
+ helper.kill()
424
+ return undefined
425
+ }
426
+
427
+ helper.unref()
428
+ helper.stdin.unref?.()
429
+ helper.stdout.unref?.()
430
+
431
+ return {
432
+ members: async () => {
433
+ helper.stdin.write('list\n')
434
+
435
+ return ((await next(10_000)) ?? '')
436
+ .split(' ')
437
+ .map(Number)
438
+ .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid)
439
+ },
440
+ close: () => helper.kill(),
441
+ }
442
+ }
443
+
444
+ // Ends exactly these processes on Windows, and nothing else. `taskkill /T` is never used: it follows the same parent
445
+ // ids, which Windows does not keep honest, as processTree below explains.
446
+ function terminate(pids) {
447
+ if (pids.length > 0) {
448
+ spawnSync('taskkill', ['/F', ...pids.flatMap((pid) => ['/PID', String(pid)])], {
449
+ stdio: 'ignore',
450
+ timeout: 60_000,
451
+ })
452
+ }
453
+ }
454
+
455
+ // Every process below the ones given, as the system sees them right now.
456
+ //
457
+ // On Windows a process keeps its parent's id after the parent exits, and the id is then given to the next process
458
+ // that starts. A process of the run can so inherit the number that winlogon, dwm or csrss name as their parent, and a
459
+ // tree followed by ids alone adopts them: on the CI runner that once stopped the session itself. A child is therefore
460
+ // only taken as one when it started after its parent; a process that started before the one holding its parent's
461
+ // number cannot be that process's child.
462
+ function processTree(roots) {
324
463
  const listed = windows
325
464
  ? spawnSync(
326
465
  'powershell',
327
466
  [
328
467
  '-NoProfile',
468
+ '-NonInteractive',
329
469
  '-Command',
330
- 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }',
470
+ 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $(if ($_.CreationDate) { $_.CreationDate.ToFileTimeUtc() } else { 0 })" }',
331
471
  ],
332
- { encoding: 'utf8' },
472
+ { encoding: 'utf8', timeout: 60_000 },
333
473
  )
334
- : spawnSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8' })
335
- const children = new Map()
474
+ : spawnSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8', timeout: 60_000 })
475
+ const started = new Map()
476
+ const links = []
336
477
 
337
478
  for (const line of (listed.stdout ?? '').split('\n')) {
338
- const [child, parent] = line.trim().split(/\s+/).map(Number)
479
+ const [child, parent, created] = line.trim().split(/\s+/).map(Number)
339
480
 
340
481
  if (Number.isInteger(child) && Number.isInteger(parent)) {
482
+ links.push([child, parent])
483
+ started.set(child, created ?? 0)
484
+ }
485
+ }
486
+
487
+ const children = new Map()
488
+
489
+ for (const [child, parent] of links) {
490
+ const parentStarted = started.get(parent)
491
+ const honest = !windows || parentStarted === undefined || parentStarted <= started.get(child)
492
+
493
+ if (honest && child !== parent) {
341
494
  children.set(parent, [...(children.get(parent) ?? []), child])
342
495
  }
343
496
  }
344
497
 
345
498
  const tree = []
346
- const pending = [pid]
499
+ const pending = [...roots]
347
500
 
348
501
  while (pending.length > 0) {
349
502
  const current = pending.shift()
350
503
 
351
- tree.push(current)
352
- pending.push(...(children.get(current) ?? []))
504
+ if (!tree.includes(current)) {
505
+ tree.push(current)
506
+ pending.push(...(children.get(current) ?? []))
507
+ }
353
508
  }
354
509
 
355
- // Deepest first, so a parent does not restart what was just stopped.
356
- return tree.reverse()
510
+ // Deepest first, so a parent does not restart what was just stopped. On Windows only what is running now: the number
511
+ // of a process that exited may already name another one.
512
+ return (windows ? tree.filter((pid) => started.has(pid)) : tree).reverse()
513
+ }
514
+
515
+ function alive(pid) {
516
+ try {
517
+ process.kill(pid, 0)
518
+ return true
519
+ } catch (error) {
520
+ return error.code === 'EPERM'
521
+ }
357
522
  }
358
523
 
359
524
  function signal(pid, sign) {
@@ -365,12 +530,18 @@ function signal(pid, sign) {
365
530
  // `pnpm -r --parallel` starts each app in a process group of its own, so the terminal's Ctrl+C reaches pnpm and
366
531
  // nothing below it. Whatever was started here is therefore stopped from here, and never anything else: the tree is
367
532
  // 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()
533
+ async function shutdown(child, ports, known, job) {
534
+ // With a job its members are exactly what the command started. Without one, on Windows, the tree is also read from
535
+ // every process seen while the command ran, since a process keeps its parent's id after the parent exits; elsewhere
536
+ // an orphan moves to init, and below a dead pid there is only whatever reused the number.
537
+ const members = (await job?.members()) ?? []
538
+ const roots = windows && job === undefined ? [child.pid, ...known] : [child.pid]
539
+ const below = processTree(roots)
540
+ const tree = [...new Set([...below, ...known, ...members])].reverse()
370
541
  const stopped = []
371
542
 
372
543
  if (windows) {
373
- spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
544
+ terminate([...new Set([...below, ...members])])
374
545
  } else {
375
546
  for (const pid of tree) {
376
547
  signal(pid, 'SIGTERM')
@@ -387,6 +558,11 @@ async function shutdown(child, ports, known) {
387
558
  stopped.push(`${command} (pid ${pid}) on port ${port}`)
388
559
  }
389
560
 
561
+ // The port is released once the system has reaped what was killed, which is not at once.
562
+ if (ours.length > 0) {
563
+ await freed(port)
564
+ }
565
+
390
566
  if (theirs.length > 0) {
391
567
  say(`Port ${port} is still held by ${describe(theirs)}, which this project did not start.`)
392
568
  }
@@ -400,13 +576,18 @@ async function shutdown(child, ports, known) {
400
576
  return stopped
401
577
  }
402
578
 
403
- async function supervise(child, ports) {
579
+ async function supervise(child, ports, job) {
404
580
  // Ctrl+C reaches the child through the terminal; this process only waits, and then makes sure nothing survived.
405
581
  // Once the child is gone, its own children answer to the system and no longer to it, so who they were is
406
582
  // remembered while they are still running: on a schedule, and again the moment the signal arrives.
407
583
  const known = new Set([child.pid])
584
+ // A job lists its members at the end, so with one there is nothing to remember on the way.
408
585
  const remember = () => {
409
- for (const pid of processTree(child.pid)) {
586
+ if (job !== undefined) {
587
+ return
588
+ }
589
+
590
+ for (const pid of processTree([child.pid])) {
410
591
  known.add(pid)
411
592
  }
412
593
  }
@@ -432,6 +613,16 @@ async function supervise(child, ports) {
432
613
  remember()
433
614
  const watch = setInterval(remember, 2000)
434
615
 
616
+ // `pnpm -r --parallel` starts each app in a process group of its own, which the terminal's Ctrl+C never reaches, and
617
+ // a Ctrl+C right after the start comes before the workspace's ports.mjs has seen the apps. So an app's ports.mjs
618
+ // does not wait to be told: once whatever started it is gone, nobody is left to stop it, and it stops its own tree.
619
+ const parent = process.ppid
620
+ const orphaned = setInterval(() => {
621
+ if (!interrupted && !alive(parent)) {
622
+ interrupt('SIGTERM')
623
+ }
624
+ }, 250)
625
+
435
626
  let patience
436
627
  const code = await new Promise((done) => {
437
628
  child.once('error', () => done(1))
@@ -449,8 +640,11 @@ async function supervise(child, ports) {
449
640
  clearInterval(patience)
450
641
  clearTimeout(patience)
451
642
  clearInterval(watch)
643
+ clearInterval(orphaned)
644
+
645
+ const stopped = await shutdown(child, ports, known, job)
452
646
 
453
- const stopped = await shutdown(child, ports, known)
647
+ job?.close()
454
648
 
455
649
  process.off('SIGINT', onInterrupt)
456
650
  process.off('SIGTERM', onTerminate)
@@ -459,17 +653,32 @@ async function supervise(child, ports) {
459
653
  return stopped
460
654
  }
461
655
 
656
+ // On Windows the command is most often a .cmd shim, such as pnpm or vite, which Node starts only through a shell, and
657
+ // a shell given an argument list is deprecated (DEP0190). So it gets one line, each part quoted when it needs it.
658
+ function commandLine(parts) {
659
+ return parts
660
+ .map((part) => (/^[\w@+=:,./\\-]+$/.test(part) ? part : `"${part.replaceAll('"', '""')}"`))
661
+ .join(' ')
662
+ }
663
+
462
664
  async function main() {
463
665
  const ports = {}
666
+ const [command, ...args] = positionals
464
667
 
465
- for (const type of typesToCheck()) {
466
- if (type !== undefined) {
467
- ports[type] = await settle(type)
668
+ // PowerShell takes a few seconds to make the job, so it does while the ports are settled.
669
+ const starting = command === undefined ? undefined : startJob()
670
+
671
+ try {
672
+ for (const type of typesToCheck()) {
673
+ if (type !== undefined) {
674
+ ports[type] = await settle(type)
675
+ }
468
676
  }
677
+ } catch (error) {
678
+ ;(await starting)?.close()
679
+ throw error
469
680
  }
470
681
 
471
- const [command, ...args] = positionals
472
-
473
682
  if (command === undefined) {
474
683
  return { ports }
475
684
  }
@@ -481,8 +690,12 @@ async function main() {
481
690
  const withPort = flag.flatMap((entry) =>
482
691
  entry === '--port' ? ['--port', String(ports[type])] : [entry],
483
692
  )
484
- const child = spawn(command, [...args, ...withPort], { stdio: 'inherit', shell: windows })
485
- const stopped = await supervise(child, Object.values(ports))
693
+ // Before the command starts, so that it and everything it starts are members from their first instant.
694
+ const job = await starting
695
+ const child = windows
696
+ ? spawn(commandLine([command, ...args, ...withPort]), { stdio: 'inherit', shell: true })
697
+ : spawn(command, [...args, ...withPort], { stdio: 'inherit' })
698
+ const stopped = await supervise(child, Object.values(ports), job)
486
699
 
487
700
  return { ports, ran: [command, ...args, ...withPort].join(' '), stopped }
488
701
  }