@o/scripts 0.16.19-canary.1790165522484
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/LICENSE +21 -0
- package/package.json +55 -0
- package/src/build-initial.ts +93 -0
- package/src/clean.ts +29 -0
- package/src/cmd.ts +105 -0
- package/src/dev-tunnel.ts +162 -0
- package/src/ensure-port.ts +79 -0
- package/src/ensure-tunnel.ts +19 -0
- package/src/ensure-zero-sqlite.ts +31 -0
- package/src/env-pull.ts +58 -0
- package/src/env-update.ts +342 -0
- package/src/exec-with-env.ts +61 -0
- package/src/helpers/args.ts +97 -0
- package/src/helpers/check-port.ts +22 -0
- package/src/helpers/deploy-health.ts +227 -0
- package/src/helpers/deploy-lock.ts +231 -0
- package/src/helpers/ensure-s3-bucket.ts +88 -0
- package/src/helpers/env-load.ts +43 -0
- package/src/helpers/get-docker-host.ts +37 -0
- package/src/helpers/get-test-env.ts +46 -0
- package/src/helpers/github-tail.ts +249 -0
- package/src/helpers/multipass.ts +174 -0
- package/src/helpers/parse-env-file.ts +71 -0
- package/src/helpers/process-compose-env.ts +57 -0
- package/src/helpers/resolve-dep-version.ts +11 -0
- package/src/helpers/run.ts +340 -0
- package/src/helpers/ssh.ts +50 -0
- package/src/helpers/uncloud-deploy.ts +162 -0
- package/src/helpers/uncloud.ts +197 -0
- package/src/helpers/wait-for-port.ts +33 -0
- package/src/helpers/zero-get-version.ts +8 -0
- package/src/release.ts +544 -0
- package/src/sst-get-environment.ts +38 -0
- package/src/typecheck.ts +21 -0
- package/src/up.ts +440 -0
- package/src/update-changelog.ts +92 -0
- package/src/wait-for-dev.ts +41 -0
|
@@ -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
|
+
}
|