@take-out/scripts 0.1.13 → 0.1.14

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 (2) hide show
  1. package/package.json +2 -2
  2. package/src/run.ts +310 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@take-out/scripts",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "main": "./src/run.ts",
6
6
  "sideEffects": false,
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@clack/prompts": "^0.8.2",
32
- "@take-out/helpers": "0.1.13",
32
+ "@take-out/helpers": "0.1.14",
33
33
  "picocolors": "^1.1.1"
34
34
  },
35
35
  "peerDependencies": {
package/src/run.ts CHANGED
@@ -89,9 +89,30 @@ const parentRunningScripts = process.env.BUN_RUN_SCRIPTS
89
89
  ? process.env.BUN_RUN_SCRIPTS.split(',')
90
90
  : []
91
91
 
92
- const processes: ReturnType<typeof spawn>[] = []
92
+ interface ManagedProcess {
93
+ proc: ReturnType<typeof spawn>
94
+ name: string
95
+ cwd: string
96
+ prefixLabel: string
97
+ extraArgs: string[]
98
+ index: number
99
+ shortcut: string
100
+ restarting: boolean
101
+ killing: boolean
102
+ }
103
+
104
+ const managedProcesses: ManagedProcess[] = []
93
105
  const { addChildProcess, exit } = handleProcessExit()
94
106
 
107
+ // dynamic prefix using shortcut letter(s) — falls back to index before shortcuts are computed
108
+ function getPrefix(index: number): string {
109
+ const managed = managedProcesses[index]
110
+ if (!managed) return ''
111
+ const color = colors[index % colors.length]
112
+ const sc = managed.shortcut || String(index + 1)
113
+ return `${color}${sc} ${managed.prefixLabel}${reset}`
114
+ }
115
+
95
116
  if (runCommands.length === 0) {
96
117
  log.error('Please provide at least one script name to run')
97
118
  log.error('Example: bun run.ts watch lint test')
@@ -239,59 +260,68 @@ const runScript = async (
239
260
  cwd = '.',
240
261
  prefixLabel: string = name,
241
262
  restarts = 0,
242
- extraArgs: string[] = []
263
+ extraArgs: string[] = [],
264
+ managedIndex?: number,
243
265
  ) => {
244
- const colorIndex = processes.length % colors.length
245
- const color = colors[colorIndex]
266
+ const index = managedIndex ?? managedProcesses.length
246
267
 
247
- // Capture stderr for error reporting
268
+ // capture stderr for error reporting
248
269
  let stderrBuffer = ''
249
270
 
250
- // Construct command with arguments to forward
251
271
  // --silent suppresses bun's "$ command" output
252
272
  const runArgs = ['run', '--silent', runBun ? '--bun' : '', name, ...extraArgs].filter(
253
273
  Boolean
254
274
  )
255
275
 
256
- // Log the exact command being run
257
276
  const commandDisplay = `bun ${runArgs.join(' ')}`
277
+ log.info(`${getPrefix(index)} Running: ${commandDisplay} (in ${resolve(cwd)})`)
258
278
 
259
- log.info(
260
- `${color}${prefixLabel}${reset} Running: ${commandDisplay} (in ${resolve(cwd)})`
261
- )
262
-
263
- // Combine parent running scripts with current scripts to prevent recursion
264
279
  const allRunningScripts = [...parentRunningScripts, ...runCommands].join(',')
265
280
 
266
- const shouldInheritStdin = name === stdinScript
281
+ // always pipe stdin - parent handles keyboard shortcuts and forwarding
267
282
  const proc = spawn('bun', runArgs, {
268
- stdio: [shouldInheritStdin ? 'inherit' : 'pipe', 'pipe', 'pipe'],
283
+ stdio: ['pipe', 'pipe', 'pipe'],
269
284
  shell: false,
270
285
  env: {
271
286
  ...process.env,
272
287
  FORCE_COLOR: '3',
273
288
  BUN_RUN_PARENT_SCRIPT: name,
274
289
  BUN_RUN_SCRIPTS: allRunningScripts,
275
- // propagate silent mode to child scripts
276
290
  TKO_SILENT: '1',
277
291
  } as any,
278
292
  cwd: resolve(cwd),
279
293
  detached: true,
280
294
  })
281
295
 
282
- log.info(`${color}${prefixLabel}${reset} Process started with PID: ${proc.pid}`)
296
+ log.info(`${getPrefix(index)} Process started with PID: ${proc.pid}`)
297
+
298
+ const managed: ManagedProcess = {
299
+ proc,
300
+ name,
301
+ cwd,
302
+ prefixLabel,
303
+ extraArgs,
304
+ index,
305
+ shortcut: '',
306
+ restarting: false,
307
+ killing: false,
308
+ }
309
+
310
+ if (managedIndex !== undefined) {
311
+ managedProcesses[managedIndex] = managed
312
+ } else {
313
+ managedProcesses.push(managed)
314
+ }
283
315
 
284
- processes.push(proc)
285
316
  addChildProcess(proc)
286
317
 
287
318
  proc.stdout!.on('data', (data) => {
288
- if (getIsExiting()) return // prevent output during cleanup
319
+ if (getIsExiting()) return
289
320
  const lines = data.toString().split('\n')
290
321
  for (const line of lines) {
291
- // filter out bun's "$ command" echo lines in nested scripts
292
322
  const stripped = line.replace(ansiPattern, '')
293
323
  if (stripped.startsWith('$ ')) continue
294
- if (line) log.output(`${color}${prefixLabel}${reset} ${line}`)
324
+ if (line) log.output(`${getPrefix(index)} ${line}`)
295
325
  }
296
326
  })
297
327
 
@@ -299,28 +329,28 @@ const runScript = async (
299
329
  const dataStr = data.toString()
300
330
  stderrBuffer += dataStr
301
331
 
302
- if (getIsExiting()) return // prevent output during cleanup
332
+ if (getIsExiting()) return
303
333
  const lines = dataStr.split('\n')
304
334
  for (const line of lines) {
305
- // filter out bun's "$ command" echo lines in nested scripts
306
335
  const stripped = line.replace(ansiPattern, '')
307
336
  if (stripped.startsWith('$ ')) continue
308
- if (line) log.error(`${color}${prefixLabel}${reset} ${line}`)
337
+ if (line) log.error(`${getPrefix(index)} ${line}`)
309
338
  }
310
339
  })
311
340
 
312
341
  proc.on('error', (error) => {
313
- log.error(`${color}${prefixLabel}${reset} Failed to start: ${error.message}`)
342
+ log.error(`${getPrefix(index)} Failed to start: ${error.message}`)
314
343
  })
315
344
 
316
345
  proc.on('close', (code) => {
317
- if (getIsExiting()) {
318
- // silently exit during cleanup
319
- return
320
- }
346
+ if (getIsExiting()) return
347
+
348
+ // intentionally killed or restarting - skip error handling
349
+ const currentManaged = managedProcesses[index]
350
+ if (currentManaged?.restarting || currentManaged?.killing) return
321
351
 
322
352
  if (code && code !== 0) {
323
- log.error(`${color}${prefixLabel}${reset} Process exited with code ${code}`)
353
+ log.error(`${getPrefix(index)} Process exited with code ${code}`)
324
354
 
325
355
  if (code === 1) {
326
356
  console.error('\x1b[31m❌ Run Failed\x1b[0m')
@@ -333,7 +363,7 @@ const runScript = async (
333
363
  console.info(
334
364
  `Restarting process ${name} (${newRestarts}/${MAX_RESTARTS} times)`
335
365
  )
336
- runScript(name, cwd, prefixLabel, newRestarts, extraArgs)
366
+ runScript(name, cwd, prefixLabel, newRestarts, extraArgs, index)
337
367
  } else {
338
368
  exit(1)
339
369
  }
@@ -344,6 +374,251 @@ const runScript = async (
344
374
  return proc
345
375
  }
346
376
 
377
+ // compute unique letter-based shortcuts from process labels
378
+ // splits on non-letters, takes first char of each word, extends until unique
379
+ function computeShortcuts() {
380
+ const initials = managedProcesses.map((p) => {
381
+ const words = p.prefixLabel
382
+ .toLowerCase()
383
+ .split(/[^a-z]+/)
384
+ .filter(Boolean)
385
+ return words.map((w) => w[0]).join('')
386
+ })
387
+
388
+ // start each shortcut at 1 letter, extend collisions
389
+ const lengths = new Array(managedProcesses.length).fill(1) as number[]
390
+
391
+ for (let round = 0; round < 5; round++) {
392
+ const shortcuts = initials.map(
393
+ (init, i) => init.slice(0, lengths[i]) || init
394
+ )
395
+
396
+ let hasCollision = false
397
+ const groups = new Map<string, number[]>()
398
+ for (let i = 0; i < shortcuts.length; i++) {
399
+ const key = shortcuts[i]!
400
+ if (!groups.has(key)) groups.set(key, [])
401
+ groups.get(key)!.push(i)
402
+ }
403
+
404
+ for (const [, indices] of groups) {
405
+ if (indices.length <= 1) continue
406
+ hasCollision = true
407
+ for (const idx of indices) {
408
+ lengths[idx]!++
409
+ }
410
+ }
411
+
412
+ if (!hasCollision) {
413
+ for (let i = 0; i < managedProcesses.length; i++) {
414
+ managedProcesses[i]!.shortcut = shortcuts[i]!
415
+ }
416
+ return
417
+ }
418
+ }
419
+
420
+ // fallback: use whatever we have, append index if still colliding
421
+ for (let i = 0; i < managedProcesses.length; i++) {
422
+ const sc = initials[i]!.slice(0, lengths[i]) || initials[i]!
423
+ managedProcesses[i]!.shortcut = sc || String(i + 1)
424
+ }
425
+ }
426
+
427
+ async function killProcessGroup(managed: ManagedProcess) {
428
+ if (managed.proc.pid) {
429
+ try {
430
+ process.kill(-managed.proc.pid, 'SIGTERM')
431
+ } catch {}
432
+ await new Promise((r) => setTimeout(r, 200))
433
+ try {
434
+ process.kill(-managed.proc.pid, 'SIGKILL')
435
+ } catch {}
436
+ }
437
+ await new Promise((r) => setTimeout(r, 100))
438
+ }
439
+
440
+ async function restartProcess(index: number) {
441
+ const managed = managedProcesses[index]
442
+ if (!managed) return
443
+
444
+ const { name, cwd, prefixLabel, extraArgs } = managed
445
+
446
+ managed.restarting = true
447
+ managed.killing = false
448
+ console.info(`\x1b[2m restarting ${managed.shortcut} ${prefixLabel}...\x1b[0m`)
449
+
450
+ await killProcessGroup(managed)
451
+ await runScript(name, cwd, prefixLabel, 0, extraArgs, index)
452
+ console.info(`${getPrefix(index)} \x1b[32m↻ restarted\x1b[0m`)
453
+ }
454
+
455
+ async function killProcess(index: number) {
456
+ const managed = managedProcesses[index]
457
+ if (!managed) return
458
+
459
+ if (managed.killing) {
460
+ console.info(`\x1b[2m ${managed.shortcut} ${managed.prefixLabel} already stopped\x1b[0m`)
461
+ return
462
+ }
463
+
464
+ managed.killing = true
465
+ managed.restarting = false
466
+ console.info(`\x1b[2m killing ${managed.shortcut} ${managed.prefixLabel}...\x1b[0m`)
467
+
468
+ await killProcessGroup(managed)
469
+ console.info(`${getPrefix(index)} \x1b[31m■ stopped\x1b[0m`)
470
+ }
471
+
472
+ type InputMode = 'restart' | 'kill' | null
473
+
474
+ function setupKeyboardShortcuts() {
475
+ if (!process.stdin.isTTY) return
476
+ if (managedProcesses.length === 0) return
477
+
478
+ process.stdin.setRawMode(true)
479
+ process.stdin.resume()
480
+ process.stdin.setEncoding('utf8')
481
+
482
+ let mode: InputMode = null
483
+ let buffer = ''
484
+ let timer: ReturnType<typeof setTimeout> | null = null
485
+
486
+ function clearTimer() {
487
+ if (timer) {
488
+ clearTimeout(timer)
489
+ timer = null
490
+ }
491
+ }
492
+
493
+ function dispatchMatch(m: InputMode, index: number) {
494
+ if (m === 'restart') restartProcess(index)
495
+ else if (m === 'kill') killProcess(index)
496
+ }
497
+
498
+ function finishMatch() {
499
+ clearTimer()
500
+ if (!buffer) return
501
+
502
+ const currentMode = mode
503
+ const match = managedProcesses.find((p) => p.shortcut === buffer)
504
+ if (match) {
505
+ dispatchMatch(currentMode, match.index)
506
+ } else {
507
+ console.info(`\x1b[2m no match for "${buffer}"\x1b[0m`)
508
+ }
509
+
510
+ buffer = ''
511
+ mode = null
512
+ }
513
+
514
+ function showProcessList(label: string) {
515
+ const dim = '\x1b[2m'
516
+ console.info()
517
+ console.info(`${dim} ${label} which process?${reset}`)
518
+ for (const managed of managedProcesses) {
519
+ const color = colors[managed.index % colors.length]
520
+ const stopped = managed.killing ? `${dim} (stopped)` : ''
521
+ console.info(`${dim} ${reset}${color}${managed.shortcut}${reset}${dim} ${managed.prefixLabel}${stopped}${reset}`)
522
+ }
523
+ console.info()
524
+ }
525
+
526
+ function enterMode(newMode: InputMode, label: string) {
527
+ clearTimer()
528
+ mode = newMode
529
+ buffer = ''
530
+ showProcessList(label)
531
+ }
532
+
533
+ process.stdin.on('data', (key: string) => {
534
+ // ctrl+c
535
+ if (key === '\x03') {
536
+ process.stdin.setRawMode(false)
537
+ exit(0)
538
+ return
539
+ }
540
+
541
+ // escape cancels
542
+ if (key === '\x1b' && mode) {
543
+ clearTimer()
544
+ buffer = ''
545
+ mode = null
546
+ console.info('\x1b[2m cancelled\x1b[0m')
547
+ return
548
+ }
549
+
550
+ // ctrl+r - restart mode
551
+ if (key === '\x12') {
552
+ enterMode('restart', 'restart')
553
+ return
554
+ }
555
+
556
+ // ctrl+k - kill mode
557
+ if (key === '\x0b') {
558
+ enterMode('kill', 'kill')
559
+ return
560
+ }
561
+
562
+ // ctrl+l - clear screen
563
+ if (key === '\x0c') {
564
+ process.stdout.write('\x1b[2J\x1b[H')
565
+ return
566
+ }
567
+
568
+ if (mode) {
569
+ const lower = key.toLowerCase()
570
+ if (/^[a-z]$/.test(lower)) {
571
+ buffer += lower
572
+ clearTimer()
573
+
574
+ // exact match → dispatch immediately
575
+ const exact = managedProcesses.find((p) => p.shortcut === buffer)
576
+ if (exact) {
577
+ const m = mode
578
+ mode = null
579
+ buffer = ''
580
+ dispatchMatch(m, exact.index)
581
+ return
582
+ }
583
+
584
+ // no shortcuts start with buffer → no match
585
+ const hasPrefix = managedProcesses.some((p) => p.shortcut.startsWith(buffer))
586
+ if (!hasPrefix) {
587
+ console.info(`\x1b[2m no match for "${buffer}"\x1b[0m`)
588
+ buffer = ''
589
+ mode = null
590
+ return
591
+ }
592
+
593
+ // ambiguous — wait 500ms for more input
594
+ timer = setTimeout(finishMatch, 500)
595
+ } else {
596
+ // non-letter cancels
597
+ clearTimer()
598
+ buffer = ''
599
+ mode = null
600
+ console.info('\x1b[2m cancelled\x1b[0m')
601
+ }
602
+ return
603
+ }
604
+
605
+ // forward other input to the designated stdin process
606
+ const stdinProc = managedProcesses.find((p) => p.name === stdinScript)
607
+ if (stdinProc?.proc.stdin && !stdinProc.proc.stdin.destroyed) {
608
+ stdinProc.proc.stdin.write(key)
609
+ }
610
+ })
611
+ }
612
+
613
+ function printShortcutHint() {
614
+ if (!process.stdin.isTTY) return
615
+ if (managedProcesses.length === 0) return
616
+
617
+ const dim = '\x1b[2m'
618
+ console.info(`${dim} ctrl+r restart · ctrl+k kill · ctrl+l clear · ctrl+c exit${reset}`)
619
+ console.info()
620
+ }
621
+
347
622
  async function main() {
348
623
  checkNodeVersion().catch((err) => {
349
624
  log.error(err.message)
@@ -384,8 +659,12 @@ async function main() {
384
659
  }
385
660
  }
386
661
 
387
- if (processes.length === 0) {
662
+ if (managedProcesses.length === 0) {
388
663
  exit(0)
664
+ } else {
665
+ computeShortcuts()
666
+ printShortcutHint()
667
+ setupKeyboardShortcuts()
389
668
  }
390
669
  } catch (error) {
391
670
  log.error(`Error running scripts: ${error}`)