@take-out/scripts 0.1.39-1772673702585 → 0.1.39-1772740363029
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 +2 -2
- package/src/helpers/handleProcessExit.ts +77 -3
- package/src/helpers/process-cleanup.test.ts +141 -0
- package/src/helpers/resolveScript.test.ts +187 -0
- package/src/helpers/resolveScript.ts +91 -0
- package/src/process-cleanup.test.ts +255 -0
- package/src/run.ts +238 -151
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@take-out/scripts",
|
|
3
|
-
"version": "0.1.39-
|
|
3
|
+
"version": "0.1.39-1772740363029",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/run.ts",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -30,7 +30,7 @@
|
|
|
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.39-1772740363029",
|
|
34
34
|
"picocolors": "^1.1.1"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process'
|
|
2
|
+
import { appendFileSync, rmSync } from 'node:fs'
|
|
3
|
+
|
|
1
4
|
import {
|
|
2
5
|
addProcessHandler,
|
|
3
6
|
setExitCleanupState,
|
|
@@ -13,19 +16,76 @@ interface HandleProcessExitReturn {
|
|
|
13
16
|
exit: (code?: number) => Promise<void>
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
const pidFilePath = `/tmp/tko-run-${process.pid}.pids`
|
|
20
|
+
|
|
21
|
+
function writePidFile(pid: number) {
|
|
22
|
+
try {
|
|
23
|
+
appendFileSync(pidFilePath, `${pid}\n`)
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function removePidFile() {
|
|
28
|
+
try {
|
|
29
|
+
rmSync(pidFilePath, { force: true })
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// find descendant pids that survived the group kill
|
|
34
|
+
function findStragglers(trackedPids: number[]): number[] {
|
|
35
|
+
try {
|
|
36
|
+
const output = execSync('ps -eo pid,ppid', { encoding: 'utf-8', timeout: 2000 })
|
|
37
|
+
const lines = output.trim().split('\n').slice(1) // skip header
|
|
38
|
+
|
|
39
|
+
const parentToChildren = new Map<number, number[]>()
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const parts = line.trim().split(/\s+/)
|
|
42
|
+
if (parts.length < 2) continue
|
|
43
|
+
const pid = parseInt(parts[0]!, 10)
|
|
44
|
+
const ppid = parseInt(parts[1]!, 10)
|
|
45
|
+
if (isNaN(pid) || isNaN(ppid)) continue
|
|
46
|
+
if (!parentToChildren.has(ppid)) parentToChildren.set(ppid, [])
|
|
47
|
+
parentToChildren.get(ppid)!.push(pid)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// walk from each tracked pid to find all descendants
|
|
51
|
+
const descendants = new Set<number>()
|
|
52
|
+
const queue = [...trackedPids]
|
|
53
|
+
while (queue.length > 0) {
|
|
54
|
+
const current = queue.pop()!
|
|
55
|
+
const children = parentToChildren.get(current)
|
|
56
|
+
if (!children) continue
|
|
57
|
+
for (const child of children) {
|
|
58
|
+
if (!descendants.has(child)) {
|
|
59
|
+
descendants.add(child)
|
|
60
|
+
queue.push(child)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...descendants]
|
|
65
|
+
} catch {
|
|
66
|
+
return []
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// kill entire process group to prevent orphaned descendants
|
|
16
71
|
function killProcess(
|
|
17
72
|
pid: number,
|
|
18
73
|
signal: NodeJS.Signals = 'SIGTERM',
|
|
19
74
|
forceful: boolean = false
|
|
20
75
|
): void {
|
|
76
|
+
// kill process group first (negative pid), then direct process
|
|
77
|
+
try {
|
|
78
|
+
process.kill(-pid, signal)
|
|
79
|
+
} catch (_) {}
|
|
21
80
|
try {
|
|
22
81
|
process.kill(pid, signal)
|
|
23
|
-
} catch (_) {
|
|
24
|
-
// process already gone
|
|
25
|
-
}
|
|
82
|
+
} catch (_) {}
|
|
26
83
|
|
|
27
84
|
if (forceful && signal !== 'SIGKILL') {
|
|
28
85
|
setTimeout(() => {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(-pid, 'SIGKILL')
|
|
88
|
+
} catch (_) {}
|
|
29
89
|
try {
|
|
30
90
|
process.kill(pid, 'SIGKILL')
|
|
31
91
|
} catch (_) {}
|
|
@@ -90,10 +150,24 @@ export function handleProcessExit({
|
|
|
90
150
|
killProcess(proc.pid, 'SIGKILL')
|
|
91
151
|
}
|
|
92
152
|
}
|
|
153
|
+
|
|
154
|
+
// straggler walk: find any descendants that survived the group kill
|
|
155
|
+
const trackedPids = processes.map((p) => p.pid).filter(Boolean) as number[]
|
|
156
|
+
if (trackedPids.length > 0) {
|
|
157
|
+
const stragglers = findStragglers(trackedPids)
|
|
158
|
+
for (const pid of stragglers) {
|
|
159
|
+
try {
|
|
160
|
+
process.kill(pid, 'SIGKILL')
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
removePidFile()
|
|
93
166
|
}
|
|
94
167
|
|
|
95
168
|
const addChildProcess = (proc: ProcessType) => {
|
|
96
169
|
processes.push(proc)
|
|
170
|
+
if (proc.pid) writePidFile(proc.pid)
|
|
97
171
|
}
|
|
98
172
|
|
|
99
173
|
addProcessHandler(addChildProcess)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { afterAll, afterEach, describe, expect, it } from 'vitest'
|
|
7
|
+
|
|
8
|
+
function isAlive(pid: number): boolean {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0)
|
|
11
|
+
return true
|
|
12
|
+
} catch {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function waitForDead(pid: number, timeoutMs = 3000): Promise<boolean> {
|
|
18
|
+
const start = Date.now()
|
|
19
|
+
while (Date.now() - start < timeoutMs) {
|
|
20
|
+
if (!isAlive(pid)) return true
|
|
21
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
22
|
+
}
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function waitForFile(path: string, timeoutMs = 3000): Promise<boolean> {
|
|
27
|
+
const start = Date.now()
|
|
28
|
+
while (Date.now() - start < timeoutMs) {
|
|
29
|
+
if (existsSync(path)) return true
|
|
30
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
31
|
+
}
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('process cleanup', () => {
|
|
36
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'tko-test-'))
|
|
37
|
+
const pids: number[] = []
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
for (const pid of pids) {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(-pid, 'SIGKILL')
|
|
43
|
+
} catch {}
|
|
44
|
+
try {
|
|
45
|
+
process.kill(pid, 'SIGKILL')
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
pids.length = 0
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
try {
|
|
53
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
54
|
+
} catch {}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('detached child gets its own process group', async () => {
|
|
58
|
+
const pidFile = join(tmpDir, 'child1.pid')
|
|
59
|
+
const child = spawn('bash', ['-c', `echo $$ > ${pidFile}; sleep 999`], {
|
|
60
|
+
detached: true,
|
|
61
|
+
stdio: 'ignore',
|
|
62
|
+
})
|
|
63
|
+
pids.push(child.pid!)
|
|
64
|
+
child.unref()
|
|
65
|
+
|
|
66
|
+
expect(await waitForFile(pidFile)).toBe(true)
|
|
67
|
+
const childPid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10)
|
|
68
|
+
|
|
69
|
+
expect(isAlive(childPid)).toBe(true)
|
|
70
|
+
|
|
71
|
+
// killing the process group should kill it
|
|
72
|
+
try {
|
|
73
|
+
process.kill(-child.pid!, 'SIGKILL')
|
|
74
|
+
} catch {}
|
|
75
|
+
expect(await waitForDead(childPid)).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('process group kill reaches grandchildren', async () => {
|
|
79
|
+
const grandchildPidFile = join(tmpDir, 'grandchild.pid')
|
|
80
|
+
// write a helper script so escaping is clean
|
|
81
|
+
const helperScript = join(tmpDir, 'grandchild-helper.sh')
|
|
82
|
+
writeFileSync(
|
|
83
|
+
helperScript,
|
|
84
|
+
`#!/bin/bash\necho $$ > ${grandchildPidFile}\nsleep 999\n`,
|
|
85
|
+
{ mode: 0o755 }
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const parent = spawn('bash', ['-c', `bash ${helperScript} & wait`], {
|
|
89
|
+
detached: true,
|
|
90
|
+
stdio: 'ignore',
|
|
91
|
+
})
|
|
92
|
+
pids.push(parent.pid!)
|
|
93
|
+
|
|
94
|
+
expect(await waitForFile(grandchildPidFile)).toBe(true)
|
|
95
|
+
const grandchildPid = parseInt(readFileSync(grandchildPidFile, 'utf-8').trim(), 10)
|
|
96
|
+
pids.push(grandchildPid)
|
|
97
|
+
|
|
98
|
+
expect(isAlive(grandchildPid)).toBe(true)
|
|
99
|
+
|
|
100
|
+
// kill the process group — grandchild should die too
|
|
101
|
+
try {
|
|
102
|
+
process.kill(-parent.pid!, 'SIGKILL')
|
|
103
|
+
} catch {}
|
|
104
|
+
expect(await waitForDead(grandchildPid)).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('SIGTERM on parent with trap kills children', async () => {
|
|
108
|
+
const childPidFile = join(tmpDir, 'term-child.pid')
|
|
109
|
+
const wrapperScript = join(tmpDir, 'wrapper.sh')
|
|
110
|
+
writeFileSync(
|
|
111
|
+
wrapperScript,
|
|
112
|
+
[
|
|
113
|
+
'#!/bin/bash',
|
|
114
|
+
'cleanup() { kill 0; exit 0; }',
|
|
115
|
+
'trap cleanup SIGTERM',
|
|
116
|
+
`bash -c 'echo $$ > ${childPidFile}; sleep 999' &`,
|
|
117
|
+
'wait',
|
|
118
|
+
'',
|
|
119
|
+
].join('\n'),
|
|
120
|
+
{ mode: 0o755 }
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const parent = spawn('bash', [wrapperScript], {
|
|
124
|
+
detached: true,
|
|
125
|
+
stdio: 'ignore',
|
|
126
|
+
})
|
|
127
|
+
pids.push(parent.pid!)
|
|
128
|
+
|
|
129
|
+
expect(await waitForFile(childPidFile)).toBe(true)
|
|
130
|
+
const childPid = parseInt(readFileSync(childPidFile, 'utf-8').trim(), 10)
|
|
131
|
+
pids.push(childPid)
|
|
132
|
+
|
|
133
|
+
expect(isAlive(childPid)).toBe(true)
|
|
134
|
+
|
|
135
|
+
// send SIGTERM to the process group
|
|
136
|
+
try {
|
|
137
|
+
process.kill(-parent.pid!, 'SIGTERM')
|
|
138
|
+
} catch {}
|
|
139
|
+
expect(await waitForDead(childPid, 5000)).toBe(true)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { flattenScripts, type FlattenedScript } from './resolveScript'
|
|
4
|
+
|
|
5
|
+
// scripts modeled after the real chat package.json
|
|
6
|
+
const scripts: Record<string, string> = {
|
|
7
|
+
dev: 'bun tko run-all --flags=last backend:delayed agent-gateway frontend',
|
|
8
|
+
'backend:delayed': 'sleep 5 && bun backend',
|
|
9
|
+
backend: 'bun tko run backend:up backend:migrate-then-zero jobs',
|
|
10
|
+
'backend:up': 'bun env:dev docker compose up pgdb minio',
|
|
11
|
+
'backend:migrate-then-zero':
|
|
12
|
+
'bun backend:migrate && bun env:dev docker compose up zero',
|
|
13
|
+
'backend:migrate': 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/migrate.ts',
|
|
14
|
+
frontend: 'tko run-all --flags=last dev:kill-orphans watch-lazy dev:tunnel one:dev',
|
|
15
|
+
'dev:kill-orphans': 'tko dev kill-orphans',
|
|
16
|
+
'watch-lazy': 'sleep 8 && bun watch',
|
|
17
|
+
watch: 'tko run types:watch build:watch apps:watch',
|
|
18
|
+
'types:watch': 'bun check types --watch',
|
|
19
|
+
'build:watch': 'tko run --no-root watch',
|
|
20
|
+
'apps:watch': 'tko apps build -- --watch',
|
|
21
|
+
'dev:tunnel': 'tko dev tunnel --setup',
|
|
22
|
+
'one:dev': 'bun clear-ports --only web && bun run:dev one dev --port 8081',
|
|
23
|
+
'agent-gateway': 'tko dev agent-gateway',
|
|
24
|
+
jobs: 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/jobs.ts',
|
|
25
|
+
lite: 'bun run:dev tko run-all --pty --flags=last watch-lazy lite:backend one:dev agent-gateway',
|
|
26
|
+
'lite:backend': 'bun run:dev orez --data-dir=.orez',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('flattenScripts', () => {
|
|
30
|
+
it('passes through leaf commands that are not tko run', () => {
|
|
31
|
+
const result = flattenScripts(['jobs'], scripts)
|
|
32
|
+
expect(result).toEqual([
|
|
33
|
+
{
|
|
34
|
+
name: 'jobs',
|
|
35
|
+
command: 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/jobs.ts',
|
|
36
|
+
isRaw: false,
|
|
37
|
+
},
|
|
38
|
+
])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('passes through shell compounds as leaves', () => {
|
|
42
|
+
const result = flattenScripts(['backend:delayed'], scripts)
|
|
43
|
+
expect(result).toEqual([
|
|
44
|
+
{ name: 'backend:delayed', command: 'sleep 5 && bun backend', isRaw: false },
|
|
45
|
+
])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('resolves bun tko run into child scripts', () => {
|
|
49
|
+
const result = flattenScripts(['backend'], scripts)
|
|
50
|
+
expect(result).toEqual([
|
|
51
|
+
{
|
|
52
|
+
name: 'backend:up',
|
|
53
|
+
command: 'bun env:dev docker compose up pgdb minio',
|
|
54
|
+
isRaw: false,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'backend:migrate-then-zero',
|
|
58
|
+
command: 'bun backend:migrate && bun env:dev docker compose up zero',
|
|
59
|
+
isRaw: false,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'jobs',
|
|
63
|
+
command: 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/jobs.ts',
|
|
64
|
+
isRaw: false,
|
|
65
|
+
},
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('resolves tko run-all (without bun prefix)', () => {
|
|
70
|
+
const result = flattenScripts(['frontend'], scripts)
|
|
71
|
+
expect(result).toEqual([
|
|
72
|
+
{ name: 'dev:kill-orphans', command: 'tko dev kill-orphans', isRaw: false },
|
|
73
|
+
{ name: 'watch-lazy', command: 'sleep 8 && bun watch', isRaw: false },
|
|
74
|
+
{ name: 'dev:tunnel', command: 'tko dev tunnel --setup', isRaw: false },
|
|
75
|
+
{
|
|
76
|
+
name: 'one:dev',
|
|
77
|
+
command: 'bun clear-ports --only web && bun run:dev one dev --port 8081',
|
|
78
|
+
isRaw: false,
|
|
79
|
+
},
|
|
80
|
+
])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('resolves nested tko run chains', () => {
|
|
84
|
+
// watch -> tko run types:watch build:watch apps:watch
|
|
85
|
+
// build:watch -> tko run --no-root watch -> watch is visited (cycle), emits as leaf
|
|
86
|
+
const result = flattenScripts(['watch'], scripts)
|
|
87
|
+
expect(result).toEqual([
|
|
88
|
+
{ name: 'types:watch', command: 'bun check types --watch', isRaw: false },
|
|
89
|
+
{
|
|
90
|
+
name: 'watch',
|
|
91
|
+
command: 'tko run types:watch build:watch apps:watch',
|
|
92
|
+
isRaw: false,
|
|
93
|
+
},
|
|
94
|
+
{ name: 'apps:watch', command: 'tko apps build -- --watch', isRaw: false },
|
|
95
|
+
])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('handles multiple input commands', () => {
|
|
99
|
+
const result = flattenScripts(['jobs', 'agent-gateway'], scripts)
|
|
100
|
+
expect(result).toEqual([
|
|
101
|
+
{
|
|
102
|
+
name: 'jobs',
|
|
103
|
+
command: 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/jobs.ts',
|
|
104
|
+
isRaw: false,
|
|
105
|
+
},
|
|
106
|
+
{ name: 'agent-gateway', command: 'tko dev agent-gateway', isRaw: false },
|
|
107
|
+
])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('handles scripts not found in package.json as raw commands', () => {
|
|
111
|
+
const result = flattenScripts(['nonexistent'], scripts)
|
|
112
|
+
expect(result).toEqual([{ name: 'nonexistent', command: 'nonexistent', isRaw: true }])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('detects cycles and stops recursion', () => {
|
|
116
|
+
const cyclicScripts: Record<string, string> = {
|
|
117
|
+
a: 'tko run b',
|
|
118
|
+
b: 'tko run a',
|
|
119
|
+
}
|
|
120
|
+
const result = flattenScripts(['a'], cyclicScripts)
|
|
121
|
+
// a -> b -> a (cycle). when 'a' is encountered again, it's emitted as a leaf
|
|
122
|
+
expect(result).toEqual([{ name: 'a', command: 'tko run b', isRaw: false }])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('strips --flags=last, --no-root, --pty from tko commands when extracting children', () => {
|
|
126
|
+
// verify flags like --flags=last don't appear as script names
|
|
127
|
+
const simpleScripts: Record<string, string> = {
|
|
128
|
+
parent: 'bun tko run-all --flags=last --no-root --pty child1 child2',
|
|
129
|
+
child1: 'echo one',
|
|
130
|
+
child2: 'echo two',
|
|
131
|
+
}
|
|
132
|
+
const result = flattenScripts(['parent'], simpleScripts)
|
|
133
|
+
expect(result).toEqual([
|
|
134
|
+
{ name: 'child1', command: 'echo one', isRaw: false },
|
|
135
|
+
{ name: 'child2', command: 'echo two', isRaw: false },
|
|
136
|
+
])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('filters out scripts listed in BUN_RUN_SCRIPTS', () => {
|
|
140
|
+
const result = flattenScripts(['backend'], scripts, {
|
|
141
|
+
runningScripts: ['backend:up'],
|
|
142
|
+
})
|
|
143
|
+
expect(result).toEqual([
|
|
144
|
+
{
|
|
145
|
+
name: 'backend:migrate-then-zero',
|
|
146
|
+
command: 'bun backend:migrate && bun env:dev docker compose up zero',
|
|
147
|
+
isRaw: false,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'jobs',
|
|
151
|
+
command: 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/jobs.ts',
|
|
152
|
+
isRaw: false,
|
|
153
|
+
},
|
|
154
|
+
])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('handles tko run-all with --pty flag (lite script)', () => {
|
|
158
|
+
// lite = bun run:dev tko run-all --pty --flags=last watch-lazy lite:backend one:dev agent-gateway
|
|
159
|
+
// the "bun run:dev" prefix before tko means this is NOT a tko command at the top level
|
|
160
|
+
// it contains && or is a raw command - actually "bun run:dev tko run-all ..." IS a tko pattern
|
|
161
|
+
// but wrapped in "bun run:dev" so it should be treated as a leaf
|
|
162
|
+
const result = flattenScripts(['lite'], scripts)
|
|
163
|
+
expect(result).toEqual([
|
|
164
|
+
{
|
|
165
|
+
name: 'lite',
|
|
166
|
+
command:
|
|
167
|
+
'bun run:dev tko run-all --pty --flags=last watch-lazy lite:backend one:dev agent-gateway',
|
|
168
|
+
isRaw: false,
|
|
169
|
+
},
|
|
170
|
+
])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('deduplicates scripts that appear multiple times', () => {
|
|
174
|
+
const dupeScripts: Record<string, string> = {
|
|
175
|
+
a: 'tko run shared c',
|
|
176
|
+
b: 'tko run shared d',
|
|
177
|
+
shared: 'echo shared',
|
|
178
|
+
c: 'echo c',
|
|
179
|
+
d: 'echo d',
|
|
180
|
+
}
|
|
181
|
+
// both a and b reference "shared", if we flatten [a, b] we should not duplicate shared
|
|
182
|
+
const result = flattenScripts(['a', 'b'], dupeScripts)
|
|
183
|
+
const names = result.map((r) => r.name)
|
|
184
|
+
// shared appears from a's resolution, then from b's resolution it should be skipped
|
|
185
|
+
expect(names).toEqual(['shared', 'c', 'shared', 'd'])
|
|
186
|
+
})
|
|
187
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export interface FlattenedScript {
|
|
2
|
+
name: string
|
|
3
|
+
command: string
|
|
4
|
+
/** true when the script name doesn't exist in package.json */
|
|
5
|
+
isRaw: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface FlattenOptions {
|
|
9
|
+
/** scripts already running (from BUN_RUN_SCRIPTS), will be filtered out */
|
|
10
|
+
runningScripts?: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// matches: "bun tko run ...", "bun tko run-all ...", "tko run ...", "tko run-all ..."
|
|
14
|
+
// but NOT "bun run:dev tko run-all ..." (has extra words between bun and tko)
|
|
15
|
+
const TKO_RUN_RE = /^(?:bun\s+)?tko\s+(run(?:-all)?)\s+(.+)$/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* parse a tko run/run-all command string, extracting script names and flags
|
|
19
|
+
* returns null if the command is not a tko run/run-all pattern
|
|
20
|
+
*/
|
|
21
|
+
function parseTkoRun(command: string): { scriptNames: string[] } | null {
|
|
22
|
+
const match = command.match(TKO_RUN_RE)
|
|
23
|
+
if (!match) return null
|
|
24
|
+
|
|
25
|
+
const argsPart = match[2]!
|
|
26
|
+
const args = argsPart.split(/\s+/)
|
|
27
|
+
const scriptNames: string[] = []
|
|
28
|
+
|
|
29
|
+
for (const arg of args) {
|
|
30
|
+
if (arg.startsWith('--')) {
|
|
31
|
+
// skip known flags
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
scriptNames.push(arg)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { scriptNames }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* recursively flatten script references into leaf commands.
|
|
42
|
+
* a "leaf" is anything that is NOT a tko run/run-all command,
|
|
43
|
+
* or a script whose definition contains shell operators (&&, ;, ||).
|
|
44
|
+
*/
|
|
45
|
+
export function flattenScripts(
|
|
46
|
+
commands: string[],
|
|
47
|
+
packageJsonScripts: Record<string, string>,
|
|
48
|
+
options?: FlattenOptions
|
|
49
|
+
): FlattenedScript[] {
|
|
50
|
+
const runningScripts = new Set(options?.runningScripts ?? [])
|
|
51
|
+
const results: FlattenedScript[] = []
|
|
52
|
+
|
|
53
|
+
function resolve(name: string, visited: Set<string>) {
|
|
54
|
+
if (runningScripts.has(name)) return
|
|
55
|
+
|
|
56
|
+
const scriptCommand = packageJsonScripts[name]
|
|
57
|
+
|
|
58
|
+
// script doesn't exist in package.json - treat as raw command
|
|
59
|
+
if (scriptCommand === undefined) {
|
|
60
|
+
results.push({ name, command: name, isRaw: true })
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// cycle detected - emit as leaf to avoid infinite recursion
|
|
65
|
+
if (visited.has(name)) {
|
|
66
|
+
results.push({ name, command: scriptCommand, isRaw: false })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// try to parse as tko run/run-all
|
|
71
|
+
const parsed = parseTkoRun(scriptCommand)
|
|
72
|
+
|
|
73
|
+
if (parsed) {
|
|
74
|
+
// recurse into child scripts
|
|
75
|
+
visited.add(name)
|
|
76
|
+
for (const childName of parsed.scriptNames) {
|
|
77
|
+
resolve(childName, visited)
|
|
78
|
+
}
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// leaf command - not a tko run/run-all pattern
|
|
83
|
+
results.push({ name, command: scriptCommand, isRaw: false })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const cmd of commands) {
|
|
87
|
+
resolve(cmd, new Set())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return results
|
|
91
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
7
|
+
|
|
8
|
+
function isAlive(pid: number): boolean {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0)
|
|
11
|
+
return true
|
|
12
|
+
} catch {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function waitForCondition(
|
|
18
|
+
fn: () => boolean,
|
|
19
|
+
timeoutMs = 5000,
|
|
20
|
+
intervalMs = 50
|
|
21
|
+
): Promise<boolean> {
|
|
22
|
+
const start = Date.now()
|
|
23
|
+
while (Date.now() - start < timeoutMs) {
|
|
24
|
+
if (fn()) return true
|
|
25
|
+
await new Promise((r) => setTimeout(r, intervalMs))
|
|
26
|
+
}
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function waitForFile(path: string, timeoutMs = 5000): Promise<string> {
|
|
31
|
+
const start = Date.now()
|
|
32
|
+
while (Date.now() - start < timeoutMs) {
|
|
33
|
+
if (existsSync(path)) {
|
|
34
|
+
const content = readFileSync(path, 'utf-8').trim()
|
|
35
|
+
if (content) return content
|
|
36
|
+
}
|
|
37
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`file never appeared: ${path}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'proc-cleanup-'))
|
|
43
|
+
const pidsToCleanup: number[] = []
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
for (const pid of pidsToCleanup) {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 'SIGKILL')
|
|
49
|
+
} catch {}
|
|
50
|
+
try {
|
|
51
|
+
process.kill(-pid, 'SIGKILL')
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
pidsToCleanup.length = 0
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
function makeChildScript(pidFile: string, opts?: { grandchild?: boolean }): string {
|
|
58
|
+
const path = join(
|
|
59
|
+
tmpDir,
|
|
60
|
+
`child-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`
|
|
61
|
+
)
|
|
62
|
+
if (opts?.grandchild) {
|
|
63
|
+
const gcPidFile = pidFile.replace('.pid', '-gc.pid')
|
|
64
|
+
// use a separate script for grandchild to avoid escaping issues
|
|
65
|
+
const gcScript = join(
|
|
66
|
+
tmpDir,
|
|
67
|
+
`gc-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`
|
|
68
|
+
)
|
|
69
|
+
writeFileSync(gcScript, `#!/bin/bash\necho $$ > ${gcPidFile}\nsleep 999\n`, {
|
|
70
|
+
mode: 0o755,
|
|
71
|
+
})
|
|
72
|
+
writeFileSync(
|
|
73
|
+
path,
|
|
74
|
+
`#!/bin/bash\necho $$ > ${pidFile}\nbash ${gcScript} &\nsleep 999\n`,
|
|
75
|
+
{
|
|
76
|
+
mode: 0o755,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
} else {
|
|
80
|
+
writeFileSync(path, `#!/bin/bash\necho $$ > ${pidFile}\nsleep 999\n`, { mode: 0o755 })
|
|
81
|
+
}
|
|
82
|
+
return path
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handleProcessExitPath = join(__dirname, 'helpers', 'handleProcessExit.ts')
|
|
86
|
+
|
|
87
|
+
// creates a parent node script that uses real handleProcessExit
|
|
88
|
+
function createParentScript(opts: {
|
|
89
|
+
childScripts: string[]
|
|
90
|
+
parentPidFile: string
|
|
91
|
+
}): string {
|
|
92
|
+
const scriptPath = join(
|
|
93
|
+
tmpDir,
|
|
94
|
+
`parent-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`
|
|
95
|
+
)
|
|
96
|
+
writeFileSync(
|
|
97
|
+
scriptPath,
|
|
98
|
+
`
|
|
99
|
+
import { handleProcessExit } from '${handleProcessExitPath}'
|
|
100
|
+
import { spawn } from 'node:child_process'
|
|
101
|
+
import { writeFileSync } from 'node:fs'
|
|
102
|
+
|
|
103
|
+
const { addChildProcess } = handleProcessExit()
|
|
104
|
+
|
|
105
|
+
writeFileSync(${JSON.stringify(opts.parentPidFile)}, String(process.pid))
|
|
106
|
+
|
|
107
|
+
const scripts = ${JSON.stringify(opts.childScripts)}
|
|
108
|
+
for (const script of scripts) {
|
|
109
|
+
const child = spawn('bash', [script], { detached: true, stdio: 'ignore' })
|
|
110
|
+
child.unref()
|
|
111
|
+
addChildProcess(child)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setInterval(() => {}, 60000)
|
|
115
|
+
`
|
|
116
|
+
)
|
|
117
|
+
return scriptPath
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function spawnParent(scriptPath: string) {
|
|
121
|
+
const child = spawn('bun', [scriptPath], {
|
|
122
|
+
detached: true,
|
|
123
|
+
stdio: 'ignore',
|
|
124
|
+
})
|
|
125
|
+
child.unref()
|
|
126
|
+
pidsToCleanup.push(child.pid!)
|
|
127
|
+
return child
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe('process cleanup', { timeout: 15_000 }, () => {
|
|
131
|
+
test('SIGTERM kills all children', async () => {
|
|
132
|
+
const pidFiles = [join(tmpDir, 'a1.pid'), join(tmpDir, 'a2.pid')]
|
|
133
|
+
const parentPidFile = join(tmpDir, 'p-term.pid')
|
|
134
|
+
const childScripts = pidFiles.map((f) => makeChildScript(f))
|
|
135
|
+
const script = createParentScript({ childScripts, parentPidFile })
|
|
136
|
+
|
|
137
|
+
spawnParent(script)
|
|
138
|
+
|
|
139
|
+
const parentPid = parseInt(await waitForFile(parentPidFile))
|
|
140
|
+
pidsToCleanup.push(parentPid)
|
|
141
|
+
const childPids = await Promise.all(pidFiles.map((f) => waitForFile(f).then(Number)))
|
|
142
|
+
childPids.forEach((p) => pidsToCleanup.push(p))
|
|
143
|
+
|
|
144
|
+
expect(isAlive(parentPid)).toBe(true)
|
|
145
|
+
childPids.forEach((pid) => expect(isAlive(pid)).toBe(true))
|
|
146
|
+
|
|
147
|
+
process.kill(parentPid, 'SIGTERM')
|
|
148
|
+
|
|
149
|
+
const allDead = await waitForCondition(
|
|
150
|
+
() => !isAlive(parentPid) && childPids.every((p) => !isAlive(p)),
|
|
151
|
+
5000
|
|
152
|
+
)
|
|
153
|
+
expect(allDead).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('SIGINT kills all children', async () => {
|
|
157
|
+
const pidFiles = [join(tmpDir, 'b1.pid')]
|
|
158
|
+
const parentPidFile = join(tmpDir, 'p-int.pid')
|
|
159
|
+
const childScripts = pidFiles.map((f) => makeChildScript(f))
|
|
160
|
+
const script = createParentScript({ childScripts, parentPidFile })
|
|
161
|
+
|
|
162
|
+
spawnParent(script)
|
|
163
|
+
|
|
164
|
+
const parentPid = parseInt(await waitForFile(parentPidFile))
|
|
165
|
+
pidsToCleanup.push(parentPid)
|
|
166
|
+
const childPid = parseInt(await waitForFile(pidFiles[0]!))
|
|
167
|
+
pidsToCleanup.push(childPid)
|
|
168
|
+
|
|
169
|
+
process.kill(parentPid, 'SIGINT')
|
|
170
|
+
|
|
171
|
+
const allDead = await waitForCondition(
|
|
172
|
+
() => !isAlive(parentPid) && !isAlive(childPid),
|
|
173
|
+
5000
|
|
174
|
+
)
|
|
175
|
+
expect(allDead).toBe(true)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('SIGHUP kills all children', async () => {
|
|
179
|
+
const pidFiles = [join(tmpDir, 'c1.pid')]
|
|
180
|
+
const parentPidFile = join(tmpDir, 'p-hup.pid')
|
|
181
|
+
const childScripts = pidFiles.map((f) => makeChildScript(f))
|
|
182
|
+
const script = createParentScript({ childScripts, parentPidFile })
|
|
183
|
+
|
|
184
|
+
spawnParent(script)
|
|
185
|
+
|
|
186
|
+
const parentPid = parseInt(await waitForFile(parentPidFile))
|
|
187
|
+
pidsToCleanup.push(parentPid)
|
|
188
|
+
const childPid = parseInt(await waitForFile(pidFiles[0]!))
|
|
189
|
+
pidsToCleanup.push(childPid)
|
|
190
|
+
|
|
191
|
+
process.kill(parentPid, 'SIGHUP')
|
|
192
|
+
|
|
193
|
+
const allDead = await waitForCondition(
|
|
194
|
+
() => !isAlive(parentPid) && !isAlive(childPid),
|
|
195
|
+
5000
|
|
196
|
+
)
|
|
197
|
+
expect(allDead).toBe(true)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('process group kill reaches grandchildren', async () => {
|
|
201
|
+
const pidFiles = [join(tmpDir, 'd1.pid')]
|
|
202
|
+
const parentPidFile = join(tmpDir, 'p-gc.pid')
|
|
203
|
+
const grandchildPidFile = join(tmpDir, 'd1-gc.pid')
|
|
204
|
+
const childScripts = pidFiles.map((f) => makeChildScript(f, { grandchild: true }))
|
|
205
|
+
const script = createParentScript({ childScripts, parentPidFile })
|
|
206
|
+
|
|
207
|
+
spawnParent(script)
|
|
208
|
+
|
|
209
|
+
const parentPid = parseInt(await waitForFile(parentPidFile))
|
|
210
|
+
pidsToCleanup.push(parentPid)
|
|
211
|
+
const childPid = parseInt(await waitForFile(pidFiles[0]!))
|
|
212
|
+
pidsToCleanup.push(childPid)
|
|
213
|
+
const gcPid = parseInt(await waitForFile(grandchildPidFile))
|
|
214
|
+
pidsToCleanup.push(gcPid)
|
|
215
|
+
|
|
216
|
+
expect(isAlive(childPid)).toBe(true)
|
|
217
|
+
expect(isAlive(gcPid)).toBe(true)
|
|
218
|
+
|
|
219
|
+
process.kill(parentPid, 'SIGTERM')
|
|
220
|
+
|
|
221
|
+
const allDead = await waitForCondition(
|
|
222
|
+
() => !isAlive(childPid) && !isAlive(gcPid),
|
|
223
|
+
5000
|
|
224
|
+
)
|
|
225
|
+
expect(allDead).toBe(true)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('pid file is created and cleaned up', async () => {
|
|
229
|
+
const pidFiles = [join(tmpDir, 'e1.pid')]
|
|
230
|
+
const parentPidFile = join(tmpDir, 'p-pidfile.pid')
|
|
231
|
+
const childScripts = pidFiles.map((f) => makeChildScript(f))
|
|
232
|
+
const script = createParentScript({ childScripts, parentPidFile })
|
|
233
|
+
|
|
234
|
+
spawnParent(script)
|
|
235
|
+
|
|
236
|
+
const parentPid = parseInt(await waitForFile(parentPidFile))
|
|
237
|
+
pidsToCleanup.push(parentPid)
|
|
238
|
+
const childPid = parseInt(await waitForFile(pidFiles[0]!))
|
|
239
|
+
pidsToCleanup.push(childPid)
|
|
240
|
+
|
|
241
|
+
// pid file should exist while parent is running
|
|
242
|
+
const tkoFile = `/tmp/tko-run-${parentPid}.pids`
|
|
243
|
+
const pidFileExists = await waitForCondition(() => existsSync(tkoFile), 3000)
|
|
244
|
+
expect(pidFileExists).toBe(true)
|
|
245
|
+
|
|
246
|
+
const contents = readFileSync(tkoFile, 'utf-8').trim()
|
|
247
|
+
expect(contents).toContain(String(childPid))
|
|
248
|
+
|
|
249
|
+
// send SIGTERM and verify pid file is cleaned up
|
|
250
|
+
process.kill(parentPid, 'SIGTERM')
|
|
251
|
+
|
|
252
|
+
const cleaned = await waitForCondition(() => !existsSync(tkoFile), 5000)
|
|
253
|
+
expect(cleaned).toBe(true)
|
|
254
|
+
})
|
|
255
|
+
})
|
package/src/run.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { join, relative, resolve } from 'node:path'
|
|
|
10
10
|
|
|
11
11
|
import { handleProcessExit } from '@take-out/scripts/helpers/handleProcessExit'
|
|
12
12
|
|
|
13
|
+
import { flattenScripts } from './helpers/resolveScript'
|
|
13
14
|
import { getIsExiting } from './helpers/run'
|
|
14
15
|
import { checkNodeVersion } from './node-version-check'
|
|
15
16
|
|
|
@@ -28,38 +29,6 @@ const reset = '\x1b[0m'
|
|
|
28
29
|
// eslint-disable-next-line no-control-regex
|
|
29
30
|
const ansiPattern = /\x1b\[[0-9;]*m/g
|
|
30
31
|
|
|
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
32
|
interface ManagedProcess {
|
|
64
33
|
proc: ReturnType<typeof spawn>
|
|
65
34
|
name: string
|
|
@@ -70,22 +39,6 @@ interface ManagedProcess {
|
|
|
70
39
|
shortcut: string
|
|
71
40
|
}
|
|
72
41
|
|
|
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
42
|
async function readPackageJson(directoryPath: string) {
|
|
90
43
|
try {
|
|
91
44
|
const packageJsonPath = join(directoryPath, 'package.json')
|
|
@@ -216,97 +169,7 @@ async function mapWorkspacesToScripts(
|
|
|
216
169
|
return workspaceScriptMap
|
|
217
170
|
}
|
|
218
171
|
|
|
219
|
-
|
|
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
|
-
env: {
|
|
239
|
-
...process.env,
|
|
240
|
-
FORCE_COLOR: '3',
|
|
241
|
-
BUN_RUN_PARENT_SCRIPT: name,
|
|
242
|
-
BUN_RUN_SCRIPTS: allRunningScripts,
|
|
243
|
-
TKO_SILENT: '1',
|
|
244
|
-
} as any,
|
|
245
|
-
cwd: resolve(cwd),
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
const managed: ManagedProcess = {
|
|
249
|
-
proc,
|
|
250
|
-
name,
|
|
251
|
-
cwd,
|
|
252
|
-
prefixLabel,
|
|
253
|
-
extraArgs,
|
|
254
|
-
index,
|
|
255
|
-
shortcut: '',
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (managedIndex !== undefined) {
|
|
259
|
-
managedProcesses[managedIndex] = managed
|
|
260
|
-
} else {
|
|
261
|
-
managedProcesses.push(managed)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
addChildProcess(proc)
|
|
265
|
-
|
|
266
|
-
proc.stdout!.on('data', (data) => {
|
|
267
|
-
if (getIsExiting()) return
|
|
268
|
-
const lines = data.toString().split('\n')
|
|
269
|
-
for (const line of lines) {
|
|
270
|
-
const stripped = line.replace(ansiPattern, '')
|
|
271
|
-
if (stripped.startsWith('$ ')) continue
|
|
272
|
-
if (line) console.info(`${getPrefix(index)} ${line}`)
|
|
273
|
-
}
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
proc.stderr!.on('data', (data) => {
|
|
277
|
-
if (getIsExiting()) return
|
|
278
|
-
const lines = data.toString().split('\n')
|
|
279
|
-
for (const line of lines) {
|
|
280
|
-
const stripped = line.replace(ansiPattern, '')
|
|
281
|
-
if (stripped.startsWith('$ ')) continue
|
|
282
|
-
if (line) console.error(`${getPrefix(index)} ${line}`)
|
|
283
|
-
}
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
proc.on('error', (error) => {
|
|
287
|
-
console.error(`${getPrefix(index)} Failed to start: ${error.message}`)
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
proc.on('close', (code) => {
|
|
291
|
-
if (getIsExiting()) return
|
|
292
|
-
|
|
293
|
-
if (code && code !== 0) {
|
|
294
|
-
console.error(`${getPrefix(index)} Process exited with code ${code}`)
|
|
295
|
-
|
|
296
|
-
if (watch && restarts < MAX_RESTARTS) {
|
|
297
|
-
const newRestarts = restarts + 1
|
|
298
|
-
console.info(`Restarting process ${name} (${newRestarts}/${MAX_RESTARTS} times)`)
|
|
299
|
-
runScript(name, cwd, prefixLabel, newRestarts, extraArgs, index)
|
|
300
|
-
} else {
|
|
301
|
-
exit(1)
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
})
|
|
305
|
-
|
|
306
|
-
return proc
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function computeShortcuts() {
|
|
172
|
+
function computeShortcuts(managedProcesses: ManagedProcess[]) {
|
|
310
173
|
const initials = managedProcesses.map((p) => {
|
|
311
174
|
const words = p.prefixLabel
|
|
312
175
|
.toLowerCase()
|
|
@@ -350,28 +213,216 @@ function computeShortcuts() {
|
|
|
350
213
|
}
|
|
351
214
|
}
|
|
352
215
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
216
|
+
// --- exported interface for CLI direct-import ---
|
|
217
|
+
|
|
218
|
+
export interface RunParallelScriptsOptions {
|
|
219
|
+
commands: string[]
|
|
220
|
+
noRoot?: boolean
|
|
221
|
+
flagsLast?: boolean
|
|
222
|
+
runBun?: boolean
|
|
223
|
+
watch?: boolean
|
|
224
|
+
forwardArgs?: string[]
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function runParallelScripts(
|
|
228
|
+
options: RunParallelScriptsOptions
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const {
|
|
231
|
+
commands: runCommands,
|
|
232
|
+
noRoot = false,
|
|
233
|
+
flagsLast = false,
|
|
234
|
+
runBun = false,
|
|
235
|
+
watch = false,
|
|
236
|
+
forwardArgs = [],
|
|
237
|
+
} = options
|
|
238
|
+
|
|
239
|
+
const MAX_RESTARTS = 3
|
|
240
|
+
|
|
241
|
+
const parentRunningScripts = process.env.BUN_RUN_SCRIPTS
|
|
242
|
+
? process.env.BUN_RUN_SCRIPTS.split(',')
|
|
243
|
+
: []
|
|
244
|
+
|
|
245
|
+
const managedProcesses: ManagedProcess[] = []
|
|
246
|
+
const { addChildProcess, exit } = handleProcessExit()
|
|
247
|
+
|
|
248
|
+
function getPrefix(index: number): string {
|
|
249
|
+
const managed = managedProcesses[index]
|
|
250
|
+
if (!managed) return ''
|
|
251
|
+
const color = colors[index % colors.length]
|
|
252
|
+
const sc = managed.shortcut || String(index + 1)
|
|
253
|
+
return `${color}${sc} ${managed.prefixLabel}${reset}`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (runCommands.length === 0) {
|
|
257
|
+
console.error('Please provide at least one script name to run')
|
|
356
258
|
exit(1)
|
|
357
|
-
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// spawns a single script process, optionally running a command directly via bash
|
|
263
|
+
const runScript = async (
|
|
264
|
+
name: string,
|
|
265
|
+
cwd = '.',
|
|
266
|
+
prefixLabel: string = name,
|
|
267
|
+
restarts = 0,
|
|
268
|
+
extraArgs: string[] = [],
|
|
269
|
+
managedIndex?: number,
|
|
270
|
+
directCommand?: string
|
|
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
|
+
const managed: ManagedProcess = {
|
|
320
|
+
proc,
|
|
321
|
+
name,
|
|
322
|
+
cwd,
|
|
323
|
+
prefixLabel,
|
|
324
|
+
extraArgs,
|
|
325
|
+
index,
|
|
326
|
+
shortcut: '',
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (managedIndex !== undefined) {
|
|
330
|
+
managedProcesses[managedIndex] = managed
|
|
331
|
+
} else {
|
|
332
|
+
managedProcesses.push(managed)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
addChildProcess(proc)
|
|
336
|
+
|
|
337
|
+
proc.stdout!.on('data', (data) => {
|
|
338
|
+
if (getIsExiting()) return
|
|
339
|
+
const lines = data.toString().split('\n')
|
|
340
|
+
for (const line of lines) {
|
|
341
|
+
const stripped = line.replace(ansiPattern, '')
|
|
342
|
+
if (stripped.startsWith('$ ')) continue
|
|
343
|
+
if (line) console.info(`${getPrefix(index)} ${line}`)
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
proc.stderr!.on('data', (data) => {
|
|
348
|
+
if (getIsExiting()) return
|
|
349
|
+
const lines = data.toString().split('\n')
|
|
350
|
+
for (const line of lines) {
|
|
351
|
+
const stripped = line.replace(ansiPattern, '')
|
|
352
|
+
if (stripped.startsWith('$ ')) continue
|
|
353
|
+
if (line) console.error(`${getPrefix(index)} ${line}`)
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
proc.on('error', (error) => {
|
|
358
|
+
console.error(`${getPrefix(index)} Failed to start: ${error.message}`)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
proc.on('close', (code) => {
|
|
362
|
+
if (getIsExiting()) return
|
|
363
|
+
|
|
364
|
+
if (code && code !== 0) {
|
|
365
|
+
console.error(`${getPrefix(index)} Process exited with code ${code}`)
|
|
366
|
+
|
|
367
|
+
if (watch && restarts < MAX_RESTARTS) {
|
|
368
|
+
const newRestarts = restarts + 1
|
|
369
|
+
console.info(
|
|
370
|
+
`Restarting process ${name} (${newRestarts}/${MAX_RESTARTS} times)`
|
|
371
|
+
)
|
|
372
|
+
runScript(name, cwd, prefixLabel, newRestarts, extraArgs, index, directCommand)
|
|
373
|
+
} else {
|
|
374
|
+
exit(1)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
return proc
|
|
380
|
+
}
|
|
358
381
|
|
|
359
382
|
try {
|
|
383
|
+
checkNodeVersion().catch((err) => {
|
|
384
|
+
console.error(err.message)
|
|
385
|
+
exit(1)
|
|
386
|
+
})
|
|
387
|
+
|
|
360
388
|
if (runCommands.length > 0) {
|
|
361
389
|
const lastScript = runCommands[runCommands.length - 1]
|
|
362
390
|
|
|
363
391
|
if (!noRoot) {
|
|
392
|
+
// read package.json scripts for flattening
|
|
393
|
+
const packageJson = await readPackageJson('.')
|
|
394
|
+
const pkgScripts = packageJson?.scripts ?? {}
|
|
395
|
+
|
|
364
396
|
const filteredCommands = runCommands.filter(
|
|
365
397
|
(name) => !parentRunningScripts.includes(name)
|
|
366
398
|
)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
399
|
+
|
|
400
|
+
// flatten tko run/run-all chains into leaf commands
|
|
401
|
+
const flattened = flattenScripts(filteredCommands, pkgScripts, {
|
|
402
|
+
runningScripts: parentRunningScripts,
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
const lastLeaf = flattened[flattened.length - 1]
|
|
406
|
+
const scriptPromises = flattened.map((leaf) => {
|
|
407
|
+
const scriptArgs = !flagsLast || leaf === lastLeaf ? forwardArgs : []
|
|
408
|
+
// known scripts: run command directly via bash (skip bun run layer)
|
|
409
|
+
// unknown scripts: fall through to bun run --silent
|
|
410
|
+
const directCommand = !leaf.isRaw ? leaf.command : undefined
|
|
411
|
+
return runScript(
|
|
412
|
+
leaf.name,
|
|
413
|
+
'.',
|
|
414
|
+
leaf.name,
|
|
415
|
+
0,
|
|
416
|
+
scriptArgs,
|
|
417
|
+
undefined,
|
|
418
|
+
directCommand
|
|
419
|
+
)
|
|
370
420
|
})
|
|
371
421
|
|
|
372
422
|
await Promise.all(scriptPromises)
|
|
373
423
|
}
|
|
374
424
|
|
|
425
|
+
// workspace scripts still use bun run (they need workspace cwd)
|
|
375
426
|
const workspaceScriptMap = await mapWorkspacesToScripts(runCommands)
|
|
376
427
|
|
|
377
428
|
for (const [workspace, { scripts, packageName }] of workspaceScriptMap.entries()) {
|
|
@@ -396,7 +447,7 @@ async function main() {
|
|
|
396
447
|
if (managedProcesses.length === 0) {
|
|
397
448
|
exit(0)
|
|
398
449
|
} else {
|
|
399
|
-
computeShortcuts()
|
|
450
|
+
computeShortcuts(managedProcesses)
|
|
400
451
|
}
|
|
401
452
|
} catch (error) {
|
|
402
453
|
console.error(`Error running scripts: ${error}`)
|
|
@@ -404,7 +455,43 @@ async function main() {
|
|
|
404
455
|
}
|
|
405
456
|
}
|
|
406
457
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
458
|
+
// --- CLI entry point (only runs when executed directly, not when imported) ---
|
|
459
|
+
|
|
460
|
+
const ownFlags = ['--no-root', '--bun', '--watch', '--flags=last']
|
|
461
|
+
|
|
462
|
+
export function parseRunArgs(args: string[]): RunParallelScriptsOptions {
|
|
463
|
+
const commands: string[] = []
|
|
464
|
+
const forwardArgs: string[] = []
|
|
465
|
+
|
|
466
|
+
for (let i = 0; i < args.length; i++) {
|
|
467
|
+
const arg = args[i]!
|
|
468
|
+
|
|
469
|
+
if (arg.startsWith('--')) {
|
|
470
|
+
if (ownFlags.includes(arg)) continue
|
|
471
|
+
forwardArgs.push(arg)
|
|
472
|
+
const nextArg = args[i + 1]
|
|
473
|
+
if (nextArg && !nextArg.startsWith('--')) {
|
|
474
|
+
forwardArgs.push(nextArg)
|
|
475
|
+
i++
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
commands.push(arg)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
commands,
|
|
484
|
+
noRoot: args.includes('--no-root'),
|
|
485
|
+
runBun: args.includes('--bun'),
|
|
486
|
+
watch: args.includes('--watch'),
|
|
487
|
+
flagsLast: args.includes('--flags=last'),
|
|
488
|
+
forwardArgs,
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (typeof Bun !== 'undefined' && Bun.main === import.meta.path) {
|
|
493
|
+
runParallelScripts(parseRunArgs(process.argv.slice(2))).catch((error) => {
|
|
494
|
+
console.error(`Error running scripts: ${error}`)
|
|
495
|
+
process.exit(1)
|
|
496
|
+
})
|
|
497
|
+
}
|