@take-out/scripts 0.1.39 → 0.1.40

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/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@take-out/scripts",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "type": "module",
5
- "main": "./src/run.ts",
5
+ "main": "./src/cmd.ts",
6
6
  "sideEffects": false,
7
7
  "exports": {
8
8
  ".": {
9
- "types": "./src/run.ts",
10
- "default": "./src/run.ts"
9
+ "types": "./src/cmd.ts",
10
+ "default": "./src/cmd.ts"
11
11
  },
12
12
  "./package.json": "./package.json",
13
13
  "./helpers/*": {
@@ -30,7 +30,8 @@
30
30
  "dependencies": {
31
31
  "@clack/prompts": "^0.8.2",
32
32
  "@lydell/node-pty": "^1.2.0-beta.3",
33
- "@take-out/helpers": "0.1.39",
33
+ "@take-out/helpers": "0.1.40",
34
+ "@take-out/run": "0.1.40",
34
35
  "picocolors": "^1.1.1"
35
36
  },
36
37
  "peerDependencies": {
@@ -25,7 +25,7 @@ await cmd`bootstrap project workspace and build initial packages`.run(
25
25
  await $`cd packages/helpers && bun run build`
26
26
 
27
27
  // then build all other packages in parallel
28
- await $`bun ./packages/scripts/src/run.ts build --no-root`
28
+ await $`bun ./packages/run/src/run.ts build --no-root`
29
29
  }
30
30
  }
31
31
 
package/src/dev-tunnel.ts CHANGED
@@ -7,7 +7,7 @@ import { cmd } from './cmd'
7
7
  await cmd`set up cloudflare dev tunnel for local development`
8
8
  .args('--port number')
9
9
  .run(async ({ args, run, os, path }) => {
10
- const { handleProcessExit } = await import('./helpers/handleProcessExit')
10
+ const { handleProcessExit } = await import('@take-out/run/helpers/handleProcessExit')
11
11
 
12
12
  handleProcessExit()
13
13
 
@@ -1,23 +1,17 @@
1
- import { spawn, type ChildProcess } from 'node:child_process'
1
+ import { spawn } from 'node:child_process'
2
2
  import { cpus } from 'node:os'
3
3
 
4
- import type { Timer } from '@take-out/helpers'
5
-
6
- export type ProcessType = ChildProcess
7
- export type ProcessHandler = (process: ProcessType) => void
8
-
9
- // track if we're in cleanup state (another process failed)
10
- let isInExitCleanup = false
11
-
12
- export function setExitCleanupState(state: boolean) {
13
- isInExitCleanup = state
14
- }
4
+ import {
5
+ getIsExiting,
6
+ notifyProcessHandlers,
7
+ type ProcessHandler,
8
+ type ProcessType,
9
+ } from '@take-out/run/helpers/process-state'
15
10
 
16
- export function getIsExiting() {
17
- return isInExitCleanup
18
- }
11
+ import type { Timer } from '@take-out/helpers'
19
12
 
20
- const processHandlers = new Set<ProcessHandler>()
13
+ export type { ProcessHandler, ProcessType }
14
+ export { getIsExiting }
21
15
 
22
16
  const colors = [
23
17
  '\x1b[36m', // cyan
@@ -105,7 +99,7 @@ export async function run(
105
99
  shell.unref()
106
100
  }
107
101
 
108
- processHandlers.forEach((cb) => cb(shell))
102
+ notifyProcessHandlers(shell)
109
103
 
110
104
  if (timeout) {
111
105
  timeoutId = setTimeout(() => {
@@ -206,7 +200,7 @@ export async function run(
206
200
  ? `Command timed out after ${timeout}ms: ${command}`
207
201
  : `Command failed with exit code ${exitCode}: ${command}`
208
202
 
209
- if (!silent && !isInExitCleanup) {
203
+ if (!silent && !getIsExiting()) {
210
204
  console.error(`run() error: ${errorMsg}: ${stderr || ''}`)
211
205
  }
212
206
 
@@ -218,14 +212,14 @@ export async function run(
218
212
  return { stdout, stderr, exitCode }
219
213
  } catch (error) {
220
214
  clearTimeout(timeoutId)
221
- if (!silent && !isInExitCleanup) {
215
+ if (!silent && !getIsExiting()) {
222
216
  // only show the error message, not the full object if it's our error
223
217
  if (error instanceof Error && (error as any).cause?.exitCode !== undefined) {
224
218
  // this is our controlled error, already logged above
225
219
  } else {
226
220
  console.error(`Error running command: ${command}`, error)
227
221
  }
228
- } else if (!silent && isInExitCleanup) {
222
+ } else if (!silent && getIsExiting()) {
229
223
  // simple message when being killed due to another error
230
224
  const shortCmd = command.split(' ')[0]
231
225
  console.error(`${shortCmd} exiting due to earlier error`)
@@ -235,10 +229,6 @@ export async function run(
235
229
  }
236
230
  }
237
231
 
238
- export const addProcessHandler = (cb: ProcessHandler) => {
239
- processHandlers.add(cb)
240
- }
241
-
242
232
  export async function waitForRun(name: string) {
243
233
  if (running[name] === undefined) {
244
234
  throw new Error(`Can't wait before task runs: ${name}`)
package/src/release.ts CHANGED
@@ -340,7 +340,7 @@ await cmd`publish takeout packages to npm`
340
340
 
341
341
  if (!skipTest) {
342
342
  await run(`bun lint`)
343
- await run(`bun check:all`)
343
+ await run(`bun check`)
344
344
  // only in packages
345
345
  // await run(`bun test`)
346
346
  }
@@ -1,171 +0,0 @@
1
- import {
2
- addProcessHandler,
3
- setExitCleanupState,
4
- type ProcessHandler,
5
- type ProcessType,
6
- } from './run'
7
-
8
- type ExitCallback = (info: { signal: NodeJS.Signals | string }) => void | Promise<void>
9
-
10
- interface HandleProcessExitReturn {
11
- addChildProcess: ProcessHandler
12
- cleanup: () => Promise<void>
13
- exit: (code?: number) => Promise<void>
14
- }
15
-
16
- // kill an entire process group (works because children are spawned with detached: true,
17
- // which makes them process group leaders). negative pid = kill the whole group.
18
- // this is synchronous, no pgrep needed, no races.
19
- function killProcessGroup(
20
- pid: number,
21
- signal: NodeJS.Signals = 'SIGTERM',
22
- forceful: boolean = false
23
- ): void {
24
- // kill the process group (negative pid)
25
- try {
26
- process.kill(-pid, signal)
27
- } catch (_) {
28
- // group may already be gone, try the individual process
29
- try {
30
- process.kill(pid, signal)
31
- } catch (_) {
32
- // process already gone
33
- }
34
- }
35
-
36
- if (forceful && signal !== 'SIGKILL') {
37
- // schedule a SIGKILL followup
38
- setTimeout(() => {
39
- try {
40
- process.kill(-pid, 'SIGKILL')
41
- } catch (_) {}
42
- try {
43
- process.kill(pid, 'SIGKILL')
44
- } catch (_) {}
45
- }, 100)
46
- }
47
- }
48
-
49
- let isHandling = false
50
-
51
- export function handleProcessExit({
52
- onExit,
53
- }: {
54
- onExit?: ExitCallback
55
- } = {}): HandleProcessExitReturn {
56
- if (isHandling) {
57
- throw new Error(`Only one handleProcessExit per process should be registered!`)
58
- }
59
-
60
- isHandling = true
61
- const processes: ProcessType[] = []
62
- let cleanupPromise: Promise<void> | null = null
63
-
64
- const cleanup = (signal: NodeJS.Signals | string = 'SIGTERM'): Promise<void> => {
65
- // return existing cleanup promise if already running, so process.exit
66
- // override waits for the real cleanup instead of exiting early
67
- if (cleanupPromise) return cleanupPromise
68
-
69
- cleanupPromise = doCleanup(signal)
70
- return cleanupPromise
71
- }
72
-
73
- const doCleanup = async (signal: NodeJS.Signals | string) => {
74
- setExitCleanupState(true)
75
-
76
- if (onExit) {
77
- try {
78
- await onExit({ signal })
79
- } catch (error) {
80
- console.error('Error in exit callback:', error)
81
- }
82
- }
83
-
84
- if (processes.length === 0) {
85
- return
86
- }
87
-
88
- // kill process groups synchronously - no pgrep, no races
89
- // detached: true makes each child a process group leader,
90
- // so kill(-pid) gets the entire group in one syscall
91
- const isInterrupt = signal === 'SIGINT'
92
- const killSignal = isInterrupt ? 'SIGTERM' : (signal as NodeJS.Signals)
93
-
94
- for (const proc of processes) {
95
- if (proc.pid) {
96
- killProcessGroup(proc.pid, killSignal, isInterrupt)
97
- }
98
- }
99
-
100
- // brief wait for graceful shutdown
101
- await new Promise((res) => setTimeout(res, isInterrupt ? 80 : 200))
102
-
103
- // force kill any remaining
104
- for (const proc of processes) {
105
- if (proc.pid && !proc.exitCode) {
106
- killProcessGroup(proc.pid, 'SIGKILL')
107
- }
108
- }
109
- }
110
-
111
- const addChildProcess = (proc: ProcessType) => {
112
- processes.push(proc)
113
- }
114
-
115
- addProcessHandler(addChildProcess)
116
-
117
- let finalized = false
118
-
119
- const finalizeWithSignal = (signal: NodeJS.Signals, fallbackCode: number) => {
120
- if (finalized) return
121
- finalized = true
122
- process.off('SIGINT', sigintHandler)
123
- process.off('SIGTERM', sigtermHandler)
124
- process.off('beforeExit', beforeExitHandler)
125
- process.exit = originalExit
126
-
127
- try {
128
- process.kill(process.pid, signal)
129
- } catch {
130
- originalExit(fallbackCode)
131
- }
132
- }
133
-
134
- const sigtermHandler = () => {
135
- cleanup('SIGTERM').then(() => {
136
- finalizeWithSignal('SIGTERM', 143)
137
- })
138
- }
139
-
140
- const sigintHandler = () => {
141
- cleanup('SIGINT').then(() => {
142
- finalizeWithSignal('SIGINT', 130)
143
- })
144
- }
145
-
146
- // intercept process.exit to ensure cleanup completes before exiting.
147
- // if cleanup is already running, this awaits the SAME promise instead of
148
- // early-returning and calling originalExit prematurely.
149
- const originalExit = process.exit
150
- process.exit = ((code?: number) => {
151
- cleanup('SIGTERM').then(() => {
152
- originalExit(code)
153
- })
154
- }) as typeof process.exit
155
-
156
- const beforeExitHandler = () => cleanup('SIGTERM')
157
- process.on('beforeExit', beforeExitHandler)
158
- process.on('SIGINT', sigintHandler)
159
- process.on('SIGTERM', sigtermHandler)
160
-
161
- const exit = async (code: number = 0) => {
162
- await cleanup('SIGTERM')
163
- process.exit(code)
164
- }
165
-
166
- return {
167
- addChildProcess,
168
- cleanup,
169
- exit,
170
- }
171
- }
package/src/run-pty.mjs DELETED
@@ -1,413 +0,0 @@
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
-
43
- const processes = []
44
- let focusedIndex = -1 // -1 = interleaved/dashboard
45
-
46
- function getPrefix(index) {
47
- const p = processes[index]
48
- if (!p) return ''
49
- const color = colors[index % colors.length]
50
- return `${color}${p.shortcut}${reset}`
51
- }
52
-
53
- if (runCommands.length === 0) {
54
- console.error('Usage: run-pty <script1> [script2] ...')
55
- process.exit(1)
56
- }
57
-
58
- async function readPackageJson(dir) {
59
- try {
60
- return JSON.parse(await fs.promises.readFile(join(dir, 'package.json'), 'utf8'))
61
- } catch {
62
- return null
63
- }
64
- }
65
-
66
- async function getWorkspacePatterns() {
67
- const pkg = await readPackageJson('.')
68
- if (!pkg?.workspaces) return []
69
- return Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages || []
70
- }
71
-
72
- async function findPackageJsonDirs(base, depth = 3) {
73
- if (depth <= 0) return []
74
- const results = []
75
- try {
76
- if (
77
- await fs.promises
78
- .access(join(base, 'package.json'))
79
- .then(() => true)
80
- .catch(() => false)
81
- ) {
82
- results.push(base)
83
- }
84
- const entries = await fs.promises.readdir(base, { withFileTypes: true })
85
- for (const e of entries) {
86
- if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') {
87
- results.push(...(await findPackageJsonDirs(join(base, e.name), depth - 1)))
88
- }
89
- }
90
- } catch {}
91
- return results
92
- }
93
-
94
- async function findWorkspaceScripts(scripts) {
95
- const patterns = await getWorkspacePatterns()
96
- if (!patterns.length) return new Map()
97
- const dirs = await findPackageJsonDirs('.')
98
- const result = new Map()
99
- for (const dir of dirs) {
100
- if (dir === '.') continue
101
- const rel = relative('.', dir).replace(/\\/g, '/')
102
- const matches = patterns.some((p) => {
103
- const np = p.replace(/\\/g, '/').replace(/^\.\//, '')
104
- return np.endsWith('/*')
105
- ? rel.startsWith(np.slice(0, -1))
106
- : rel === np || rel.startsWith(np + '/')
107
- })
108
- if (!matches) continue
109
- const pkg = await readPackageJson(dir)
110
- if (!pkg?.scripts) continue
111
- const available = scripts.filter((s) => typeof pkg.scripts[s] === 'string')
112
- if (available.length) result.set(dir, { scripts: available, name: pkg.name || dir })
113
- }
114
- return result
115
- }
116
-
117
- function spawnScript(name, cwd, label, extraArgs, index) {
118
- const runArgs = ['run', '--silent', runBun ? '--bun' : '', name, ...extraArgs].filter(
119
- Boolean
120
- )
121
- const terminal = pty.spawn('bun', runArgs, {
122
- cwd: resolve(cwd),
123
- cols: process.stdout.columns || 80,
124
- rows: process.stdout.rows || 24,
125
- env: { ...process.env, FORCE_COLOR: '3' },
126
- })
127
-
128
- const idx = index ?? processes.length
129
- const managed = {
130
- terminal,
131
- name,
132
- cwd,
133
- label,
134
- extraArgs,
135
- index: idx,
136
- shortcut: '',
137
- killed: false,
138
- }
139
-
140
- if (index !== undefined) processes[index] = managed
141
- else processes.push(managed)
142
-
143
- terminal.onData((data) => {
144
- if (focusedIndex === -1) {
145
- // interleaved - prefix each line, skip bun's $ command echo
146
- const lines = data.split(/\r?\n/)
147
- for (const line of lines) {
148
- if (!line) continue
149
- // eslint-disable-next-line no-control-regex
150
- const stripped = line.replace(/\x1b\[[0-9;]*m/g, '')
151
- if (stripped.startsWith('$ ')) continue
152
- process.stdout.write(`${getPrefix(idx)} ${line}\n`)
153
- }
154
- } else if (focusedIndex === idx) {
155
- // focused - raw output
156
- process.stdout.write(data)
157
- }
158
- })
159
-
160
- terminal.onExit(({ exitCode }) => {
161
- if (managed.killed) return
162
- if (focusedIndex === idx) {
163
- focusedIndex = -1
164
- showDashboard()
165
- }
166
- if (exitCode !== 0) {
167
- console.error(`${getPrefix(idx)} exited ${exitCode}`)
168
- }
169
- })
170
-
171
- return managed
172
- }
173
-
174
- function computeShortcuts() {
175
- // use last word of label
176
- for (let i = 0; i < processes.length; i++) {
177
- const p = processes[i]
178
- const words = p.label
179
- .toLowerCase()
180
- .split(/[^a-z]+/)
181
- .filter(Boolean)
182
- const lastWord = words[words.length - 1] || words[0] || String(i)
183
-
184
- // find unique shortcut
185
- let shortcut = lastWord[0]
186
- let len = 1
187
- while (
188
- processes.slice(0, i).some((q) => q.shortcut === shortcut) &&
189
- len < lastWord.length
190
- ) {
191
- len++
192
- shortcut = lastWord.slice(0, len)
193
- }
194
- p.shortcut = shortcut
195
- }
196
- }
197
-
198
- function showDashboard() {
199
- const tabs = processes
200
- .map((p, i) => {
201
- const color = colors[i % colors.length]
202
- return `${color}[${p.shortcut}]${reset} ${p.label.split(' ').pop()}${p.killed ? dim + ' ✗' + reset : ''}`
203
- })
204
- .join(' ')
205
- console.log(`\n${tabs}`)
206
- console.log(
207
- `${dim}press shortcut to focus, r+shortcut restart, k+shortcut kill, ctrl+c exit${reset}\n`
208
- )
209
- }
210
-
211
- let pendingAction = null // 'r' or 'k'
212
-
213
- function handleInput(data) {
214
- const str = data.toString()
215
-
216
- // ctrl+c exit
217
- if (str === '\x03') {
218
- cleanup()
219
- return
220
- }
221
-
222
- // ctrl+z toggle focus
223
- if (str === '\x1a') {
224
- if (focusedIndex >= 0) {
225
- focusedIndex = -1
226
- showDashboard()
227
- } else {
228
- showDashboard()
229
- }
230
- return
231
- }
232
-
233
- // if focused, forward to process
234
- if (focusedIndex >= 0) {
235
- const p = processes[focusedIndex]
236
- if (p && !p.killed) {
237
- p.terminal.write(str)
238
- }
239
- return
240
- }
241
-
242
- // dashboard mode
243
- if (str === 'r') {
244
- pendingAction = 'restart'
245
- process.stdout.write(`${dim}restart which? ${reset}`)
246
- return
247
- }
248
- if (str === 'k') {
249
- pendingAction = 'kill'
250
- process.stdout.write(`${dim}kill which? ${reset}`)
251
- return
252
- }
253
-
254
- // check for shortcut match
255
- const match = processes.find((p) => p.shortcut === str.toLowerCase())
256
- if (match) {
257
- if (pendingAction === 'restart') {
258
- pendingAction = null
259
- console.log(match.shortcut)
260
- match.killed = true
261
- killProcessTree(match.terminal.pid)
262
- setTimeout(() => {
263
- spawnScript(match.name, match.cwd, match.label, match.extraArgs, match.index)
264
- console.log(`${getPrefix(match.index)} restarted`)
265
- }, 100)
266
- } else if (pendingAction === 'kill') {
267
- pendingAction = null
268
- console.log(match.shortcut)
269
- if (!match.killed) {
270
- match.killed = true
271
- killProcessTree(match.terminal.pid)
272
- console.log(`${getPrefix(match.index)} killed`)
273
- }
274
- } else {
275
- // focus
276
- focusedIndex = match.index
277
- console.log(`${dim}focused: ${match.label} (ctrl+z to unfocus)${reset}\n`)
278
- }
279
- return
280
- }
281
-
282
- // escape cancels pending
283
- if (str === '\x1b' && pendingAction) {
284
- pendingAction = null
285
- console.log('cancelled')
286
- }
287
- }
288
-
289
- // kill entire process group to prevent orphans
290
- function killProcessTree(pid) {
291
- if (!pid) return
292
- // try killing process group first (negative pid)
293
- try {
294
- process.kill(-pid, 'SIGTERM')
295
- } catch {}
296
- // also kill direct process
297
- try {
298
- process.kill(pid, 'SIGTERM')
299
- } catch {}
300
- // schedule force kill
301
- setTimeout(() => {
302
- try {
303
- process.kill(-pid, 'SIGKILL')
304
- } catch {}
305
- try {
306
- process.kill(pid, 'SIGKILL')
307
- } catch {}
308
- }, 100)
309
- }
310
-
311
- function cleanup() {
312
- // restore terminal to cooked mode before exiting
313
- if (process.stdin.isTTY && process.stdin.setRawMode) {
314
- try {
315
- process.stdin.setRawMode(false)
316
- } catch {}
317
- }
318
- // reset terminal attributes (colors, etc)
319
- process.stdout.write('\x1b[0m\n')
320
-
321
- for (const p of processes) {
322
- if (!p.killed) {
323
- p.killed = true
324
- killProcessTree(p.terminal.pid)
325
- }
326
- }
327
-
328
- // wait for kills to complete before exit
329
- setTimeout(() => process.exit(0), 150)
330
- }
331
-
332
- async function main() {
333
- const lastScript = runCommands[runCommands.length - 1]
334
-
335
- if (!noRoot) {
336
- const pkg = await readPackageJson('.')
337
- if (pkg?.scripts) {
338
- for (const name of runCommands) {
339
- if (typeof pkg.scripts[name] === 'string') {
340
- spawnScript(
341
- name,
342
- '.',
343
- name,
344
- !flagsLast || name === lastScript ? forwardArgs : []
345
- )
346
- }
347
- }
348
- }
349
- }
350
-
351
- const wsScripts = await findWorkspaceScripts(runCommands)
352
- for (const [dir, { scripts, name }] of wsScripts) {
353
- for (const script of scripts) {
354
- spawnScript(
355
- script,
356
- dir,
357
- `${name} ${script}`,
358
- !flagsLast || script === lastScript ? forwardArgs : []
359
- )
360
- }
361
- }
362
-
363
- if (processes.length === 0) {
364
- console.error('No scripts found')
365
- process.exit(1)
366
- }
367
-
368
- computeShortcuts()
369
- showDashboard()
370
-
371
- if (process.stdin.isTTY) {
372
- process.stdin.setRawMode(true)
373
- process.stdin.resume()
374
- process.stdin.on('data', handleInput)
375
- }
376
-
377
- process.stdout.on('resize', () => {
378
- for (const p of processes) {
379
- if (!p.killed) {
380
- try {
381
- p.terminal.resize(process.stdout.columns || 80, process.stdout.rows || 24)
382
- } catch {
383
- // pty already closed
384
- }
385
- }
386
- }
387
- })
388
-
389
- process.on('SIGINT', cleanup)
390
- process.on('SIGTERM', cleanup)
391
- }
392
-
393
- // restore terminal on any unexpected exit
394
- function restoreTerminal() {
395
- if (process.stdin.isTTY && process.stdin.setRawMode) {
396
- try {
397
- process.stdin.setRawMode(false)
398
- } catch {}
399
- }
400
- process.stdout.write('\x1b[0m')
401
- }
402
-
403
- process.on('uncaughtException', (e) => {
404
- restoreTerminal()
405
- console.error(e)
406
- process.exit(1)
407
- })
408
-
409
- main().catch((e) => {
410
- restoreTerminal()
411
- console.error(e)
412
- process.exit(1)
413
- })
package/src/run.ts DELETED
@@ -1,411 +0,0 @@
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 '@take-out/scripts/helpers/handleProcessExit'
12
-
13
- import { getIsExiting } from './helpers/run'
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
- const args = process.argv.slice(2)
32
- const ownFlags = ['--no-root', '--bun', '--watch', '--flags=last']
33
- const runCommands: string[] = []
34
- const forwardArgs: string[] = []
35
-
36
- for (let i = 0; i < args.length; i++) {
37
- const arg = args[i]!
38
-
39
- if (arg.startsWith('--')) {
40
- if (ownFlags.includes(arg)) continue
41
- forwardArgs.push(arg)
42
- const nextArg = args[i + 1]
43
- if (nextArg && !nextArg.startsWith('--')) {
44
- forwardArgs.push(nextArg)
45
- i++
46
- }
47
- } else {
48
- runCommands.push(arg)
49
- }
50
- }
51
-
52
- const noRoot = args.includes('--no-root')
53
- const runBun = args.includes('--bun')
54
- const watch = args.includes('--watch')
55
- const flagsLast = args.includes('--flags=last')
56
-
57
- const MAX_RESTARTS = 3
58
-
59
- const parentRunningScripts = process.env.BUN_RUN_SCRIPTS
60
- ? process.env.BUN_RUN_SCRIPTS.split(',')
61
- : []
62
-
63
- interface ManagedProcess {
64
- proc: ReturnType<typeof spawn>
65
- name: string
66
- cwd: string
67
- prefixLabel: string
68
- extraArgs: string[]
69
- index: number
70
- shortcut: string
71
- }
72
-
73
- const managedProcesses: ManagedProcess[] = []
74
- const { addChildProcess, exit } = handleProcessExit()
75
-
76
- function getPrefix(index: number): string {
77
- const managed = managedProcesses[index]
78
- if (!managed) return ''
79
- const color = colors[index % colors.length]
80
- const sc = managed.shortcut || String(index + 1)
81
- return `${color}${sc} ${managed.prefixLabel}${reset}`
82
- }
83
-
84
- if (runCommands.length === 0) {
85
- console.error('Please provide at least one script name to run')
86
- exit(1)
87
- }
88
-
89
- async function readPackageJson(directoryPath: string) {
90
- try {
91
- const packageJsonPath = join(directoryPath, 'package.json')
92
- const content = await fs.promises.readFile(packageJsonPath, 'utf8')
93
- return JSON.parse(content)
94
- } catch {
95
- return null
96
- }
97
- }
98
-
99
- async function getWorkspacePatterns(): Promise<string[]> {
100
- try {
101
- const packageJson = await readPackageJson('.')
102
- if (!packageJson || !packageJson.workspaces) return []
103
-
104
- return Array.isArray(packageJson.workspaces)
105
- ? packageJson.workspaces
106
- : packageJson.workspaces.packages || []
107
- } catch {
108
- return []
109
- }
110
- }
111
-
112
- async function hasPackageJson(path: string): Promise<boolean> {
113
- try {
114
- await fs.promises.access(join(path, 'package.json'))
115
- return true
116
- } catch {
117
- return false
118
- }
119
- }
120
-
121
- async function findPackageJsonDirs(basePath: string, maxDepth = 3): Promise<string[]> {
122
- if (maxDepth <= 0) return []
123
-
124
- try {
125
- const entries = await fs.promises.readdir(basePath, { withFileTypes: true })
126
- const results: string[] = []
127
-
128
- if (await hasPackageJson(basePath)) {
129
- results.push(basePath)
130
- }
131
-
132
- const subDirPromises = entries
133
- .filter(
134
- (entry) =>
135
- entry.isDirectory() &&
136
- !entry.name.startsWith('.') &&
137
- entry.name !== 'node_modules'
138
- )
139
- .map(async (dir) => {
140
- const path = join(basePath, dir.name)
141
- return findPackageJsonDirs(path, maxDepth - 1)
142
- })
143
-
144
- const subdirResults = await Promise.all(subDirPromises)
145
- return [...results, ...subdirResults.flat()]
146
- } catch {
147
- return []
148
- }
149
- }
150
-
151
- async function findWorkspaceDirectories(): Promise<string[]> {
152
- const patterns = await getWorkspacePatterns()
153
- if (!patterns.length) return []
154
-
155
- const allPackageDirs = await findPackageJsonDirs('.')
156
-
157
- const normalizePath = (path: string): string => {
158
- const normalized = path.replace(/\\/g, '/')
159
- return normalized.startsWith('./') ? normalized.substring(2) : normalized
160
- }
161
-
162
- return allPackageDirs.filter((dir) => {
163
- if (dir === '.') return false
164
-
165
- const relativePath = relative('.', dir)
166
- return patterns.some((pattern) => {
167
- const normalizedPattern = normalizePath(pattern)
168
- const normalizedPath = normalizePath(relativePath)
169
-
170
- if (normalizedPattern.endsWith('/*')) {
171
- const prefix = normalizedPattern.slice(0, -1)
172
- return normalizedPath.startsWith(prefix)
173
- }
174
- return (
175
- normalizedPath === normalizedPattern ||
176
- normalizedPath.startsWith(normalizedPattern + '/')
177
- )
178
- })
179
- })
180
- }
181
-
182
- async function findAvailableScripts(
183
- directoryPath: string,
184
- scriptNames: string[]
185
- ): Promise<string[]> {
186
- const packageJson = await readPackageJson(directoryPath)
187
-
188
- if (!packageJson || !packageJson.scripts) {
189
- return []
190
- }
191
-
192
- return scriptNames.filter(
193
- (scriptName) => typeof packageJson.scripts?.[scriptName] === 'string'
194
- )
195
- }
196
-
197
- async function mapWorkspacesToScripts(
198
- scriptNames: string[]
199
- ): Promise<Map<string, { scripts: string[]; packageName: string }>> {
200
- const workspaceDirs = await findWorkspaceDirectories()
201
- const workspaceScriptMap = new Map<string, { scripts: string[]; packageName: string }>()
202
-
203
- for (const dir of workspaceDirs) {
204
- const availableScripts = await findAvailableScripts(dir, scriptNames)
205
-
206
- if (availableScripts.length > 0) {
207
- const packageJson = await readPackageJson(dir)
208
- const packageName = packageJson?.name || dir
209
- workspaceScriptMap.set(dir, {
210
- scripts: availableScripts,
211
- packageName,
212
- })
213
- }
214
- }
215
-
216
- return workspaceScriptMap
217
- }
218
-
219
- const runScript = async (
220
- name: string,
221
- cwd = '.',
222
- prefixLabel: string = name,
223
- restarts = 0,
224
- extraArgs: string[] = [],
225
- managedIndex?: number
226
- ) => {
227
- const index = managedIndex ?? managedProcesses.length
228
-
229
- const runArgs = ['run', '--silent', runBun ? '--bun' : '', name, ...extraArgs].filter(
230
- Boolean
231
- )
232
-
233
- const allRunningScripts = [...parentRunningScripts, ...runCommands].join(',')
234
-
235
- const proc = spawn('bun', runArgs, {
236
- stdio: ['ignore', 'pipe', 'pipe'],
237
- shell: false,
238
- detached: true,
239
- env: {
240
- ...process.env,
241
- FORCE_COLOR: '3',
242
- BUN_RUN_PARENT_SCRIPT: name,
243
- BUN_RUN_SCRIPTS: allRunningScripts,
244
- TKO_SILENT: '1',
245
- } as any,
246
- cwd: resolve(cwd),
247
- })
248
-
249
- const managed: ManagedProcess = {
250
- proc,
251
- name,
252
- cwd,
253
- prefixLabel,
254
- extraArgs,
255
- index,
256
- shortcut: '',
257
- }
258
-
259
- if (managedIndex !== undefined) {
260
- managedProcesses[managedIndex] = managed
261
- } else {
262
- managedProcesses.push(managed)
263
- }
264
-
265
- addChildProcess(proc)
266
-
267
- proc.stdout!.on('data', (data) => {
268
- if (getIsExiting()) return
269
- const lines = data.toString().split('\n')
270
- for (const line of lines) {
271
- const stripped = line.replace(ansiPattern, '')
272
- if (stripped.startsWith('$ ')) continue
273
- if (line) console.info(`${getPrefix(index)} ${line}`)
274
- }
275
- })
276
-
277
- proc.stderr!.on('data', (data) => {
278
- if (getIsExiting()) return
279
- const lines = data.toString().split('\n')
280
- for (const line of lines) {
281
- const stripped = line.replace(ansiPattern, '')
282
- if (stripped.startsWith('$ ')) continue
283
- if (line) console.error(`${getPrefix(index)} ${line}`)
284
- }
285
- })
286
-
287
- proc.on('error', (error) => {
288
- console.error(`${getPrefix(index)} Failed to start: ${error.message}`)
289
- })
290
-
291
- proc.on('close', (code) => {
292
- if (getIsExiting()) return
293
-
294
- if (code && code !== 0) {
295
- console.error(`${getPrefix(index)} Process exited with code ${code}`)
296
-
297
- if (watch && restarts < MAX_RESTARTS) {
298
- const newRestarts = restarts + 1
299
- console.info(`Restarting process ${name} (${newRestarts}/${MAX_RESTARTS} times)`)
300
- runScript(name, cwd, prefixLabel, newRestarts, extraArgs, index)
301
- } else {
302
- exit(1)
303
- }
304
- }
305
- })
306
-
307
- return proc
308
- }
309
-
310
- function computeShortcuts() {
311
- const initials = managedProcesses.map((p) => {
312
- const words = p.prefixLabel
313
- .toLowerCase()
314
- .split(/[^a-z]+/)
315
- .filter(Boolean)
316
- return words.map((w) => w[0]).join('')
317
- })
318
-
319
- const lengths = new Array(managedProcesses.length).fill(1) as number[]
320
-
321
- for (let round = 0; round < 5; round++) {
322
- const shortcuts = initials.map((init, i) => init.slice(0, lengths[i]) || init)
323
-
324
- let hasCollision = false
325
- const groups = new Map<string, number[]>()
326
- for (let i = 0; i < shortcuts.length; i++) {
327
- const key = shortcuts[i]!
328
- if (!groups.has(key)) groups.set(key, [])
329
- groups.get(key)!.push(i)
330
- }
331
-
332
- for (const [, indices] of groups) {
333
- if (indices.length <= 1) continue
334
- hasCollision = true
335
- for (const idx of indices) {
336
- lengths[idx]!++
337
- }
338
- }
339
-
340
- if (!hasCollision) {
341
- for (let i = 0; i < managedProcesses.length; i++) {
342
- managedProcesses[i]!.shortcut = shortcuts[i]!
343
- }
344
- return
345
- }
346
- }
347
-
348
- for (let i = 0; i < managedProcesses.length; i++) {
349
- const sc = initials[i]!.slice(0, lengths[i]) || initials[i]!
350
- managedProcesses[i]!.shortcut = sc || String(i + 1)
351
- }
352
- }
353
-
354
- async function main() {
355
- checkNodeVersion().catch((err) => {
356
- console.error(err.message)
357
- exit(1)
358
- })
359
-
360
- try {
361
- if (runCommands.length > 0) {
362
- const lastScript = runCommands[runCommands.length - 1]
363
-
364
- if (!noRoot) {
365
- const filteredCommands = runCommands.filter(
366
- (name) => !parentRunningScripts.includes(name)
367
- )
368
- const scriptPromises = filteredCommands.map((name) => {
369
- const scriptArgs = !flagsLast || name === lastScript ? forwardArgs : []
370
- return runScript(name, '.', name, 0, scriptArgs)
371
- })
372
-
373
- await Promise.all(scriptPromises)
374
- }
375
-
376
- const workspaceScriptMap = await mapWorkspacesToScripts(runCommands)
377
-
378
- for (const [workspace, { scripts, packageName }] of workspaceScriptMap.entries()) {
379
- const filteredScripts = scripts.filter(
380
- (scriptName) => !parentRunningScripts.includes(scriptName)
381
- )
382
- const workspaceScriptPromises = filteredScripts.map((scriptName) => {
383
- const scriptArgs = !flagsLast || scriptName === lastScript ? forwardArgs : []
384
- return runScript(
385
- scriptName,
386
- workspace,
387
- `${packageName} ${scriptName}`,
388
- 0,
389
- scriptArgs
390
- )
391
- })
392
-
393
- await Promise.all(workspaceScriptPromises)
394
- }
395
- }
396
-
397
- if (managedProcesses.length === 0) {
398
- exit(0)
399
- } else {
400
- computeShortcuts()
401
- }
402
- } catch (error) {
403
- console.error(`Error running scripts: ${error}`)
404
- exit(1)
405
- }
406
- }
407
-
408
- main().catch((error) => {
409
- console.error(`Error running scripts: ${error}`)
410
- exit(1)
411
- })