@stjoseph/prumo 0.4.2 → 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 +15 -5
- package/dist/command.js +17 -0
- package/dist/commands.js +4 -1
- package/dist/compose.js +4 -1
- package/dist/doctor.js +18 -11
- package/dist/generate.js +2 -4
- package/dist/questions.js +7 -4
- package/package.json +2 -2
- package/templates/api/.gitattributes +2 -0
- package/templates/api/README.md +2 -0
- package/templates/api/scripts/database.mjs +2 -1
- package/templates/api/scripts/ports.mjs +249 -36
- package/templates/mobile/.gitattributes +2 -0
- package/templates/mobile/scripts/ports.mjs +249 -36
- package/templates/site/.gitattributes +2 -0
- package/templates/site/scripts/ports.mjs +249 -36
- package/templates/web/.gitattributes +2 -0
- package/templates/web/scripts/ports.mjs +249 -36
- package/templates/workspace/.gitattributes +2 -0
- package/templates/workspace/README.md +2 -0
- 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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
323
|
-
|
|
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
|
|
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 = [
|
|
499
|
+
const pending = [...roots]
|
|
347
500
|
|
|
348
501
|
while (pending.length > 0) {
|
|
349
502
|
const current = pending.shift()
|
|
350
503
|
|
|
351
|
-
tree.
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
485
|
-
const
|
|
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
|
}
|