@o/scripts 0.16.19

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,340 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { cpus } from 'node:os'
3
+
4
+ import {
5
+ getIsExiting,
6
+ notifyProcessHandlers,
7
+ type ProcessHandler,
8
+ type ProcessType,
9
+ } from '@o/run/helpers/process-state'
10
+
11
+ import type { Timer } from '@o/helpers'
12
+
13
+ export type { ProcessHandler, ProcessType }
14
+ export { getIsExiting }
15
+
16
+ const colors = [
17
+ '\x1b[36m', // cyan
18
+ '\x1b[35m', // magenta
19
+ '\x1b[32m', // green
20
+ '\x1b[33m', // yellow
21
+ '\x1b[34m', // blue
22
+ '\x1b[31m', // red
23
+ ]
24
+ const reset = '\x1b[0m'
25
+ let colorIndex = 0
26
+
27
+ function getNextColor(): string {
28
+ const color = colors[colorIndex % colors.length]!
29
+ colorIndex++
30
+ return color
31
+ }
32
+
33
+ const running: Record<string, Promise<unknown> | undefined | null> = {}
34
+
35
+ export async function runInline(name: string, cb: () => Promise<void>) {
36
+ const promise = cb()
37
+ running[name] = promise
38
+ return promise
39
+ }
40
+
41
+ export async function run(
42
+ command: string,
43
+ options?: {
44
+ env?: Record<string, string>
45
+ cwd?: string
46
+ silent?: boolean
47
+ captureOutput?: boolean
48
+ prefix?: string
49
+ detached?: boolean
50
+ timeout?: number
51
+ timing?: boolean | string
52
+ interactive?: boolean
53
+ }
54
+ ) {
55
+ const {
56
+ env,
57
+ cwd,
58
+ silent,
59
+ captureOutput,
60
+ prefix,
61
+ detached,
62
+ timeout,
63
+ timing,
64
+ interactive,
65
+ } = options || {}
66
+
67
+ if (timing) {
68
+ const name = typeof timing === 'string' ? timing : command
69
+ const startTime = Date.now()
70
+ try {
71
+ const promise = runInternal()
72
+ running[name] = promise
73
+ const result = await promise
74
+ const duration = Date.now() - startTime
75
+ console.info(
76
+ `\x1b[32m✓\x1b[0m \x1b[35m${name}\x1b[0m completed in \x1b[33m${formatDuration(duration)}\x1b[0m`
77
+ )
78
+ return result
79
+ } catch (error) {
80
+ const duration = Date.now() - startTime
81
+ console.error(`✗ ${name} failed after ${formatDuration(duration)}`)
82
+ throw error
83
+ } finally {
84
+ running[name] = null
85
+ }
86
+ }
87
+
88
+ return runInternal()
89
+
90
+ async function runInternal() {
91
+ // respect TKO_SILENT env var for quiet mode in watch scenarios
92
+ const effectiveSilent = silent || process.env.TKO_SILENT === '1'
93
+ if (!effectiveSilent) {
94
+ console.info(`$ ${command}${cwd ? ` (in ${cwd})` : ``}`)
95
+ }
96
+
97
+ let timeoutId: Timer | undefined
98
+ let didTimeOut = false
99
+
100
+ try {
101
+ const shell = spawn('bash', ['-c', command], {
102
+ env: { ...process.env, ...env },
103
+ cwd,
104
+ stdio: interactive ? 'inherit' : ['ignore', 'pipe', 'pipe'],
105
+ detached: detached ?? false,
106
+ })
107
+
108
+ if (detached) {
109
+ shell.unref()
110
+ }
111
+
112
+ notifyProcessHandlers(shell)
113
+
114
+ if (timeout) {
115
+ timeoutId = setTimeout(() => {
116
+ didTimeOut = true
117
+ console.error(`Command timed out after ${timeout}ms: ${command}`)
118
+ shell.kill()
119
+ }, timeout)
120
+ }
121
+
122
+ const color = prefix ? getNextColor() : ''
123
+ const coloredPrefix = prefix ? `${color}[${prefix}]${reset}` : ''
124
+
125
+ const writeOutput = (text: string, isStderr: boolean) => {
126
+ if (!effectiveSilent) {
127
+ const output = prefix ? `${coloredPrefix} ${text}` : text
128
+ if (!prefix || !captureOutput) {
129
+ const stream = isStderr ? process.stderr : process.stdout
130
+ stream.write(output)
131
+ }
132
+ }
133
+ }
134
+
135
+ const processStream = (
136
+ stream: NodeJS.ReadableStream | null,
137
+ isStderr: boolean
138
+ ): Promise<string> => {
139
+ return new Promise((resolve) => {
140
+ if (effectiveSilent && !captureOutput) {
141
+ resolve('')
142
+ return
143
+ }
144
+
145
+ if (!stream) {
146
+ resolve('')
147
+ return
148
+ }
149
+
150
+ let buffer = ''
151
+ let captured = ''
152
+
153
+ stream.on('data', (chunk: Buffer) => {
154
+ const text = buffer + chunk.toString()
155
+ const lines = text.split('\n')
156
+
157
+ // keep last partial line in buffer
158
+ buffer = lines.pop() || ''
159
+
160
+ // process complete lines
161
+ for (const line of lines) {
162
+ // always capture for potential error messages or captureOutput
163
+ captured += line + '\n'
164
+
165
+ // output if not silent and appropriate
166
+ if (!captureOutput || prefix) {
167
+ writeOutput(line + '\n', isStderr)
168
+ }
169
+ }
170
+ })
171
+
172
+ stream.on('end', () => {
173
+ // output any remaining buffer
174
+ if (buffer) {
175
+ captured += buffer
176
+ if (!captureOutput || prefix) {
177
+ writeOutput(buffer + '\n', isStderr)
178
+ }
179
+ }
180
+ resolve(captured)
181
+ })
182
+
183
+ stream.on('error', (err) => {
184
+ console.error(`Error reading stream!`, err)
185
+ resolve(captured)
186
+ })
187
+ })
188
+ }
189
+
190
+ // process both streams and wait for exit
191
+ const [stdout, stderr, exitCode] = await Promise.all([
192
+ processStream(shell.stdout, false),
193
+ processStream(shell.stderr, true),
194
+ new Promise<number | null>((resolve) => {
195
+ shell.on('close', (code) => resolve(code))
196
+ }),
197
+ ])
198
+
199
+ if (timeoutId) {
200
+ clearTimeout(timeoutId)
201
+ }
202
+
203
+ if (detached) {
204
+ return { stdout: '', stderr: '' }
205
+ }
206
+
207
+ // a command we killed for exceeding its timeout failed, whatever exit code
208
+ // the kill happened to leave behind. shell.kill() signals the `bash -c`
209
+ // wrapper, and whether that surfaces as 143, as null, or as 0 depends on
210
+ // whether bash exec'd into the command or stayed its parent, so gating on
211
+ // the code alone reports a killed command as a completed one. that is not
212
+ // hypothetical: a production `wrangler deploy` was killed at its 10-minute
213
+ // timeout and this returned success, so the release printed a green check
214
+ // for a worker it never activated.
215
+ if (exitCode !== 0 || didTimeOut) {
216
+ const errorMsg = didTimeOut
217
+ ? `Command timed out after ${timeout}ms: ${command}`
218
+ : `Command failed with exit code ${exitCode}: ${command}`
219
+
220
+ if (!silent && !getIsExiting()) {
221
+ console.error(`run() error: ${errorMsg}: ${stderr || ''}`)
222
+ }
223
+
224
+ const error = new Error(errorMsg, { cause: { exitCode, stdout, stderr } })
225
+ Error.captureStackTrace(error, runInternal)
226
+ throw error
227
+ }
228
+
229
+ return { stdout, stderr, exitCode }
230
+ } catch (error) {
231
+ clearTimeout(timeoutId)
232
+ if (!silent && !getIsExiting()) {
233
+ // only show the error message, not the full object if it's our error
234
+ if (error instanceof Error && (error as any).cause?.exitCode !== undefined) {
235
+ // this is our controlled error, already logged above
236
+ } else {
237
+ console.error(`Error running command: ${command}`, error)
238
+ }
239
+ } else if (!silent && getIsExiting()) {
240
+ // simple message when being killed due to another error
241
+ const shortCmd = command.split(' ')[0]
242
+ console.error(`${shortCmd} exiting due to earlier error`)
243
+ }
244
+ throw error
245
+ }
246
+ }
247
+ }
248
+
249
+ export async function waitForRun(name: string) {
250
+ if (running[name] === undefined) {
251
+ throw new Error(`Can't wait before task runs: ${name}`)
252
+ }
253
+ await running[name]
254
+ }
255
+
256
+ export function formatDuration(ms: number): string {
257
+ const seconds = Math.floor(ms / 1000)
258
+ const minutes = Math.floor(seconds / 60)
259
+ const remainingSeconds = seconds % 60
260
+
261
+ if (minutes > 0) {
262
+ return `${minutes}m ${remainingSeconds}s`
263
+ }
264
+ return `${seconds}s`
265
+ }
266
+
267
+ export async function printTiming<T>(name: string, fn: () => Promise<T>): Promise<T> {
268
+ const startTime = Date.now()
269
+ try {
270
+ const result = await fn()
271
+ const duration = Date.now() - startTime
272
+ console.info(
273
+ `\x1b[32m✓\x1b[0m \x1b[35m${name}\x1b[0m completed in \x1b[33m${formatDuration(duration)}\x1b[0m`
274
+ )
275
+ return result
276
+ } catch (error) {
277
+ const duration = Date.now() - startTime
278
+ console.error(`✗ ${name} failed after ${formatDuration(duration)}`)
279
+ throw error
280
+ }
281
+ }
282
+
283
+ export async function runParallel(
284
+ tasks: Array<{ name: string; fn: () => Promise<void>; condition?: () => boolean }>,
285
+ options?: { maxParallelism?: number }
286
+ ) {
287
+ const activeTasks = tasks.filter((task) => !task.condition || task.condition())
288
+
289
+ if (activeTasks.length === 0) {
290
+ return
291
+ }
292
+
293
+ const maxParallelism = options?.maxParallelism ?? cpus().length
294
+ console.info(`\nStarting parallel tasks: ${activeTasks.map((t) => t.name).join(', ')}`)
295
+ console.info(`Max parallelism: ${maxParallelism}`)
296
+
297
+ const taskStartTime = Date.now()
298
+
299
+ try {
300
+ const results: Promise<void>[] = []
301
+ const executing: Set<Promise<void>> = new Set()
302
+
303
+ for (const task of activeTasks) {
304
+ const startTime = Date.now()
305
+ const taskPromise = task.fn().then(
306
+ () => {
307
+ const duration = Date.now() - startTime
308
+ console.info(
309
+ `\x1b[32m✓\x1b[0m task: \x1b[35m${task.name}\x1b[0m completed in \x1b[33m${formatDuration(duration)}\x1b[0m`
310
+ )
311
+ executing.delete(taskPromise)
312
+ },
313
+ (error: unknown) => {
314
+ const duration = Date.now() - startTime
315
+ console.error(`✗ task: ${task.name} failed after ${formatDuration(duration)}`)
316
+ executing.delete(taskPromise)
317
+ throw error
318
+ }
319
+ )
320
+
321
+ results.push(taskPromise)
322
+ executing.add(taskPromise)
323
+
324
+ if (executing.size >= maxParallelism) {
325
+ await Promise.race(executing)
326
+ }
327
+ }
328
+
329
+ await Promise.all(results)
330
+
331
+ const totalDuration = Date.now() - taskStartTime
332
+ console.info(
333
+ `\nAll parallel tasks completed successfully in ${formatDuration(totalDuration)}`
334
+ )
335
+ } catch (error) {
336
+ const totalDuration = Date.now() - taskStartTime
337
+ console.error(`\nCI fail after ${formatDuration(totalDuration)} failed`)
338
+ throw error
339
+ }
340
+ }
@@ -0,0 +1,50 @@
1
+ import { run } from './run'
2
+
3
+ export async function checkSSHKey(sshKeyPath: string): Promise<void> {
4
+ try {
5
+ await run(`test -f ${sshKeyPath}`, { silent: true })
6
+ console.info('✅ ssh key exists')
7
+ } catch {
8
+ throw new Error(`ssh key not found: ${sshKeyPath}`)
9
+ }
10
+ }
11
+
12
+ async function sleep(ms: number) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms))
14
+ }
15
+
16
+ export async function testSSHConnection(
17
+ host: string,
18
+ sshKey: string,
19
+ options?: { retries?: number; retryDelayMs?: number }
20
+ ): Promise<void> {
21
+ const maxRetries = options?.retries ?? 3
22
+ const retryDelay = options?.retryDelayMs ?? 5000
23
+
24
+ console.info('\n🔑 testing ssh connection...\n')
25
+
26
+ let lastError: Error | null = null
27
+
28
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
29
+ try {
30
+ await run(
31
+ `ssh -i ${sshKey} -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${host} "echo 'SSH connection successful'"`,
32
+ { silent: attempt > 1 }
33
+ )
34
+ console.info('✅ ssh connection verified')
35
+ return
36
+ } catch (err) {
37
+ lastError = err as Error
38
+ if (attempt < maxRetries) {
39
+ console.info(
40
+ ` ssh attempt ${attempt}/${maxRetries} failed, retrying in ${retryDelay / 1000}s...`
41
+ )
42
+ await sleep(retryDelay)
43
+ }
44
+ }
45
+ }
46
+
47
+ throw new Error(
48
+ `cannot connect to ${host} after ${maxRetries} attempts - check ssh key: ${sshKey}`
49
+ )
50
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * generic uncloud deployment helpers for ci/cd
3
+ * shared across repos via @o/scripts/helpers/uncloud-deploy
4
+ */
5
+
6
+ import { existsSync } from 'node:fs'
7
+ import { mkdir, writeFile } from 'node:fs/promises'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ import { time } from '@o/helpers'
12
+
13
+ import { run } from './run'
14
+
15
+ export async function checkUncloudConfigured(): Promise<boolean> {
16
+ return Boolean(process.env.DEPLOY_HOST && process.env.DEPLOY_DB)
17
+ }
18
+
19
+ export async function verifyDatabaseConfig(): Promise<{
20
+ valid: boolean
21
+ errors: string[]
22
+ }> {
23
+ const errors: string[] = []
24
+
25
+ const deployDb = process.env.DEPLOY_DB
26
+ const upstreamDb = process.env.ZERO_UPSTREAM_DB
27
+ const cvrDb = process.env.ZERO_CVR_DB
28
+ const changeDb = process.env.ZERO_CHANGE_DB
29
+
30
+ if (!deployDb) errors.push('DEPLOY_DB is not set')
31
+ if (!upstreamDb) errors.push('ZERO_UPSTREAM_DB is not set')
32
+ if (!cvrDb) errors.push('ZERO_CVR_DB is not set')
33
+ if (!changeDb) errors.push('ZERO_CHANGE_DB is not set')
34
+
35
+ if (errors.length > 0) {
36
+ return { valid: false, errors }
37
+ }
38
+
39
+ const getHost = (url: string): string | null => {
40
+ try {
41
+ const match = url.match(/@([^:/]+)/)
42
+ return match?.[1] || null
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ const deployHost = getHost(deployDb!)
49
+ const upstreamHost = getHost(upstreamDb!)
50
+ const cvrHost = getHost(cvrDb!)
51
+ const changeHost = getHost(changeDb!)
52
+
53
+ if (deployHost && upstreamHost && deployHost !== upstreamHost) {
54
+ errors.push(
55
+ `ZERO_UPSTREAM_DB host (${upstreamHost}) does not match DEPLOY_DB host (${deployHost})`
56
+ )
57
+ }
58
+ if (deployHost && cvrHost && deployHost !== cvrHost) {
59
+ errors.push(
60
+ `ZERO_CVR_DB host (${cvrHost}) does not match DEPLOY_DB host (${deployHost})`
61
+ )
62
+ }
63
+ if (deployHost && changeHost && deployHost !== changeHost) {
64
+ errors.push(
65
+ `ZERO_CHANGE_DB host (${changeHost}) does not match DEPLOY_DB host (${deployHost})`
66
+ )
67
+ }
68
+
69
+ return { valid: errors.length === 0, errors }
70
+ }
71
+
72
+ export async function installUncloudCLI(version = '0.16.0') {
73
+ console.info('🔧 checking uncloud cli...')
74
+ try {
75
+ await run('uc --version', { silent: true, timeout: time.ms.seconds(5) })
76
+ console.info(' uncloud cli already installed')
77
+ } catch {
78
+ console.info(` installing uncloud cli v${version}...`)
79
+ await run(
80
+ `curl -fsS https://get.uncloud.run/install.sh | sh -s -- --version ${version}`,
81
+ { timeout: time.ms.seconds(30) }
82
+ )
83
+ console.info(' ✓ uncloud cli installed')
84
+ }
85
+ }
86
+
87
+ export interface SetupSSHKeyOptions {
88
+ // env var containing the key path or content (default: DEPLOY_SSH_KEY)
89
+ envVar?: string
90
+ // filename to write in ~/.ssh/ (default: uncloud_deploy)
91
+ keyName?: string
92
+ // host to add to known_hosts (default: process.env.DEPLOY_HOST)
93
+ host?: string
94
+ }
95
+
96
+ /**
97
+ * resolve an ssh key from env - handles both file paths (local) and
98
+ * raw/base64 key content (CI). writes to ~/.ssh/{keyName} and updates
99
+ * the env var to point to the file.
100
+ */
101
+ export async function setupSSHKey(options: SetupSSHKeyOptions = {}) {
102
+ const envVar = options.envVar || 'DEPLOY_SSH_KEY'
103
+ const keyName = options.keyName || 'uncloud_deploy'
104
+ const host = options.host || process.env.DEPLOY_HOST
105
+
106
+ if (!process.env[envVar]) {
107
+ return
108
+ }
109
+
110
+ // expand ~ to home directory (node doesn't do this automatically)
111
+ const sshKeyValue = process.env[envVar]!.replace(/^~/, homedir())
112
+
113
+ // check if it's a path to an existing file (local usage) or key content (CI usage)
114
+ if (existsSync(sshKeyValue)) {
115
+ // local usage - ensure env has resolved path (not ~ prefix)
116
+ process.env[envVar] = sshKeyValue
117
+ console.info(` using ssh key from: ${sshKeyValue}`)
118
+ return
119
+ }
120
+
121
+ // CI usage - env var contains the actual key content
122
+ console.info('🔑 setting up ssh key from environment...')
123
+ const sshDir = join(homedir(), '.ssh')
124
+ const keyPath = join(sshDir, keyName)
125
+
126
+ if (!existsSync(sshDir)) {
127
+ await mkdir(sshDir, { recursive: true })
128
+ }
129
+
130
+ // decode base64-encoded keys (github secrets often store keys as base64)
131
+ let keyContent = sshKeyValue
132
+ if (
133
+ !sshKeyValue.includes('-----BEGIN') &&
134
+ /^[A-Za-z0-9+/=\s]+$/.test(sshKeyValue.trim())
135
+ ) {
136
+ console.info(' detected base64-encoded key, decoding...')
137
+ keyContent = Buffer.from(sshKeyValue.trim(), 'base64').toString('utf-8')
138
+ }
139
+
140
+ // ensure trailing newline (github secrets can strip it)
141
+ if (!keyContent.endsWith('\n')) {
142
+ keyContent += '\n'
143
+ }
144
+
145
+ await writeFile(keyPath, keyContent, { mode: 0o600 })
146
+
147
+ // add host to known_hosts
148
+ if (host) {
149
+ try {
150
+ await run(`ssh-keyscan -H ${host} >> ${join(sshDir, 'known_hosts')}`, {
151
+ silent: true,
152
+ timeout: time.ms.seconds(10),
153
+ })
154
+ } catch {
155
+ // ignore - ssh will prompt if needed
156
+ }
157
+ }
158
+
159
+ // override env var to point to the file we created
160
+ process.env[envVar] = keyPath
161
+ console.info(` ssh key written to ${keyPath}`)
162
+ }