@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@take-out/scripts",
3
- "version": "0.1.39-1772673702585",
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-1772673702585",
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
- 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
- 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
- async function main() {
354
- checkNodeVersion().catch((err) => {
355
- console.error(err.message)
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
- const scriptPromises = filteredCommands.map((name) => {
368
- const scriptArgs = !flagsLast || name === lastScript ? forwardArgs : []
369
- return runScript(name, '.', name, 0, scriptArgs)
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
- main().catch((error) => {
408
- console.error(`Error running scripts: ${error}`)
409
- exit(1)
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
+ }