@o/run 0.16.19

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.
@@ -0,0 +1,455 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import { join, relative, resolve } from 'node:path'
5
+
6
+ import pty from '@lydell/node-pty'
7
+
8
+ const colors = [
9
+ '\x1b[38;5;81m',
10
+ '\x1b[38;5;209m',
11
+ '\x1b[38;5;156m',
12
+ '\x1b[38;5;183m',
13
+ '\x1b[38;5;222m',
14
+ '\x1b[38;5;117m',
15
+ ]
16
+ const reset = '\x1b[0m'
17
+ const dim = '\x1b[2m'
18
+
19
+ const args = process.argv.slice(2)
20
+ const ownFlags = ['--no-root', '--bun', '--watch', '--flags=last']
21
+ const runCommands = []
22
+ const forwardArgs = []
23
+
24
+ for (let i = 0; i < args.length; i++) {
25
+ const arg = args[i]
26
+ if (arg.startsWith('--')) {
27
+ if (ownFlags.includes(arg)) continue
28
+ forwardArgs.push(arg)
29
+ const nextArg = args[i + 1]
30
+ if (nextArg && !nextArg.startsWith('--')) {
31
+ forwardArgs.push(nextArg)
32
+ i++
33
+ }
34
+ } else {
35
+ runCommands.push(arg)
36
+ }
37
+ }
38
+
39
+ const noRoot = args.includes('--no-root')
40
+ const runBun = args.includes('--bun')
41
+ const flagsLast = args.includes('--flags=last')
42
+ const watch = args.includes('--watch')
43
+ const MAX_RESTARTS = 3
44
+
45
+ const processes = []
46
+ let focusedIndex = -1 // -1 = interleaved/dashboard
47
+ let exiting = false
48
+
49
+ function getPrefix(index) {
50
+ const p = processes[index]
51
+ if (!p) return ''
52
+ const color = colors[index % colors.length]
53
+ return `${color}${p.shortcut}${reset}`
54
+ }
55
+
56
+ if (runCommands.length === 0) {
57
+ console.error('Usage: run-pty <script1> [script2] ...')
58
+ process.exit(1)
59
+ }
60
+
61
+ async function readPackageJson(dir) {
62
+ try {
63
+ return JSON.parse(await fs.promises.readFile(join(dir, 'package.json'), 'utf8'))
64
+ } catch {
65
+ return null
66
+ }
67
+ }
68
+
69
+ async function getWorkspacePatterns() {
70
+ const pkg = await readPackageJson('.')
71
+ if (!pkg?.workspaces) return []
72
+ return Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages || []
73
+ }
74
+
75
+ async function findPackageJsonDirs(base, depth = 3) {
76
+ if (depth <= 0) return []
77
+ const results = []
78
+ try {
79
+ if (
80
+ await fs.promises
81
+ .access(join(base, 'package.json'))
82
+ .then(() => true)
83
+ .catch(() => false)
84
+ ) {
85
+ results.push(base)
86
+ }
87
+ const entries = await fs.promises.readdir(base, { withFileTypes: true })
88
+ for (const e of entries) {
89
+ if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') {
90
+ results.push(...(await findPackageJsonDirs(join(base, e.name), depth - 1)))
91
+ }
92
+ }
93
+ } catch {}
94
+ return results
95
+ }
96
+
97
+ async function findWorkspaceScripts(scripts) {
98
+ const patterns = await getWorkspacePatterns()
99
+ if (!patterns.length) return new Map()
100
+ const dirs = await findPackageJsonDirs('.')
101
+ const result = new Map()
102
+ for (const dir of dirs) {
103
+ if (dir === '.') continue
104
+ const rel = relative('.', dir).replace(/\\/g, '/')
105
+ const matches = patterns.some((p) => {
106
+ const np = p.replace(/\\/g, '/').replace(/^\.\//, '')
107
+ return np.endsWith('/*')
108
+ ? rel.startsWith(np.slice(0, -1))
109
+ : rel === np || rel.startsWith(np + '/')
110
+ })
111
+ if (!matches) continue
112
+ const pkg = await readPackageJson(dir)
113
+ if (!pkg?.scripts) continue
114
+ const available = scripts.filter((s) => typeof pkg.scripts[s] === 'string')
115
+ if (available.length) result.set(dir, { scripts: available, name: pkg.name || dir })
116
+ }
117
+ return result
118
+ }
119
+
120
+ function spawnScript(name, cwd, label, extraArgs, index, restarts = 0) {
121
+ const runArgs = ['run', '--silent', runBun ? '--bun' : '', name, ...extraArgs].filter(
122
+ Boolean
123
+ )
124
+ const terminal = pty.spawn('bun', runArgs, {
125
+ cwd: resolve(cwd),
126
+ cols: process.stdout.columns || 80,
127
+ rows: process.stdout.rows || 24,
128
+ env: { ...process.env, FORCE_COLOR: '3' },
129
+ })
130
+
131
+ const idx = index ?? processes.length
132
+ const startedAt = Date.now()
133
+ const previous = index === undefined ? undefined : processes[index]
134
+ const managed = {
135
+ terminal,
136
+ name,
137
+ cwd,
138
+ label,
139
+ extraArgs,
140
+ index: idx,
141
+ // Keep the same keyboard shortcut when a process is restarted in-place.
142
+ shortcut: previous?.shortcut || '',
143
+ killed: false,
144
+ restartTimer: null,
145
+ }
146
+
147
+ if (index !== undefined) processes[index] = managed
148
+ else processes.push(managed)
149
+
150
+ terminal.onData((data) => {
151
+ if (focusedIndex === -1) {
152
+ // interleaved - prefix each line, skip bun's $ command echo
153
+ const lines = data.split(/\r?\n/)
154
+ for (const line of lines) {
155
+ if (!line) continue
156
+ // eslint-disable-next-line no-control-regex
157
+ const stripped = line.replace(/\x1b\[[0-9;]*m/g, '')
158
+ if (stripped.startsWith('$ ')) continue
159
+ process.stdout.write(`${getPrefix(idx)} ${line}\n`)
160
+ }
161
+ } else if (focusedIndex === idx) {
162
+ // focused - raw output
163
+ process.stdout.write(data)
164
+ }
165
+ })
166
+
167
+ terminal.onExit(({ exitCode, signal }) => {
168
+ if (managed.killed) return
169
+ if (focusedIndex === idx) {
170
+ focusedIndex = -1
171
+ showDashboard()
172
+ }
173
+ const terminatedBySignal = signal != null && signal !== 0
174
+ if (exitCode !== 0 || terminatedBySignal) {
175
+ const result = terminatedBySignal ? `signal ${signal}` : `code ${exitCode}`
176
+ console.error(`${getPrefix(idx)} exited with ${result}`)
177
+ if (watch && !exiting) {
178
+ const effectiveRestarts = Date.now() - startedAt >= 20_000 ? 0 : restarts
179
+ if (effectiveRestarts < MAX_RESTARTS) {
180
+ const nextRestart = effectiveRestarts + 1
181
+ const delayMs = Math.min(1_000 * 2 ** effectiveRestarts, 30_000)
182
+ managed.killed = true
183
+ console.info(
184
+ `${getPrefix(idx)} restarting (${nextRestart}/${MAX_RESTARTS}) in ${Math.round(delayMs / 1_000)}s`
185
+ )
186
+ managed.restartTimer = setTimeout(() => {
187
+ if (exiting || processes[idx] !== managed) return
188
+ managed.restartTimer = null
189
+ spawnScript(name, cwd, label, extraArgs, idx, nextRestart)
190
+ }, delayMs)
191
+ } else {
192
+ console.error(`${getPrefix(idx)} gave up after ${MAX_RESTARTS} rapid restarts`)
193
+ cleanup(1)
194
+ }
195
+ }
196
+ }
197
+ })
198
+
199
+ return managed
200
+ }
201
+
202
+ function computeShortcuts() {
203
+ // use last word of label
204
+ for (let i = 0; i < processes.length; i++) {
205
+ const p = processes[i]
206
+ const words = p.label
207
+ .toLowerCase()
208
+ .split(/[^a-z]+/)
209
+ .filter(Boolean)
210
+ const lastWord = words[words.length - 1] || words[0] || String(i)
211
+
212
+ // find unique shortcut
213
+ let shortcut = lastWord[0]
214
+ let len = 1
215
+ while (
216
+ processes.slice(0, i).some((q) => q.shortcut === shortcut) &&
217
+ len < lastWord.length
218
+ ) {
219
+ len++
220
+ shortcut = lastWord.slice(0, len)
221
+ }
222
+ p.shortcut = shortcut
223
+ }
224
+ }
225
+
226
+ function showDashboard() {
227
+ const tabs = processes
228
+ .map((p, i) => {
229
+ const color = colors[i % colors.length]
230
+ return `${color}[${p.shortcut}]${reset} ${p.label.split(' ').pop()}${p.killed ? dim + ' ✗' + reset : ''}`
231
+ })
232
+ .join(' ')
233
+ console.log(`\n${tabs}`)
234
+ console.log(
235
+ `${dim}press shortcut to focus, r+shortcut restart, k+shortcut kill, ctrl+c exit${reset}\n`
236
+ )
237
+ }
238
+
239
+ let pendingAction = null // 'r' or 'k'
240
+
241
+ function handleInput(data) {
242
+ const str = data.toString()
243
+
244
+ // ctrl+c exit
245
+ if (str === '\x03') {
246
+ cleanup()
247
+ return
248
+ }
249
+
250
+ // ctrl+z toggle focus
251
+ if (str === '\x1a') {
252
+ if (focusedIndex >= 0) {
253
+ focusedIndex = -1
254
+ showDashboard()
255
+ } else {
256
+ showDashboard()
257
+ }
258
+ return
259
+ }
260
+
261
+ // if focused, forward to process
262
+ if (focusedIndex >= 0) {
263
+ const p = processes[focusedIndex]
264
+ if (p && !p.killed) {
265
+ p.terminal.write(str)
266
+ }
267
+ return
268
+ }
269
+
270
+ // dashboard mode
271
+ if (str === 'r') {
272
+ pendingAction = 'restart'
273
+ process.stdout.write(`${dim}restart which? ${reset}`)
274
+ return
275
+ }
276
+ if (str === 'k') {
277
+ pendingAction = 'kill'
278
+ process.stdout.write(`${dim}kill which? ${reset}`)
279
+ return
280
+ }
281
+
282
+ // check for shortcut match
283
+ const match = processes.find((p) => p.shortcut === str.toLowerCase())
284
+ if (match) {
285
+ if (pendingAction === 'restart') {
286
+ pendingAction = null
287
+ console.log(match.shortcut)
288
+ if (match.restartTimer) {
289
+ clearTimeout(match.restartTimer)
290
+ match.restartTimer = null
291
+ }
292
+ if (!match.killed) {
293
+ match.killed = true
294
+ killProcessTree(match.terminal.pid)
295
+ }
296
+ match.restartTimer = setTimeout(() => {
297
+ if (exiting || processes[match.index] !== match) return
298
+ match.restartTimer = null
299
+ spawnScript(match.name, match.cwd, match.label, match.extraArgs, match.index)
300
+ console.log(`${getPrefix(match.index)} restarted`)
301
+ }, 100)
302
+ } else if (pendingAction === 'kill') {
303
+ pendingAction = null
304
+ console.log(match.shortcut)
305
+ if (match.restartTimer) {
306
+ clearTimeout(match.restartTimer)
307
+ match.restartTimer = null
308
+ }
309
+ if (!match.killed) {
310
+ match.killed = true
311
+ killProcessTree(match.terminal.pid)
312
+ console.log(`${getPrefix(match.index)} killed`)
313
+ }
314
+ } else {
315
+ // focus
316
+ focusedIndex = match.index
317
+ console.log(`${dim}focused: ${match.label} (ctrl+z to unfocus)${reset}\n`)
318
+ }
319
+ return
320
+ }
321
+
322
+ // escape cancels pending
323
+ if (str === '\x1b' && pendingAction) {
324
+ pendingAction = null
325
+ console.log('cancelled')
326
+ }
327
+ }
328
+
329
+ // kill entire process group to prevent orphans
330
+ function killProcessTree(pid) {
331
+ if (!pid) return
332
+ // try killing process group first (negative pid)
333
+ try {
334
+ process.kill(-pid, 'SIGTERM')
335
+ } catch {}
336
+ // also kill direct process
337
+ try {
338
+ process.kill(pid, 'SIGTERM')
339
+ } catch {}
340
+ // schedule force kill
341
+ setTimeout(() => {
342
+ try {
343
+ process.kill(-pid, 'SIGKILL')
344
+ } catch {}
345
+ try {
346
+ process.kill(pid, 'SIGKILL')
347
+ } catch {}
348
+ }, 100)
349
+ }
350
+
351
+ function cleanup(exitCode = 0) {
352
+ if (exiting) return
353
+ exiting = true
354
+ // restore terminal to cooked mode before exiting
355
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
356
+ try {
357
+ process.stdin.setRawMode(false)
358
+ } catch {}
359
+ }
360
+ // reset terminal attributes (colors, etc)
361
+ process.stdout.write('\x1b[0m\n')
362
+
363
+ for (const p of processes) {
364
+ if (!p.killed) {
365
+ p.killed = true
366
+ killProcessTree(p.terminal.pid)
367
+ }
368
+ }
369
+
370
+ // wait for kills to complete before exit
371
+ setTimeout(() => process.exit(exitCode), 150)
372
+ }
373
+
374
+ async function main() {
375
+ const lastScript = runCommands[runCommands.length - 1]
376
+
377
+ if (!noRoot) {
378
+ const pkg = await readPackageJson('.')
379
+ if (pkg?.scripts) {
380
+ for (const name of runCommands) {
381
+ if (typeof pkg.scripts[name] === 'string') {
382
+ spawnScript(
383
+ name,
384
+ '.',
385
+ name,
386
+ !flagsLast || name === lastScript ? forwardArgs : []
387
+ )
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ const wsScripts = await findWorkspaceScripts(runCommands)
394
+ for (const [dir, { scripts, name }] of wsScripts) {
395
+ for (const script of scripts) {
396
+ spawnScript(
397
+ script,
398
+ dir,
399
+ `${name} ${script}`,
400
+ !flagsLast || script === lastScript ? forwardArgs : []
401
+ )
402
+ }
403
+ }
404
+
405
+ if (processes.length === 0) {
406
+ console.error('No scripts found')
407
+ process.exit(1)
408
+ }
409
+
410
+ computeShortcuts()
411
+ showDashboard()
412
+
413
+ if (process.stdin.isTTY) {
414
+ process.stdin.setRawMode(true)
415
+ process.stdin.resume()
416
+ process.stdin.on('data', handleInput)
417
+ }
418
+
419
+ process.stdout.on('resize', () => {
420
+ for (const p of processes) {
421
+ if (!p.killed) {
422
+ try {
423
+ p.terminal.resize(process.stdout.columns || 80, process.stdout.rows || 24)
424
+ } catch {
425
+ // pty already closed
426
+ }
427
+ }
428
+ }
429
+ })
430
+
431
+ process.on('SIGINT', () => cleanup())
432
+ process.on('SIGTERM', () => cleanup())
433
+ }
434
+
435
+ // restore terminal on any unexpected exit
436
+ function restoreTerminal() {
437
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
438
+ try {
439
+ process.stdin.setRawMode(false)
440
+ } catch {}
441
+ }
442
+ process.stdout.write('\x1b[0m')
443
+ }
444
+
445
+ process.on('uncaughtException', (e) => {
446
+ restoreTerminal()
447
+ console.error(e)
448
+ process.exit(1)
449
+ })
450
+
451
+ main().catch((e) => {
452
+ restoreTerminal()
453
+ console.error(e)
454
+ process.exit(1)
455
+ })
@@ -0,0 +1,94 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { spawn, type ChildProcess } from 'node:child_process'
3
+ import {
4
+ appendFileSync,
5
+ existsSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join, resolve } from 'node:path'
13
+
14
+ const RUNNER = resolve(import.meta.dir, 'run.ts')
15
+ const running: ChildProcess[] = []
16
+ const directories: string[] = []
17
+
18
+ afterEach(async () => {
19
+ for (const child of running.splice(0)) {
20
+ if (child.exitCode !== null || child.signalCode !== null) continue
21
+ child.kill('SIGTERM')
22
+ await new Promise<void>((done) => child.once('exit', () => done()))
23
+ }
24
+ for (const directory of directories.splice(0)) {
25
+ rmSync(directory, { recursive: true, force: true })
26
+ }
27
+ })
28
+
29
+ async function waitFor(read: () => boolean, timeoutMs = 5_000) {
30
+ const deadline = Date.now() + timeoutMs
31
+ while (Date.now() < deadline) {
32
+ if (read()) return
33
+ await Bun.sleep(25)
34
+ }
35
+ throw new Error('timed out waiting for supervised process state')
36
+ }
37
+
38
+ function startRunner(watch: boolean) {
39
+ const directory = mkdtempSync(join(tmpdir(), 'tko-run-supervision-'))
40
+ directories.push(directory)
41
+ const startsPath = join(directory, 'starts.txt')
42
+ writeFileSync(
43
+ join(directory, 'child.ts'),
44
+ `import { appendFileSync } from 'node:fs'\nappendFileSync(process.env.STARTS_PATH!, String(process.pid) + '\\n')\nawait new Promise(() => {})\n`
45
+ )
46
+ writeFileSync(
47
+ join(directory, 'package.json'),
48
+ JSON.stringify({ scripts: { child: 'bun child.ts' } })
49
+ )
50
+
51
+ const child = spawn('bun', [RUNNER, ...(watch ? ['--watch'] : []), 'child'], {
52
+ cwd: directory,
53
+ env: { ...process.env, STARTS_PATH: startsPath },
54
+ stdio: ['pipe', 'pipe', 'pipe'],
55
+ })
56
+ running.push(child)
57
+ let output = ''
58
+ child.stdout?.on('data', (chunk) => {
59
+ output += String(chunk)
60
+ })
61
+ child.stderr?.on('data', (chunk) => {
62
+ output += String(chunk)
63
+ })
64
+ const managedPidsPath = `/tmp/tko-run-${child.pid}.pids`
65
+
66
+ return {
67
+ child,
68
+ managedPidsPath,
69
+ startsPath,
70
+ output() {
71
+ return output
72
+ },
73
+ starts() {
74
+ if (!existsSync(startsPath)) return []
75
+ return readFileSync(startsPath, 'utf8').trim().split('\n').filter(Boolean)
76
+ },
77
+ managedPids() {
78
+ if (!existsSync(managedPidsPath)) return []
79
+ return readFileSync(managedPidsPath, 'utf8').trim().split('\n').filter(Boolean)
80
+ },
81
+ }
82
+ }
83
+
84
+ describe('run process supervision', () => {
85
+ test('watch restarts a child terminated by a signal', async () => {
86
+ const runner = startRunner(true)
87
+ await waitFor(() => runner.starts().length === 1 && runner.managedPids().length === 1)
88
+
89
+ process.kill(Number(runner.managedPids()[0]), 'SIGTERM')
90
+
91
+ await waitFor(() => runner.starts().length === 2)
92
+ expect(runner.starts()[1]).not.toBe(runner.starts()[0])
93
+ }, 15_000)
94
+ })