@stjoseph/prumo 0.4.3 → 0.5.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/README.md CHANGED
@@ -33,7 +33,16 @@ workspace holding four applications, without either one carrying rules it has no
33
33
  ## Requirements
34
34
 
35
35
  Node 22.18 or later, pnpm 10.26 or later, and Docker for anything with an API. `prumo doctor` checks
36
- all of it. Prumo runs on macOS and Linux; Windows is not supported yet.
36
+ all of it. Prumo runs on macOS, Linux and Windows.
37
+
38
+ On Windows it runs natively: the project lives in a Windows folder, and the Windows Node and pnpm run it.
39
+ Docker Desktop, with its WSL2 backend and set to Linux containers, is only where the database and the API's
40
+ tests run.
41
+
42
+ Inside a WSL distribution, with the distribution's own terminal, Node, pnpm and Docker, Prumo is on Linux, which is
43
+ supported, though not tested on WSL specifically. Mixing the two is not supported: Windows tools, Prumo Desktop
44
+ included, working on a project inside the WSL filesystem (`\\wsl$` or `\\wsl.localhost`).
45
+
37
46
 
38
47
  ## Running it
39
48
 
@@ -79,7 +88,7 @@ npx @stjoseph/prumo new my-api --types api --alone --single-tenant --email
79
88
  | `prumo new [name \| .]` | Generates a project |
80
89
  | `prumo db` | Inside a project with an API: creates the development database in Docker and writes its URL into `.env` |
81
90
  | `prumo clean` | Removes what the project needed only once, today the database setup, once the database exists. Only the Prumo version that generated the project cleans it |
82
- | `prumo doctor` | Checks Node, pnpm, git and Docker on this machine |
91
+ | `prumo doctor` | Checks Node, pnpm, git and Docker on this machine, and that Docker runs Linux containers |
83
92
  | `prumo version` · `--version` · `-v` | Prints the CLI version |
84
93
  | `prumo help [command]` · `--help` | Lists the commands, or describes one |
85
94
 
@@ -0,0 +1,17 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ const windows = process.platform === 'win32';
3
+ // Only what the CLI itself writes: no space, quote or shell operator, so one line means the same in any shell.
4
+ const PLAIN = /^[\w@.:/={}-]+$/;
5
+ // Runs a tool the CLI names, such as pnpm, git or docker. On Windows pnpm is a .cmd shim, which Node starts only
6
+ // through a shell, and a shell given an argument list is deprecated (DEP0190), so there the command goes as one line.
7
+ export function runCommand(command, args, options = {}) {
8
+ if (!windows) {
9
+ return spawnSync(command, args, options);
10
+ }
11
+ for (const part of [command, ...args]) {
12
+ if (!PLAIN.test(part)) {
13
+ throw new Error(`${part} cannot be passed through a shell unquoted`);
14
+ }
15
+ }
16
+ return spawnSync([command, ...args].join(' '), { ...options, shell: true });
17
+ }
package/dist/compose.js CHANGED
@@ -49,7 +49,10 @@ export async function copyTemplate(source, destination) {
49
49
  async function copyApp(templates, target, type) {
50
50
  const destination = join(target, 'apps', type);
51
51
  await copyTemplate(join(templates, type), destination);
52
- await rm(join(destination, '.githooks'), { recursive: true, force: true });
52
+ // The workspace root carries both, for every app below it.
53
+ for (const rootOnly of ['.githooks', '.gitattributes']) {
54
+ await rm(join(destination, rootOnly), { recursive: true, force: true });
55
+ }
53
56
  return destination;
54
57
  }
55
58
  async function contractFiles(directory) {
package/dist/doctor.js CHANGED
@@ -1,14 +1,15 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { runCommand } from "./command.js";
2
2
  const NODE_FLOOR = [22, 18, 0];
3
3
  // Every generated workspace approves dependency builds with `allowBuilds`, which pnpm added in 10.26.0.
4
4
  const PNPM_FLOOR = [10, 26, 0];
5
- // On Windows pnpm is a .cmd shim, and Node starts one only through a shell.
6
- export const SHELL = process.platform === 'win32';
7
5
  export const systemProbe = {
8
6
  nodeVersion: process.versions.node,
9
7
  run: (command, args) => {
10
- const result = spawnSync(command, args, { encoding: 'utf8', timeout: 10_000, shell: SHELL });
11
- return { ok: result.error === undefined && result.status === 0, stdout: result.stdout ?? '' };
8
+ const result = runCommand(command, args, { encoding: 'utf8', timeout: 10_000 });
9
+ return {
10
+ ok: result.error === undefined && result.status === 0,
11
+ stdout: String(result.stdout ?? ''),
12
+ };
12
13
  },
13
14
  };
14
15
  function atLeast(version, floor) {
@@ -59,19 +60,25 @@ export async function doctor(probe = systemProbe) {
59
60
  });
60
61
  checks.push(...toolChecks(probe, ['pnpm', 'git']));
61
62
  const docker = probe.run('docker', ['--version']);
62
- const dockerRunning = docker.ok && probe.run('docker', ['info']).ok;
63
+ const engine = docker.ok ? probe.run('docker', ['info', '--format', '{{.OSType}}']) : undefined;
64
+ const os = firstLine(engine?.stdout ?? '');
63
65
  checks.push({
64
66
  id: 'docker',
65
67
  label: 'Docker',
66
68
  required: false,
67
- ...(dockerRunning
68
- ? { status: 'ok', detail: firstLine(docker.stdout) }
69
- : {
69
+ ...(engine?.ok !== true
70
+ ? {
70
71
  status: 'warn',
71
72
  detail: docker.ok
72
73
  ? 'installed but not running; open Docker Desktop. The API runs its database and tests in it'
73
- : 'not found; install Docker Desktop. The API runs its database and tests in it',
74
- }),
74
+ : 'not found; install Docker Desktop, on Windows with the WSL2 backend. The API runs its database and tests in it',
75
+ }
76
+ : os !== 'linux'
77
+ ? {
78
+ status: 'warn',
79
+ detail: `running ${os || 'unknown'} containers; switch Docker Desktop to Linux containers. The API's Postgres image is a Linux one`,
80
+ }
81
+ : { status: 'ok', detail: firstLine(docker.stdout) }),
75
82
  });
76
83
  return { ready: checks.every((check) => !check.required || check.status === 'ok'), checks };
77
84
  }
package/dist/generate.js CHANGED
@@ -1,11 +1,10 @@
1
- import { spawnSync } from 'node:child_process';
2
1
  import { randomBytes } from 'node:crypto';
3
2
  import { existsSync } from 'node:fs';
4
3
  import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
5
4
  import { join } from 'node:path';
5
+ import { runCommand } from "./command.js";
6
6
  import { composeWorkspace, copyTemplate } from "./compose.js";
7
7
  import { writeContext } from "./context.js";
8
- import { SHELL } from "./doctor.js";
9
8
  import { applyFeatures, enabledFor } from "./features.js";
10
9
  import { setJsonc } from "./jsonc.js";
11
10
  import { schemeFor, targetProblem } from "./names.js";
@@ -78,10 +77,9 @@ function stopIfInterrupted() {
78
77
  function run(command, args, cwd, output) {
79
78
  stopIfInterrupted();
80
79
  // Under --json stdout belongs to the result document, so a child's output goes to stderr instead.
81
- const result = spawnSync(command, args, {
80
+ const result = runCommand(command, args, {
82
81
  cwd,
83
82
  stdio: output === 'inherit' ? 'inherit' : ['ignore', 2, 2],
84
- shell: SHELL,
85
83
  });
86
84
  // spawnSync holds the event loop, so the listener has not run yet; the child says it instead, either by dying of
87
85
  // the signal or, as pnpm does, by catching it and exiting with 130.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stjoseph/prumo",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
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",
@@ -36,7 +36,7 @@
36
36
  "scripts": {
37
37
  "lint": "biome check",
38
38
  "typecheck": "tsc --noEmit",
39
- "test": "vitest run --dir test",
39
+ "test": "vitest run --dir test --test-timeout 30000",
40
40
  "prumo": "node src/cli.ts",
41
41
  "build": "tsc -p tsconfig.build.json && node scripts/copy-assets.mjs",
42
42
  "prepack": "pnpm build"
@@ -0,0 +1,2 @@
1
+ # LF in every checkout, Windows included: Biome formats to LF and would otherwise flag every file.
2
+ * text=auto eol=lf
@@ -3,6 +3,8 @@
3
3
  ## Requirements
4
4
 
5
5
  Node 22.18 or later, pnpm 10.26 or later, and Docker Desktop running: the database and the tests both run in it.
6
+ On Windows, Docker Desktop needs its WSL2 backend and Linux containers. Keep the project in a Windows folder, or
7
+ work entirely inside a WSL distribution; Windows tools on a project inside the WSL filesystem are not supported.
6
8
 
7
9
  ## Running it
8
10
 
@@ -267,7 +267,8 @@ async function main() {
267
267
 
268
268
  if (!values['skip-migrate']) {
269
269
  say('Migrating…')
270
- if (spawnSync('pnpm', ['db:migrate'], { cwd: api, stdio: childStdio }).status !== 0) {
270
+ // One line through the shell: on Windows pnpm is a .cmd shim, which Node starts only that way.
271
+ if (spawnSync('pnpm db:migrate', { cwd: api, stdio: childStdio, shell: true }).status !== 0) {
271
272
  throw new Failure(
272
273
  'migrate_failed',
273
274
  '`pnpm db:migrate` failed. The database exists and .env points at it.',
@@ -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
  }
@@ -0,0 +1,2 @@
1
+ # LF in every checkout, Windows included: Biome formats to LF and would otherwise flag every file.
2
+ * text=auto eol=lf