@o/run 0.16.19-canary.1790165522484

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/src/run.ts ADDED
@@ -0,0 +1,546 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * @description Run multiple scripts in parallel
5
+ */
6
+
7
+ import { spawn } from 'node:child_process'
8
+ import fs from 'node:fs'
9
+ import { join, relative, resolve } from 'node:path'
10
+
11
+ import { handleProcessExit } from './helpers/handleProcessExit'
12
+ import { getIsExiting } from './helpers/process-state'
13
+ import { flattenScripts } from './helpers/resolveScript'
14
+ import { checkNodeVersion } from './node-version-check'
15
+
16
+ const colors = [
17
+ '\x1b[38;5;245m',
18
+ '\x1b[38;5;240m',
19
+ '\x1b[38;5;250m',
20
+ '\x1b[38;5;243m',
21
+ '\x1b[38;5;248m',
22
+ '\x1b[38;5;238m',
23
+ '\x1b[38;5;252m',
24
+ ]
25
+
26
+ const reset = '\x1b[0m'
27
+
28
+ // eslint-disable-next-line no-control-regex
29
+ const ansiPattern = /\x1b\[[0-9;]*m/g
30
+
31
+ interface ManagedProcess {
32
+ proc: ReturnType<typeof spawn>
33
+ name: string
34
+ cwd: string
35
+ prefixLabel: string
36
+ extraArgs: string[]
37
+ index: number
38
+ shortcut: string
39
+ }
40
+
41
+ async function readPackageJson(directoryPath: string) {
42
+ try {
43
+ const packageJsonPath = join(directoryPath, 'package.json')
44
+ const content = await fs.promises.readFile(packageJsonPath, 'utf8')
45
+ return JSON.parse(content)
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ async function getWorkspacePatterns(): Promise<string[]> {
52
+ try {
53
+ const packageJson = await readPackageJson('.')
54
+ if (!packageJson || !packageJson.workspaces) return []
55
+
56
+ return Array.isArray(packageJson.workspaces)
57
+ ? packageJson.workspaces
58
+ : packageJson.workspaces.packages || []
59
+ } catch {
60
+ return []
61
+ }
62
+ }
63
+
64
+ async function hasPackageJson(path: string): Promise<boolean> {
65
+ try {
66
+ await fs.promises.access(join(path, 'package.json'))
67
+ return true
68
+ } catch {
69
+ return false
70
+ }
71
+ }
72
+
73
+ async function findPackageJsonDirs(basePath: string, maxDepth = 3): Promise<string[]> {
74
+ if (maxDepth <= 0) return []
75
+
76
+ try {
77
+ const entries = await fs.promises.readdir(basePath, { withFileTypes: true })
78
+ const results: string[] = []
79
+
80
+ if (await hasPackageJson(basePath)) {
81
+ results.push(basePath)
82
+ }
83
+
84
+ const subDirPromises = entries
85
+ .filter(
86
+ (entry) =>
87
+ entry.isDirectory() &&
88
+ !entry.name.startsWith('.') &&
89
+ entry.name !== 'node_modules'
90
+ )
91
+ .map(async (dir) => {
92
+ const path = join(basePath, dir.name)
93
+ return findPackageJsonDirs(path, maxDepth - 1)
94
+ })
95
+
96
+ const subdirResults = await Promise.all(subDirPromises)
97
+ return [...results, ...subdirResults.flat()]
98
+ } catch {
99
+ return []
100
+ }
101
+ }
102
+
103
+ async function findWorkspaceDirectories(): Promise<string[]> {
104
+ const patterns = await getWorkspacePatterns()
105
+ if (!patterns.length) return []
106
+
107
+ const allPackageDirs = await findPackageJsonDirs('.')
108
+
109
+ const normalizePath = (path: string): string => {
110
+ const normalized = path.replace(/\\/g, '/')
111
+ return normalized.startsWith('./') ? normalized.substring(2) : normalized
112
+ }
113
+
114
+ return allPackageDirs.filter((dir) => {
115
+ if (dir === '.') return false
116
+
117
+ const relativePath = relative('.', dir)
118
+ return patterns.some((pattern) => {
119
+ const normalizedPattern = normalizePath(pattern)
120
+ const normalizedPath = normalizePath(relativePath)
121
+
122
+ if (normalizedPattern.endsWith('/*')) {
123
+ const prefix = normalizedPattern.slice(0, -1)
124
+ return normalizedPath.startsWith(prefix)
125
+ }
126
+ return (
127
+ normalizedPath === normalizedPattern ||
128
+ normalizedPath.startsWith(normalizedPattern + '/')
129
+ )
130
+ })
131
+ })
132
+ }
133
+
134
+ async function findAvailableScripts(
135
+ directoryPath: string,
136
+ scriptNames: string[]
137
+ ): Promise<string[]> {
138
+ const packageJson = await readPackageJson(directoryPath)
139
+
140
+ if (!packageJson || !packageJson.scripts) {
141
+ return []
142
+ }
143
+
144
+ return scriptNames.filter(
145
+ (scriptName) => typeof packageJson.scripts?.[scriptName] === 'string'
146
+ )
147
+ }
148
+
149
+ async function mapWorkspacesToScripts(
150
+ scriptNames: string[]
151
+ ): Promise<Map<string, { scripts: string[]; packageName: string }>> {
152
+ const workspaceDirs = await findWorkspaceDirectories()
153
+ const workspaceScriptMap = new Map<string, { scripts: string[]; packageName: string }>()
154
+
155
+ for (const dir of workspaceDirs) {
156
+ const availableScripts = await findAvailableScripts(dir, scriptNames)
157
+
158
+ if (availableScripts.length > 0) {
159
+ const packageJson = await readPackageJson(dir)
160
+ const packageName = packageJson?.name || dir
161
+ workspaceScriptMap.set(dir, {
162
+ scripts: availableScripts,
163
+ packageName,
164
+ })
165
+ }
166
+ }
167
+
168
+ return workspaceScriptMap
169
+ }
170
+
171
+ function computeShortcuts(managedProcesses: ManagedProcess[]) {
172
+ const initials = managedProcesses.map((p) => {
173
+ const words = p.prefixLabel
174
+ .toLowerCase()
175
+ .split(/[^a-z]+/)
176
+ .filter(Boolean)
177
+ return words.map((w) => w[0]).join('')
178
+ })
179
+
180
+ const lengths = new Array(managedProcesses.length).fill(1) as number[]
181
+
182
+ for (let round = 0; round < 5; round++) {
183
+ const shortcuts = initials.map((init, i) => init.slice(0, lengths[i]) || init)
184
+
185
+ let hasCollision = false
186
+ const groups = new Map<string, number[]>()
187
+ for (let i = 0; i < shortcuts.length; i++) {
188
+ const key = shortcuts[i]!
189
+ if (!groups.has(key)) groups.set(key, [])
190
+ groups.get(key)!.push(i)
191
+ }
192
+
193
+ for (const [, indices] of groups) {
194
+ if (indices.length <= 1) continue
195
+ hasCollision = true
196
+ for (const idx of indices) {
197
+ lengths[idx]!++
198
+ }
199
+ }
200
+
201
+ if (!hasCollision) {
202
+ for (let i = 0; i < managedProcesses.length; i++) {
203
+ managedProcesses[i]!.shortcut = shortcuts[i]!
204
+ }
205
+ return
206
+ }
207
+ }
208
+
209
+ for (let i = 0; i < managedProcesses.length; i++) {
210
+ const sc = initials[i]!.slice(0, lengths[i]) || initials[i]!
211
+ managedProcesses[i]!.shortcut = sc || String(i + 1)
212
+ }
213
+ }
214
+
215
+ // --- exported interface for CLI direct-import ---
216
+
217
+ export interface RunParallelScriptsOptions {
218
+ commands: string[]
219
+ noRoot?: boolean
220
+ flagsLast?: boolean
221
+ runBun?: boolean
222
+ watch?: boolean
223
+ forwardArgs?: string[]
224
+ }
225
+
226
+ export async function runParallelScripts(
227
+ options: RunParallelScriptsOptions
228
+ ): Promise<void> {
229
+ const {
230
+ commands: runCommands,
231
+ noRoot = false,
232
+ flagsLast = false,
233
+ runBun = false,
234
+ watch = false,
235
+ forwardArgs = [],
236
+ } = options
237
+
238
+ const MAX_RESTARTS = 3
239
+
240
+ const parentRunningScripts = process.env.BUN_RUN_SCRIPTS
241
+ ? process.env.BUN_RUN_SCRIPTS.split(',')
242
+ : []
243
+
244
+ const managedProcesses: ManagedProcess[] = []
245
+ const { addChildProcess, exit } = handleProcessExit()
246
+
247
+ function getPrefix(index: number): string {
248
+ const managed = managedProcesses[index]
249
+ if (!managed) return ''
250
+ const color = colors[index % colors.length]
251
+ const sc = managed.shortcut || String(index + 1)
252
+ return `${color}${sc} ${managed.prefixLabel}${reset}`
253
+ }
254
+
255
+ if (runCommands.length === 0) {
256
+ console.error('Please provide at least one script name to run')
257
+ exit(1)
258
+ return
259
+ }
260
+
261
+ // spawns a single script process, optionally running a command directly via bash
262
+ const runScript = async (
263
+ name: string,
264
+ cwd = '.',
265
+ prefixLabel: string = name,
266
+ restarts = 0,
267
+ extraArgs: string[] = [],
268
+ managedIndex?: number,
269
+ directCommand?: string,
270
+ deferredChildren?: import('./helpers/resolveScript').FlattenedScript[]
271
+ ) => {
272
+ const index = managedIndex ?? managedProcesses.length
273
+
274
+ const allRunningScripts = [...parentRunningScripts, ...runCommands].join(',')
275
+
276
+ let proc: ReturnType<typeof spawn>
277
+
278
+ if (directCommand) {
279
+ // run the resolved command directly via bash, skipping the bun run layer
280
+ const command = extraArgs.length
281
+ ? `${directCommand} ${extraArgs.join(' ')}`
282
+ : directCommand
283
+ proc = spawn('bash', ['-c', command], {
284
+ stdio: ['ignore', 'pipe', 'pipe'],
285
+ shell: false,
286
+ detached: true,
287
+ env: {
288
+ ...process.env,
289
+ FORCE_COLOR: '3',
290
+ BUN_RUN_PARENT_SCRIPT: name,
291
+ BUN_RUN_SCRIPTS: allRunningScripts,
292
+ TKO_SILENT: '1',
293
+ } as any,
294
+ cwd: resolve(cwd),
295
+ })
296
+ } else {
297
+ const runArgs = [
298
+ 'run',
299
+ '--silent',
300
+ runBun ? '--bun' : '',
301
+ name,
302
+ ...extraArgs,
303
+ ].filter(Boolean)
304
+ proc = spawn('bun', runArgs, {
305
+ stdio: ['ignore', 'pipe', 'pipe'],
306
+ shell: false,
307
+ detached: true,
308
+ env: {
309
+ ...process.env,
310
+ FORCE_COLOR: '3',
311
+ BUN_RUN_PARENT_SCRIPT: name,
312
+ BUN_RUN_SCRIPTS: allRunningScripts,
313
+ TKO_SILENT: '1',
314
+ } as any,
315
+ cwd: resolve(cwd),
316
+ })
317
+ }
318
+
319
+ // when this process spawned — used to tell a crash-loop (rapid re-crashes)
320
+ // from an occasional crash after a long stable run.
321
+ const startedAt = Date.now()
322
+
323
+ const managed: ManagedProcess = {
324
+ proc,
325
+ name,
326
+ cwd,
327
+ prefixLabel,
328
+ extraArgs,
329
+ index,
330
+ shortcut: '',
331
+ }
332
+
333
+ if (managedIndex !== undefined) {
334
+ managedProcesses[managedIndex] = managed
335
+ } else {
336
+ managedProcesses.push(managed)
337
+ }
338
+
339
+ addChildProcess(proc)
340
+
341
+ proc.stdout!.on('data', (data) => {
342
+ if (getIsExiting()) return
343
+ const lines = data.toString().split('\n')
344
+ for (const line of lines) {
345
+ const stripped = line.replace(ansiPattern, '')
346
+ if (stripped.startsWith('$ ')) continue
347
+ if (line) console.info(`${getPrefix(index)} ${line}`)
348
+ }
349
+ })
350
+
351
+ proc.stderr!.on('data', (data) => {
352
+ if (getIsExiting()) return
353
+ const lines = data.toString().split('\n')
354
+ for (const line of lines) {
355
+ const stripped = line.replace(ansiPattern, '')
356
+ if (stripped.startsWith('$ ')) continue
357
+ if (line) console.error(`${getPrefix(index)} ${line}`)
358
+ }
359
+ })
360
+
361
+ proc.on('error', (error) => {
362
+ console.error(`${getPrefix(index)} Failed to start: ${error.message}`)
363
+ })
364
+
365
+ proc.on('close', (code, signal) => {
366
+ if (getIsExiting()) return
367
+
368
+ if (code !== 0 || signal !== null) {
369
+ const result = signal ? `signal ${signal}` : `code ${code}`
370
+ console.error(`${getPrefix(index)} Process exited with ${result}`)
371
+
372
+ if (watch) {
373
+ // a process that ran stably for a while before crashing isn't a
374
+ // crash-loop — reset the counter so an occasional crash over a long
375
+ // dev session recovers, instead of permanently giving up after
376
+ // MAX_RESTARTS lifetime hits. the cap then only catches RAPID re-crashes.
377
+ const ranMs = Date.now() - startedAt
378
+ const STABLE_MS = 20_000
379
+ const effectiveRestarts = ranMs >= STABLE_MS ? 0 : restarts
380
+ if (effectiveRestarts < MAX_RESTARTS) {
381
+ const newRestarts = effectiveRestarts + 1
382
+ // exponential backoff so a process crash-looping under CPU/memory
383
+ // pressure doesn't instantly re-boot and spin the box at 100% —
384
+ // wait 1s, 2s, 4s … capped at 30s before the next attempt.
385
+ const delayMs = Math.min(1_000 * 2 ** effectiveRestarts, 30_000)
386
+ console.info(
387
+ `Restarting process ${name} (${newRestarts}/${MAX_RESTARTS}) in ${Math.round(delayMs / 1000)}s`
388
+ )
389
+ setTimeout(() => {
390
+ if (getIsExiting()) return
391
+ runScript(
392
+ name,
393
+ cwd,
394
+ prefixLabel,
395
+ newRestarts,
396
+ extraArgs,
397
+ index,
398
+ directCommand,
399
+ deferredChildren
400
+ )
401
+ }, delayMs)
402
+ } else {
403
+ console.error(
404
+ `${getPrefix(index)} gave up after ${MAX_RESTARTS} rapid restarts`
405
+ )
406
+ exit(1)
407
+ }
408
+ } else {
409
+ exit(1)
410
+ }
411
+ } else if (deferredChildren && deferredChildren.length > 0) {
412
+ // command succeeded — spawn deferred parallel scripts
413
+ for (const deferred of deferredChildren) {
414
+ runScript(
415
+ deferred.name,
416
+ '.',
417
+ deferred.name,
418
+ 0,
419
+ [],
420
+ undefined,
421
+ !deferred.isRaw ? deferred.command : undefined
422
+ )
423
+ }
424
+ }
425
+ })
426
+
427
+ return proc
428
+ }
429
+
430
+ try {
431
+ checkNodeVersion().catch((err: unknown) => {
432
+ console.error((err as Error).message)
433
+ exit(1)
434
+ })
435
+
436
+ if (runCommands.length > 0) {
437
+ const lastScript = runCommands[runCommands.length - 1]
438
+
439
+ if (!noRoot) {
440
+ // read package.json scripts for flattening
441
+ const packageJson = await readPackageJson('.')
442
+ const pkgScripts = packageJson?.scripts ?? {}
443
+
444
+ const filteredCommands = runCommands.filter(
445
+ (name) => !parentRunningScripts.includes(name)
446
+ )
447
+
448
+ // flatten tko run/run-all chains into leaf commands
449
+ const flattened = flattenScripts(filteredCommands, pkgScripts, {
450
+ runningScripts: parentRunningScripts,
451
+ })
452
+
453
+ const lastLeaf = flattened[flattened.length - 1]
454
+ const scriptPromises = flattened.map((leaf) => {
455
+ const scriptArgs = !flagsLast || leaf === lastLeaf ? forwardArgs : []
456
+ // known scripts: run command directly via bash (skip bun run layer)
457
+ // unknown scripts: fall through to bun run --silent
458
+ const directCommand = !leaf.isRaw ? leaf.command : undefined
459
+ return runScript(
460
+ leaf.name,
461
+ '.',
462
+ leaf.name,
463
+ 0,
464
+ scriptArgs,
465
+ undefined,
466
+ directCommand,
467
+ leaf.deferredScripts
468
+ )
469
+ })
470
+
471
+ await Promise.all(scriptPromises)
472
+ }
473
+
474
+ // workspace scripts still use bun run (they need workspace cwd)
475
+ const workspaceScriptMap = await mapWorkspacesToScripts(runCommands)
476
+
477
+ for (const [workspace, { scripts, packageName }] of workspaceScriptMap.entries()) {
478
+ const filteredScripts = scripts.filter(
479
+ (scriptName) => !parentRunningScripts.includes(scriptName)
480
+ )
481
+ const workspaceScriptPromises = filteredScripts.map((scriptName) => {
482
+ const scriptArgs = !flagsLast || scriptName === lastScript ? forwardArgs : []
483
+ return runScript(
484
+ scriptName,
485
+ workspace,
486
+ `${packageName} ${scriptName}`,
487
+ 0,
488
+ scriptArgs
489
+ )
490
+ })
491
+
492
+ await Promise.all(workspaceScriptPromises)
493
+ }
494
+ }
495
+
496
+ if (managedProcesses.length === 0) {
497
+ exit(0)
498
+ } else {
499
+ computeShortcuts(managedProcesses)
500
+ }
501
+ } catch (error) {
502
+ console.error(`Error running scripts: ${error}`)
503
+ exit(1)
504
+ }
505
+ }
506
+
507
+ // --- CLI entry point (only runs when executed directly, not when imported) ---
508
+
509
+ const ownFlags = ['--no-root', '--bun', '--watch', '--flags=last']
510
+
511
+ export function parseRunArgs(args: string[]): RunParallelScriptsOptions {
512
+ const commands: string[] = []
513
+ const forwardArgs: string[] = []
514
+
515
+ for (let i = 0; i < args.length; i++) {
516
+ const arg = args[i]!
517
+
518
+ if (arg.startsWith('--')) {
519
+ if (ownFlags.includes(arg)) continue
520
+ forwardArgs.push(arg)
521
+ const nextArg = args[i + 1]
522
+ if (nextArg && !nextArg.startsWith('--')) {
523
+ forwardArgs.push(nextArg)
524
+ i++
525
+ }
526
+ } else {
527
+ commands.push(arg)
528
+ }
529
+ }
530
+
531
+ return {
532
+ commands,
533
+ noRoot: args.includes('--no-root'),
534
+ runBun: args.includes('--bun'),
535
+ watch: args.includes('--watch'),
536
+ flagsLast: args.includes('--flags=last'),
537
+ forwardArgs,
538
+ }
539
+ }
540
+
541
+ if (typeof Bun !== 'undefined' && Bun.main === import.meta.path) {
542
+ runParallelScripts(parseRunArgs(process.argv.slice(2))).catch((error: unknown) => {
543
+ console.error(`Error running scripts: ${error}`)
544
+ process.exit(1)
545
+ })
546
+ }