@take-out/scripts 0.1.39-1772740363029 → 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-1772740363029",
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-1772740363029",
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,237 +0,0 @@
1
- import { execSync } from 'node:child_process'
2
- import { appendFileSync, rmSync } from 'node:fs'
3
-
4
- import {
5
- addProcessHandler,
6
- setExitCleanupState,
7
- type ProcessHandler,
8
- type ProcessType,
9
- } from './run'
10
-
11
- type ExitCallback = (info: { signal: NodeJS.Signals | string }) => void | Promise<void>
12
-
13
- interface HandleProcessExitReturn {
14
- addChildProcess: ProcessHandler
15
- cleanup: () => Promise<void>
16
- exit: (code?: number) => Promise<void>
17
- }
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
71
- function killProcess(
72
- pid: number,
73
- signal: NodeJS.Signals = 'SIGTERM',
74
- forceful: boolean = false
75
- ): void {
76
- // kill process group first (negative pid), then direct process
77
- try {
78
- process.kill(-pid, signal)
79
- } catch (_) {}
80
- try {
81
- process.kill(pid, signal)
82
- } catch (_) {}
83
-
84
- if (forceful && signal !== 'SIGKILL') {
85
- setTimeout(() => {
86
- try {
87
- process.kill(-pid, 'SIGKILL')
88
- } catch (_) {}
89
- try {
90
- process.kill(pid, 'SIGKILL')
91
- } catch (_) {}
92
- }, 100)
93
- }
94
- }
95
-
96
- let isHandling = false
97
-
98
- export function handleProcessExit({
99
- onExit,
100
- }: {
101
- onExit?: ExitCallback
102
- } = {}): HandleProcessExitReturn {
103
- if (isHandling) {
104
- throw new Error(`Only one handleProcessExit per process should be registered!`)
105
- }
106
-
107
- isHandling = true
108
- const processes: ProcessType[] = []
109
- let cleanupPromise: Promise<void> | null = null
110
-
111
- const cleanup = (signal: NodeJS.Signals | string = 'SIGTERM'): Promise<void> => {
112
- // return existing cleanup promise if already running, so process.exit
113
- // override waits for the real cleanup instead of exiting early
114
- if (cleanupPromise) return cleanupPromise
115
-
116
- cleanupPromise = doCleanup(signal)
117
- return cleanupPromise
118
- }
119
-
120
- const doCleanup = async (signal: NodeJS.Signals | string) => {
121
- setExitCleanupState(true)
122
-
123
- if (onExit) {
124
- try {
125
- await onExit({ signal })
126
- } catch (error) {
127
- console.error('Error in exit callback:', error)
128
- }
129
- }
130
-
131
- if (processes.length === 0) {
132
- return
133
- }
134
-
135
- const isInterrupt = signal === 'SIGINT'
136
- const killSignal = isInterrupt ? 'SIGTERM' : (signal as NodeJS.Signals)
137
-
138
- for (const proc of processes) {
139
- if (proc.pid) {
140
- killProcess(proc.pid, killSignal, isInterrupt)
141
- }
142
- }
143
-
144
- // brief wait for graceful shutdown
145
- await new Promise((res) => setTimeout(res, isInterrupt ? 80 : 200))
146
-
147
- // force kill any remaining
148
- for (const proc of processes) {
149
- if (proc.pid && !proc.exitCode) {
150
- killProcess(proc.pid, 'SIGKILL')
151
- }
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()
166
- }
167
-
168
- const addChildProcess = (proc: ProcessType) => {
169
- processes.push(proc)
170
- if (proc.pid) writePidFile(proc.pid)
171
- }
172
-
173
- addProcessHandler(addChildProcess)
174
-
175
- let finalized = false
176
-
177
- const finalizeWithSignal = (signal: NodeJS.Signals, fallbackCode: number) => {
178
- if (finalized) return
179
- finalized = true
180
- process.off('SIGINT', sigintHandler)
181
- process.off('SIGTERM', sigtermHandler)
182
- process.off('SIGHUP', sighupHandler)
183
- process.off('beforeExit', beforeExitHandler)
184
- process.exit = originalExit
185
-
186
- try {
187
- process.kill(process.pid, signal)
188
- } catch {
189
- originalExit(fallbackCode)
190
- }
191
- }
192
-
193
- const sigtermHandler = () => {
194
- cleanup('SIGTERM').then(() => {
195
- finalizeWithSignal('SIGTERM', 143)
196
- })
197
- }
198
-
199
- const sigintHandler = () => {
200
- cleanup('SIGINT').then(() => {
201
- finalizeWithSignal('SIGINT', 130)
202
- })
203
- }
204
-
205
- const sighupHandler = () => {
206
- cleanup('SIGHUP').then(() => {
207
- finalizeWithSignal('SIGHUP', 129)
208
- })
209
- }
210
-
211
- // intercept process.exit to ensure cleanup completes before exiting.
212
- // if cleanup is already running, this awaits the SAME promise instead of
213
- // early-returning and calling originalExit prematurely.
214
- const originalExit = process.exit
215
- process.exit = ((code?: number) => {
216
- cleanup('SIGTERM').then(() => {
217
- originalExit(code)
218
- })
219
- }) as typeof process.exit
220
-
221
- const beforeExitHandler = () => cleanup('SIGTERM')
222
- process.on('beforeExit', beforeExitHandler)
223
- process.on('SIGINT', sigintHandler)
224
- process.on('SIGTERM', sigtermHandler)
225
- process.on('SIGHUP', sighupHandler)
226
-
227
- const exit = async (code: number = 0) => {
228
- await cleanup('SIGTERM')
229
- process.exit(code)
230
- }
231
-
232
- return {
233
- addChildProcess,
234
- cleanup,
235
- exit,
236
- }
237
- }
@@ -1,141 +0,0 @@
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
- })