@take-out/scripts 0.0.28

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.
@@ -0,0 +1,254 @@
1
+ import type { ChildProcess } from 'node:child_process'
2
+ import { spawn } from 'node:child_process'
3
+ import {
4
+ addProcessHandler,
5
+ setExitCleanupState,
6
+ type ProcessHandler,
7
+ type ProcessType,
8
+ } from './run'
9
+
10
+ type ExitCallback = (info: { signal: NodeJS.Signals | string }) => void | Promise<void>
11
+
12
+ interface HandleProcessExitReturn {
13
+ addChildProcess: ProcessHandler
14
+ cleanup: () => Promise<void>
15
+ exit: (code?: number) => Promise<void>
16
+ }
17
+
18
+ async function getChildPids(parentPid: number): Promise<number[]> {
19
+ try {
20
+ const proc = spawn('pgrep', ['-P', parentPid.toString()], {
21
+ stdio: ['ignore', 'pipe', 'pipe'],
22
+ })
23
+
24
+ return new Promise((resolve) => {
25
+ let output = ''
26
+ proc.stdout?.on('data', (data) => {
27
+ output += data.toString()
28
+ })
29
+
30
+ proc.on('close', () => {
31
+ const childPids = output.trim().split('\n').filter(Boolean).map(Number)
32
+ resolve(childPids)
33
+ })
34
+ })
35
+ } catch (error) {
36
+ console.error(`Error getting child PIDs for ${parentPid}: ${error}`)
37
+ return []
38
+ }
39
+ }
40
+
41
+ async function getAllDescendantPids(parentPid: number): Promise<number[]> {
42
+ const childPids = await getChildPids(parentPid)
43
+ const descendantPids = [...childPids]
44
+
45
+ for (const childPid of childPids) {
46
+ const grandchildren = await getAllDescendantPids(childPid)
47
+ descendantPids.push(...grandchildren)
48
+ }
49
+
50
+ return descendantPids
51
+ }
52
+
53
+ async function killProcessTree(
54
+ pid: number,
55
+ signal: NodeJS.Signals = 'SIGTERM',
56
+ forceful: boolean = false
57
+ ): Promise<void> {
58
+ try {
59
+ const descendants = await getAllDescendantPids(pid)
60
+
61
+ // first send the requested signal
62
+ for (const descendantPid of descendants.reverse()) {
63
+ try {
64
+ process.kill(descendantPid, signal)
65
+ } catch (_) {
66
+ // process may already be gone
67
+ }
68
+ }
69
+
70
+ if (pid && !Number.isNaN(pid)) {
71
+ try {
72
+ process.kill(pid, signal)
73
+ } catch (_) {
74
+ // process may already be gone
75
+ }
76
+ }
77
+
78
+ // if forceful, wait briefly then send SIGKILL
79
+ if (forceful && signal !== 'SIGKILL') {
80
+ await new Promise((resolve) => setTimeout(resolve, 100))
81
+
82
+ // send SIGKILL to any still-alive processes
83
+ for (const descendantPid of descendants.reverse()) {
84
+ try {
85
+ process.kill(descendantPid, 'SIGKILL')
86
+ } catch (_) {
87
+ // process may already be gone
88
+ }
89
+ }
90
+
91
+ if (pid && !Number.isNaN(pid)) {
92
+ try {
93
+ process.kill(pid, 'SIGKILL')
94
+ } catch (_) {
95
+ // process may already be gone
96
+ }
97
+ }
98
+ }
99
+ } catch (error) {
100
+ console.error(`Error killing process tree for ${pid}: ${error}`)
101
+ }
102
+ }
103
+
104
+ function isChildProcess(proc: ProcessType): proc is ChildProcess {
105
+ return 'killed' in proc && typeof (proc as any).on === 'function'
106
+ }
107
+
108
+ let isHandling = false
109
+
110
+ export function handleProcessExit({
111
+ onExit,
112
+ }: {
113
+ onExit?: ExitCallback
114
+ } = {}): HandleProcessExitReturn {
115
+ if (isHandling) {
116
+ throw new Error(`Only one handleProcessExit per process should be registered!`)
117
+ }
118
+
119
+ isHandling = true
120
+ const processes: ProcessType[] = []
121
+ const allChildPids = new Set<number>()
122
+ let cleaning = false
123
+
124
+ const cleanup = async (signal: NodeJS.Signals | string = 'SIGTERM') => {
125
+ if (cleaning) return
126
+ cleaning = true
127
+
128
+ // notify run.ts that we're in cleanup state
129
+ setExitCleanupState(true)
130
+
131
+ // suppress console output during cleanup for cleaner exit
132
+ if (signal === 'SIGINT') {
133
+ const noop = () => {}
134
+ console.log = noop
135
+ console.info = noop
136
+ console.warn = noop
137
+ // keep console.error for critical errors only
138
+ }
139
+
140
+ // wrap entire cleanup in a timeout to prevent hanging
141
+ if (onExit) {
142
+ try {
143
+ await onExit({ signal })
144
+ } catch (error) {
145
+ console.error('Error in exit callback:', error)
146
+ }
147
+ }
148
+
149
+ // skip cleanup if no processes to clean up
150
+ if (processes.length === 0) {
151
+ return
152
+ }
153
+
154
+ // for SIGINT (Ctrl+C), be more aggressive with cleanup
155
+ const isInterrupt = signal === 'SIGINT'
156
+ const killSignal = isInterrupt ? 'SIGTERM' : (signal as NodeJS.Signals)
157
+
158
+ await Promise.all(
159
+ processes.map(async (proc) => {
160
+ if (proc.pid) {
161
+ try {
162
+ await killProcessTree(proc.pid, killSignal, isInterrupt)
163
+ } catch (error) {
164
+ console.error(
165
+ `Error during graceful shutdown of process ${proc.pid}: ${error}`
166
+ )
167
+ }
168
+ }
169
+ })
170
+ )
171
+
172
+ // shorter wait for SIGINT
173
+ await new Promise((res) => setTimeout(res, isInterrupt ? 50 : 200))
174
+
175
+ // force kill any remaining processes
176
+ for (const proc of processes) {
177
+ try {
178
+ if (!proc.exitCode && proc.pid) {
179
+ await killProcessTree(proc.pid, 'SIGKILL', true)
180
+ }
181
+ } catch (err) {
182
+ // process already gone
183
+ }
184
+ }
185
+ }
186
+
187
+ const addChildProcess = (proc: ProcessType) => {
188
+ processes.push(proc)
189
+
190
+ // track pid if available
191
+ const pid = (proc as any).pid
192
+ if (pid) {
193
+ allChildPids.add(pid)
194
+
195
+ // for child processes, capture descendants once after a brief delay
196
+ if (isChildProcess(proc)) {
197
+ setTimeout(async () => {
198
+ if (proc.pid) {
199
+ try {
200
+ const childPids = await getChildPids(proc.pid)
201
+ for (const childPid of childPids) {
202
+ allChildPids.add(childPid)
203
+ }
204
+ } catch {
205
+ // ignore errors in background polling
206
+ }
207
+ }
208
+ }, 300)
209
+ }
210
+ }
211
+ }
212
+
213
+ addProcessHandler(addChildProcess)
214
+
215
+ const sigtermHandler = () => {
216
+ cleanup('SIGTERM').then(() => {
217
+ process.exit(0)
218
+ })
219
+ }
220
+
221
+ const sigintHandler = () => {
222
+ // immediately print newline and reset cursor for clean terminal
223
+ process.stdout.write('\n')
224
+ // reset terminal attributes
225
+ process.stdout.write('\x1b[0m')
226
+
227
+ cleanup('SIGINT').then(() => {
228
+ process.exit(0)
229
+ })
230
+ }
231
+
232
+ // intercept process.exit to ensure cleanup
233
+ const originalExit = process.exit
234
+ process.exit = ((code?: number) => {
235
+ cleanup('SIGTERM').then(() => {
236
+ originalExit(code)
237
+ })
238
+ }) as typeof process.exit
239
+
240
+ process.on('beforeExit', () => cleanup('SIGTERM'))
241
+ process.on('SIGINT', sigintHandler)
242
+ process.on('SIGTERM', sigtermHandler)
243
+
244
+ const exit = async (code: number = 0) => {
245
+ await cleanup('SIGTERM')
246
+ process.exit(code)
247
+ }
248
+
249
+ return {
250
+ addChildProcess,
251
+ cleanup,
252
+ exit,
253
+ }
254
+ }
@@ -0,0 +1,310 @@
1
+ import type { ChildProcess } from 'node:child_process'
2
+ import type { Timer } from '@take-out/helpers'
3
+
4
+ export type ProcessType = ChildProcess | Bun.Subprocess
5
+ export type ProcessHandler = (process: ProcessType) => void
6
+
7
+ // track if we're in cleanup state (another process failed)
8
+ let isInExitCleanup = false
9
+
10
+ export function setExitCleanupState(state: boolean) {
11
+ isInExitCleanup = state
12
+ }
13
+
14
+ export function getIsExiting() {
15
+ return isInExitCleanup
16
+ }
17
+
18
+ const processHandlers = new Set<ProcessHandler>()
19
+
20
+ const colors = [
21
+ '\x1b[36m', // cyan
22
+ '\x1b[35m', // magenta
23
+ '\x1b[32m', // green
24
+ '\x1b[33m', // yellow
25
+ '\x1b[34m', // blue
26
+ '\x1b[31m', // red
27
+ ]
28
+ const reset = '\x1b[0m'
29
+ let colorIndex = 0
30
+
31
+ function getNextColor(): string {
32
+ const color = colors[colorIndex % colors.length]!
33
+ colorIndex++
34
+ return color
35
+ }
36
+
37
+ const running: Record<string, Promise<unknown> | undefined | null> = {}
38
+
39
+ export async function runInline(name: string, cb: () => Promise<void>) {
40
+ const promise = cb()
41
+ running[name] = promise
42
+ return await promise
43
+ }
44
+
45
+ export async function run(
46
+ command: string,
47
+ options?: {
48
+ env?: Record<string, string>
49
+ cwd?: string
50
+ silent?: boolean
51
+ captureOutput?: boolean
52
+ prefix?: string
53
+ detached?: boolean
54
+ timeout?: number
55
+ timing?: boolean | string
56
+ }
57
+ ) {
58
+ const { env, cwd, silent, captureOutput, prefix, detached, timeout, timing } =
59
+ options || {}
60
+
61
+ if (timing) {
62
+ const name = typeof timing === 'string' ? timing : command
63
+ const startTime = Date.now()
64
+ try {
65
+ const promise = runInternal()
66
+ running[name] = promise
67
+ const result = await promise
68
+ const duration = Date.now() - startTime
69
+ console.info(
70
+ `\x1b[32m✓\x1b[0m \x1b[35m${name}\x1b[0m completed in \x1b[33m${formatDuration(duration)}\x1b[0m`
71
+ )
72
+ return result
73
+ } catch (error) {
74
+ const duration = Date.now() - startTime
75
+ console.error(`✗ ${name} failed after ${formatDuration(duration)}`)
76
+ throw error
77
+ } finally {
78
+ running[name] = null
79
+ }
80
+ }
81
+
82
+ return runInternal()
83
+
84
+ async function runInternal() {
85
+ if (!silent) {
86
+ console.info(`$ ${command}${cwd ? ` (in ${cwd})` : ``}`)
87
+ }
88
+
89
+ let timeoutId: Timer | undefined
90
+ let didTimeOut = false
91
+
92
+ try {
93
+ const shell = Bun.spawn(['bash', '-c', command], {
94
+ env: { ...process.env, ...(env || {}) },
95
+ cwd,
96
+ stdout: 'pipe',
97
+ stderr: 'pipe', // always pipe stderr so we can capture it for error messages
98
+ })
99
+
100
+ if (detached) {
101
+ shell.unref()
102
+ }
103
+
104
+ processHandlers.forEach((cb) => cb(shell))
105
+
106
+ if (timeout) {
107
+ timeoutId = setTimeout(() => {
108
+ didTimeOut = true
109
+ console.error(`Command timed out after ${timeout}ms: ${command}`)
110
+ shell.kill()
111
+ }, timeout)
112
+ }
113
+
114
+ const color = prefix ? getNextColor() : ''
115
+ const coloredPrefix = prefix ? `${color}[${prefix}]${reset}` : ''
116
+
117
+ const writeOutput = (text: string, isStderr: boolean) => {
118
+ if (!silent) {
119
+ const output = prefix ? `${coloredPrefix} ${text}` : text
120
+ if (!prefix || !captureOutput) {
121
+ const stream = isStderr ? process.stderr : process.stdout
122
+ stream.write(output)
123
+ }
124
+ }
125
+ }
126
+
127
+ const processStream = async (
128
+ stream: ReadableStream<Uint8Array> | undefined,
129
+ isStderr: boolean
130
+ ): Promise<string> => {
131
+ if (silent && !captureOutput) {
132
+ return ''
133
+ }
134
+
135
+ if (!stream) return ''
136
+
137
+ let buffer = ''
138
+ let captured = ''
139
+ const decoder = new TextDecoder()
140
+ const reader = stream.getReader()
141
+
142
+ try {
143
+ while (true) {
144
+ const { done, value } = await reader.read()
145
+ if (done) break
146
+
147
+ const text = buffer + decoder.decode(value, { stream: true })
148
+ const lines = text.split('\n')
149
+
150
+ // keep last partial line in buffer
151
+ buffer = lines.pop() || ''
152
+
153
+ // process complete lines
154
+ for (const line of lines) {
155
+ // always capture for potential error messages or captureOutput
156
+ captured += line + '\n'
157
+
158
+ // output if not silent and appropriate
159
+ if (!captureOutput || prefix) {
160
+ writeOutput(line + '\n', isStderr)
161
+ }
162
+ }
163
+ }
164
+
165
+ // output any remaining buffer
166
+ if (buffer) {
167
+ captured += buffer
168
+ if (!captureOutput || prefix) {
169
+ writeOutput(buffer + '\n', isStderr)
170
+ }
171
+ }
172
+ } catch (err) {
173
+ console.error(`Error reading stream!`, err)
174
+ } finally {
175
+ reader.releaseLock()
176
+ }
177
+
178
+ return captured
179
+ }
180
+
181
+ // always process both streams
182
+ const [stdout, stderr] = await Promise.all([
183
+ processStream(shell.stdout, false),
184
+ processStream(shell.stderr, true),
185
+ ])
186
+
187
+ const exitCode = await shell.exited
188
+
189
+ if (timeoutId) {
190
+ clearTimeout(timeoutId)
191
+ }
192
+
193
+ if (detached) {
194
+ return { stdout: '', stderr: '' }
195
+ }
196
+
197
+ if (exitCode !== 0) {
198
+ const errorMsg =
199
+ exitCode === 143 && didTimeOut
200
+ ? `Command timed out after ${timeout}ms: ${command}`
201
+ : `Command failed with exit code ${exitCode}: ${command}`
202
+
203
+ if (!silent && !isInExitCleanup) {
204
+ console.error(`run() error: ${errorMsg}: ${stderr || ''}`)
205
+ }
206
+
207
+ const error = new Error(errorMsg, { cause: { exitCode } })
208
+ Error.captureStackTrace(error, runInternal)
209
+ throw error
210
+ }
211
+
212
+ return { stdout, stderr, exitCode }
213
+ } catch (error) {
214
+ clearTimeout(timeoutId)
215
+ if (!silent && !isInExitCleanup) {
216
+ // only show the error message, not the full object if it's our error
217
+ if (error instanceof Error && (error as any).cause?.exitCode !== undefined) {
218
+ // this is our controlled error, already logged above
219
+ } else {
220
+ console.error(`Error running command: ${command}`, error)
221
+ }
222
+ } else if (!silent && isInExitCleanup) {
223
+ // simple message when being killed due to another error
224
+ const shortCmd = command.split(' ')[0]
225
+ console.error(`${shortCmd} exiting due to earlier error`)
226
+ }
227
+ throw error
228
+ }
229
+ }
230
+ }
231
+
232
+ export const addProcessHandler = (cb: ProcessHandler) => {
233
+ processHandlers.add(cb)
234
+ }
235
+
236
+ export async function waitForRun(name: string) {
237
+ if (running[name] === undefined) {
238
+ throw new Error(`Can't wait before task runs: ${name}`)
239
+ }
240
+ await running[name]
241
+ }
242
+
243
+ export function formatDuration(ms: number): string {
244
+ const seconds = Math.floor(ms / 1000)
245
+ const minutes = Math.floor(seconds / 60)
246
+ const remainingSeconds = seconds % 60
247
+
248
+ if (minutes > 0) {
249
+ return `${minutes}m ${remainingSeconds}s`
250
+ }
251
+ return `${seconds}s`
252
+ }
253
+
254
+ export async function printTiming<T>(name: string, fn: () => Promise<T>): Promise<T> {
255
+ const startTime = Date.now()
256
+ try {
257
+ const result = await fn()
258
+ const duration = Date.now() - startTime
259
+ console.info(
260
+ `\x1b[32m✓\x1b[0m \x1b[35m${name}\x1b[0m completed in \x1b[33m${formatDuration(duration)}\x1b[0m`
261
+ )
262
+ return result
263
+ } catch (error) {
264
+ const duration = Date.now() - startTime
265
+ console.error(`✗ ${name} failed after ${formatDuration(duration)}`)
266
+ throw error
267
+ }
268
+ }
269
+
270
+ export async function runParallel(
271
+ tasks: Array<{ name: string; fn: () => Promise<void>; condition?: () => boolean }>
272
+ ) {
273
+ const activeTasks = tasks.filter((task) => !task.condition || task.condition())
274
+
275
+ if (activeTasks.length === 0) {
276
+ return
277
+ }
278
+
279
+ console.info(`\nStarting parallel tasks: ${activeTasks.map((t) => t.name).join(', ')}`)
280
+
281
+ const taskStartTime = Date.now()
282
+
283
+ try {
284
+ await Promise.all(
285
+ activeTasks.map(async (task) => {
286
+ const startTime = Date.now()
287
+ try {
288
+ await task.fn()
289
+ const duration = Date.now() - startTime
290
+ console.info(
291
+ `\x1b[32m✓\x1b[0m task: \x1b[35m${task.name}\x1b[0m completed in \x1b[33m${formatDuration(duration)}\x1b[0m`
292
+ )
293
+ } catch (error) {
294
+ const duration = Date.now() - startTime
295
+ console.error(`✗ task: ${task.name} failed after ${formatDuration(duration)}`)
296
+ throw error
297
+ }
298
+ })
299
+ )
300
+
301
+ const totalDuration = Date.now() - taskStartTime
302
+ console.info(
303
+ `\nAll parallel tasks completed successfully in ${formatDuration(totalDuration)}`
304
+ )
305
+ } catch (error) {
306
+ const totalDuration = Date.now() - taskStartTime
307
+ console.error(`\nCI fail after ${formatDuration(totalDuration)} failed`)
308
+ throw error
309
+ }
310
+ }
@@ -0,0 +1,33 @@
1
+ import { checkPort } from './check-port'
2
+
3
+ interface WaitForPortOptions {
4
+ host?: string
5
+ intervalMs?: number
6
+ timeoutMs?: number
7
+ retries?: number
8
+ }
9
+
10
+ export async function waitForPort(
11
+ port: number,
12
+ options: WaitForPortOptions = {}
13
+ ): Promise<void> {
14
+ const {
15
+ host = '127.0.0.1',
16
+ intervalMs = 1000,
17
+ timeoutMs = 30000,
18
+ retries = Math.floor(timeoutMs / intervalMs),
19
+ } = options
20
+
21
+ for (let i = 0; i < retries; i++) {
22
+ const isOpen = await checkPort(port, host)
23
+ if (isOpen) {
24
+ return
25
+ }
26
+
27
+ if (i < retries - 1) {
28
+ await new Promise((resolve) => setTimeout(resolve, intervalMs))
29
+ }
30
+ }
31
+
32
+ throw new Error(`Port ${port} on ${host} did not become available after ${timeoutMs}ms`)
33
+ }
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ export function getZeroVersion() {
5
+ const packageJsonPath = join(process.cwd(), 'package.json')
6
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
7
+ return packageJson.dependencies?.['@rocicorp/zero']?.replace(/^[\^~]/, '')
8
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import fs from 'node:fs/promises'
4
+ import path from 'node:path'
5
+
6
+ function getCurrentNodeVersion() {
7
+ return process.version
8
+ }
9
+
10
+ async function getRequiredNodeVersion() {
11
+ // try .node-version file first
12
+ try {
13
+ const nodeVersionContent = await fs.readFile(
14
+ path.join(process.cwd(), '.node-version'),
15
+ 'utf-8'
16
+ )
17
+ return `v${nodeVersionContent.trim()}`
18
+ } catch {}
19
+
20
+ // fallback to package.json engines.node
21
+ try {
22
+ const packageJson = JSON.parse(
23
+ await fs.readFile(path.join(process.cwd(), 'package.json'), 'utf-8')
24
+ )
25
+ return packageJson?.engines?.node ? `v${packageJson.engines.node}` : null
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ export async function checkNodeVersion() {
32
+ const currentNodeVersion = getCurrentNodeVersion()
33
+ const requiredNodeVersion = await getRequiredNodeVersion()
34
+
35
+ if (requiredNodeVersion) {
36
+ if (currentNodeVersion !== requiredNodeVersion) {
37
+ throw new Error(
38
+ `\u001b[33mWarning: Incorrect Node.js version. Expected ${requiredNodeVersion} but got ${currentNodeVersion}\u001b[0m`
39
+ )
40
+ }
41
+ }
42
+ }
43
+
44
+ if (import.meta.main) {
45
+ checkNodeVersion().catch((e: any) => {
46
+ console.error(e.message)
47
+ process.exit(1)
48
+ })
49
+ }