@take-out/scripts 0.0.79 → 0.0.81

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.0.79",
3
+ "version": "0.0.81",
4
4
  "type": "module",
5
5
  "main": "./src/run.ts",
6
6
  "sideEffects": false,
@@ -28,10 +28,10 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@take-out/helpers": "0.0.76"
31
+ "@take-out/helpers": "0.0.79"
32
32
  },
33
33
  "peerDependencies": {
34
- "vxrn": "*"
34
+ "vxrn": "^1.4.15"
35
35
  },
36
36
  "peerDependenciesMeta": {
37
37
  "vxrn": {
@@ -39,6 +39,6 @@
39
39
  }
40
40
  },
41
41
  "devDependencies": {
42
- "vxrn": "1.4.10-1770207233041"
42
+ "vxrn": "1.4.15"
43
43
  }
44
44
  }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * tail github actions ci logs for the latest run
3
+ */
4
+
5
+ import { execSync, spawn } from 'node:child_process'
6
+
7
+ export interface GithubTailOptions {
8
+ /** workflow name to look for (default: 'CI') */
9
+ workflowName?: string
10
+ /** job name to tail (default: 'build-test-and-deploy') */
11
+ jobName?: string
12
+ /** only show failed step logs */
13
+ showOnlyFailed?: boolean
14
+ /** force watch mode even for completed jobs */
15
+ forceWatch?: boolean
16
+ }
17
+
18
+ function getFailedStepLogs(jobId: string, stepName: string): string {
19
+ try {
20
+ const logs = execSync(
21
+ `gh run view --job ${jobId} --log 2>/dev/null | grep -A 100 -B 20 "${stepName}"`,
22
+ {
23
+ encoding: 'utf-8',
24
+ shell: '/bin/bash',
25
+ maxBuffer: 10 * 1024 * 1024,
26
+ }
27
+ )
28
+ return logs
29
+ } catch {
30
+ try {
31
+ const logs = execSync(`gh run view --job ${jobId} --log-failed 2>/dev/null`, {
32
+ encoding: 'utf-8',
33
+ maxBuffer: 10 * 1024 * 1024,
34
+ })
35
+ return logs
36
+ } catch {
37
+ return ''
38
+ }
39
+ }
40
+ }
41
+
42
+ async function watchJob(
43
+ runId: string,
44
+ jobId: string,
45
+ jobName: string,
46
+ showOnlyFailed: boolean
47
+ ) {
48
+ try {
49
+ const jobsJson = execSync(`gh run view ${runId} --json jobs`, {
50
+ encoding: 'utf-8',
51
+ })
52
+ const { jobs } = JSON.parse(jobsJson)
53
+ const job = jobs.find((j: any) => j.name === jobName)
54
+
55
+ if (job && job.status === 'completed') {
56
+ console.info(`\nJob already completed with conclusion: ${job.conclusion}\n`)
57
+
58
+ if (job.conclusion === 'failure') {
59
+ const failedSteps =
60
+ job.steps?.filter((s: any) => s.conclusion === 'failure') || []
61
+ if (failedSteps.length > 0) {
62
+ console.info(`āŒ Failed step: ${failedSteps[0].name}\n`)
63
+ console.info('šŸ” Showing failure details...\n')
64
+ console.info('─'.repeat(80))
65
+
66
+ const failureLogs = getFailedStepLogs(jobId, failedSteps[0].name)
67
+ if (failureLogs) {
68
+ console.info(failureLogs)
69
+ } else {
70
+ execSync(`gh run view --job ${jobId} --log-failed`, {
71
+ stdio: 'inherit',
72
+ })
73
+ }
74
+ } else {
75
+ execSync(`gh run view --job ${jobId} --log-failed`, {
76
+ stdio: 'inherit',
77
+ })
78
+ }
79
+ } else {
80
+ if (!showOnlyFailed) {
81
+ execSync(`gh run view --job ${jobId} --log`, {
82
+ stdio: 'inherit',
83
+ })
84
+ }
85
+ }
86
+
87
+ process.exit(job.conclusion === 'success' ? 0 : 1)
88
+ }
89
+ } catch {
90
+ // continue to watch mode
91
+ }
92
+
93
+ console.info('\nšŸ“” Watching CI logs...\n')
94
+ console.info('─'.repeat(80))
95
+
96
+ const watchProcess = spawn('gh', ['run', 'watch', runId, '--exit-status'], {
97
+ stdio: 'inherit',
98
+ })
99
+
100
+ watchProcess.on('exit', async (code) => {
101
+ if (code !== 0) {
102
+ console.info('\nāŒ CI failed! Getting detailed logs...\n')
103
+ console.info('─'.repeat(80))
104
+
105
+ try {
106
+ const jobsJson = execSync(`gh run view ${runId} --json jobs`, {
107
+ encoding: 'utf-8',
108
+ })
109
+ const { jobs } = JSON.parse(jobsJson)
110
+ const job = jobs.find((j: any) => j.name === jobName)
111
+
112
+ if (job) {
113
+ const failedSteps =
114
+ job.steps?.filter((s: any) => s.conclusion === 'failure') || []
115
+ if (failedSteps.length > 0) {
116
+ console.info(
117
+ `Failed steps: ${failedSteps.map((s: any) => s.name).join(', ')}\n`
118
+ )
119
+
120
+ for (const step of failedSteps) {
121
+ console.info(`\n━━━ Logs for failed step: ${step.name} ━━━\n`)
122
+ const stepLogs = getFailedStepLogs(jobId, step.name)
123
+ if (stepLogs) {
124
+ console.info(stepLogs)
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ console.info('\n━━━ Summary of all failed steps ━━━\n')
131
+ execSync(`gh run view --job ${jobId} --log-failed`, {
132
+ stdio: 'inherit',
133
+ })
134
+ } catch {
135
+ console.error('Could not retrieve detailed failure logs')
136
+ }
137
+ }
138
+
139
+ process.exit(code || 0)
140
+ })
141
+
142
+ process.on('SIGINT', () => {
143
+ watchProcess.kill()
144
+ process.exit(0)
145
+ })
146
+ }
147
+
148
+ export async function githubTail(options: GithubTailOptions = {}) {
149
+ const {
150
+ workflowName = 'CI',
151
+ jobName = 'build-test-and-deploy',
152
+ showOnlyFailed = false,
153
+ forceWatch = false,
154
+ } = options
155
+
156
+ try {
157
+ let latestCommit = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim()
158
+ let shortCommit = latestCommit.substring(0, 7)
159
+
160
+ console.info(`Fetching CI logs for commit: ${shortCommit}`)
161
+
162
+ let runsJson = execSync(
163
+ `gh run list --commit ${latestCommit} --json databaseId,name,status,conclusion --limit 5`,
164
+ { encoding: 'utf-8' }
165
+ )
166
+ let runs = JSON.parse(runsJson)
167
+ let ciRun = runs.find((r: any) => r.name === workflowName)
168
+
169
+ if (!ciRun) {
170
+ console.info('No CI run found for local commit, checking remote...')
171
+
172
+ latestCommit = execSync('git rev-parse origin/main', { encoding: 'utf-8' }).trim()
173
+ shortCommit = latestCommit.substring(0, 7)
174
+
175
+ console.info(`Fetching CI logs for remote commit: ${shortCommit}`)
176
+
177
+ runsJson = execSync(
178
+ `gh run list --commit ${latestCommit} --json databaseId,name,status,conclusion --limit 5`,
179
+ { encoding: 'utf-8' }
180
+ )
181
+ runs = JSON.parse(runsJson)
182
+ ciRun = runs.find((r: any) => r.name === workflowName)
183
+
184
+ if (!ciRun) {
185
+ console.info('No CI run for remote HEAD, getting latest CI run...')
186
+ runsJson = execSync(
187
+ `gh run list --workflow=${workflowName} --json databaseId,name,status,conclusion,headSha --limit 1`,
188
+ { encoding: 'utf-8' }
189
+ )
190
+ runs = JSON.parse(runsJson)
191
+ ciRun = runs[0]
192
+
193
+ if (ciRun) {
194
+ shortCommit = ciRun.headSha?.substring(0, 7) || 'unknown'
195
+ console.info(`Using latest CI run for commit: ${shortCommit}`)
196
+ }
197
+ }
198
+ }
199
+
200
+ if (!ciRun) {
201
+ console.error('No CI runs found')
202
+ process.exit(1)
203
+ }
204
+
205
+ console.info(`CI Run: ${ciRun.status} (${ciRun.conclusion || 'in progress'})`)
206
+
207
+ const jobsJson = execSync(`gh run view ${ciRun.databaseId} --json jobs`, {
208
+ encoding: 'utf-8',
209
+ })
210
+ const { jobs } = JSON.parse(jobsJson)
211
+ const buildJob = jobs.find((j: any) => j.name === jobName)
212
+
213
+ if (!buildJob) {
214
+ console.error(`No ${jobName} job found`)
215
+ process.exit(1)
216
+ }
217
+
218
+ console.info(
219
+ `\nJob: ${buildJob.name} - ${buildJob.status} (${buildJob.conclusion || 'in progress'})`
220
+ )
221
+ console.info('─'.repeat(80))
222
+
223
+ if (buildJob.status === 'completed' && !forceWatch) {
224
+ const logFlag =
225
+ showOnlyFailed || buildJob.conclusion === 'failure' ? '--log-failed' : '--log'
226
+
227
+ if (buildJob.conclusion === 'failure') {
228
+ console.info('\nāŒ Job failed! Showing failed steps...\n')
229
+ } else if (showOnlyFailed) {
230
+ console.info('\nšŸ” Showing only failed steps...\n')
231
+ }
232
+
233
+ execSync(`gh run view --job ${buildJob.databaseId} ${logFlag}`, {
234
+ stdio: 'inherit',
235
+ })
236
+
237
+ process.exit(buildJob.conclusion === 'success' ? 0 : 1)
238
+ } else {
239
+ await watchJob(ciRun.databaseId, buildJob.databaseId, jobName, showOnlyFailed)
240
+ }
241
+ } catch (error: any) {
242
+ if (error.status === 1 && error.stdout?.includes('No jobs in progress')) {
243
+ console.info('Job completed. Run again to see full logs.')
244
+ } else {
245
+ console.error('Error fetching CI logs:', error.message)
246
+ process.exit(1)
247
+ }
248
+ }
249
+ }
@@ -0,0 +1,174 @@
1
+ import { writeFileSync } from 'node:fs'
2
+
3
+ import { sleep } from '@take-out/helpers'
4
+
5
+ import { run } from './run'
6
+
7
+ export interface MultipassConfig {
8
+ vmName: string
9
+ sshKeyPath: string
10
+ }
11
+
12
+ export function createMultipassConfig(
13
+ name: string,
14
+ sshKeyPath?: string
15
+ ): MultipassConfig {
16
+ return {
17
+ vmName: name,
18
+ sshKeyPath: sshKeyPath || `${process.env.HOME}/.ssh/${name}`,
19
+ }
20
+ }
21
+
22
+ export async function checkMultipass(): Promise<void> {
23
+ try {
24
+ await run('multipass --version', { silent: true })
25
+ console.info('āœ… multipass installed')
26
+ } catch {
27
+ throw new Error('multipass not found - install: brew install multipass')
28
+ }
29
+ }
30
+
31
+ export async function getVMIP(config: MultipassConfig): Promise<string | null> {
32
+ try {
33
+ const { stdout } = await run(`multipass info ${config.vmName} --format json`, {
34
+ silent: true,
35
+ captureOutput: true,
36
+ })
37
+ const data = JSON.parse(stdout)
38
+ return data.info[config.vmName]?.ipv4?.[0] || null
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ export async function vmExists(config: MultipassConfig): Promise<boolean> {
45
+ try {
46
+ const { stdout } = await run('multipass list', { silent: true, captureOutput: true })
47
+ return stdout.includes(config.vmName)
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ export async function createVM(config: MultipassConfig): Promise<void> {
54
+ console.info('\nšŸ–„ļø creating multipass vm...\n')
55
+
56
+ if (await vmExists(config)) {
57
+ console.info(`āœ… vm '${config.vmName}' already exists`)
58
+ return
59
+ }
60
+
61
+ const cloudInit = `#cloud-config
62
+ package_update: true
63
+ package_upgrade: true
64
+ packages:
65
+ - curl
66
+ `
67
+
68
+ const cloudInitPath = `${process.cwd()}/.cloud-init.yml`
69
+ writeFileSync(cloudInitPath, cloudInit)
70
+
71
+ try {
72
+ await run(
73
+ `multipass launch --name ${config.vmName} --cpus 4 --memory 4G --disk 20G --cloud-init ${cloudInitPath}`
74
+ )
75
+ } finally {
76
+ await run(`rm -f ${cloudInitPath}`, { silent: true })
77
+ }
78
+
79
+ console.info('\nā³ waiting for vm to be ready...')
80
+
81
+ let attempts = 0
82
+ while (attempts < 30) {
83
+ try {
84
+ const { stdout } = await run(`multipass info ${config.vmName} --format json`, {
85
+ silent: true,
86
+ captureOutput: true,
87
+ })
88
+ const data = JSON.parse(stdout)
89
+ if (data.info[config.vmName]?.state === 'Running') {
90
+ break
91
+ }
92
+ } catch {
93
+ // vm not ready yet
94
+ }
95
+ await sleep(2000)
96
+ attempts++
97
+ }
98
+
99
+ // wait for cloud-init
100
+ console.info('waiting for cloud-init to complete...')
101
+ await sleep(10000)
102
+
103
+ try {
104
+ await run(`multipass exec ${config.vmName} -- timeout 300 cloud-init status --wait`)
105
+ console.info('āœ… cloud-init complete')
106
+ } catch {
107
+ console.info('āš ļø cloud-init status check timed out, continuing...')
108
+ }
109
+
110
+ console.info('āœ… vm ready')
111
+ }
112
+
113
+ export async function setupVMSSH(config: MultipassConfig): Promise<void> {
114
+ console.info('\nšŸ”‘ setting up ssh...\n')
115
+
116
+ try {
117
+ await run(`test -f ${config.sshKeyPath}`, { silent: true })
118
+ console.info('āœ… ssh key exists')
119
+ } catch {
120
+ console.info('generating ssh key...')
121
+ await run(`ssh-keygen -t rsa -b 4096 -f ${config.sshKeyPath} -N ""`, { silent: true })
122
+ console.info('āœ… ssh key generated')
123
+ }
124
+
125
+ const { stdout: pubKey } = await run(`cat ${config.sshKeyPath}.pub`, {
126
+ silent: true,
127
+ captureOutput: true,
128
+ })
129
+ await run(
130
+ `multipass exec ${config.vmName} -- sh -c 'mkdir -p ~/.ssh && echo "${pubKey.trim()}" >> ~/.ssh/authorized_keys'`
131
+ )
132
+ console.info('āœ… ssh key configured')
133
+ }
134
+
135
+ export async function setupPortForward(
136
+ config: MultipassConfig,
137
+ ports: { local: number; containerName: string; containerPort: number }[]
138
+ ): Promise<void> {
139
+ console.info('\n🌐 setting up port forwarding...\n')
140
+
141
+ const ip = await getVMIP(config)
142
+ if (!ip) {
143
+ console.error('āŒ could not get vm ip')
144
+ return
145
+ }
146
+
147
+ const forwards: string[] = []
148
+ for (const { local, containerName, containerPort } of ports) {
149
+ const { stdout: containerIP } = await run(
150
+ `multipass exec ${config.vmName} -- sudo docker inspect $(multipass exec ${config.vmName} -- sudo docker ps -q -f name=${containerName}) -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'`,
151
+ { silent: true, captureOutput: true }
152
+ )
153
+ console.info(`${containerName} container: ${containerIP.trim()}:${containerPort}`)
154
+ forwards.push(`-L ${local}:${containerIP.trim()}:${containerPort}`)
155
+ }
156
+
157
+ // kill any existing port forwards
158
+ try {
159
+ await run(`pkill -f "ssh.*${config.vmName}"`, { silent: true })
160
+ } catch {
161
+ // ignore if none found
162
+ }
163
+
164
+ for (const { local, containerName, containerPort } of ports) {
165
+ console.info(`forwarding localhost:${local} -> ${containerName}:${containerPort}`)
166
+ }
167
+
168
+ await run(
169
+ `ssh -i ${config.sshKeyPath} -o StrictHostKeyChecking=no -f -N ${forwards.join(' ')} ubuntu@${ip}`,
170
+ { silent: true }
171
+ )
172
+
173
+ console.info('āœ… port forwarding active')
174
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * parse a .env file, handling multi-line quoted values (PEM keys etc)
3
+ */
4
+ export function parseEnvFile(
5
+ content: string,
6
+ options?: { allowedKeys?: string[] }
7
+ ): Record<string, string> {
8
+ const result: Record<string, string> = {}
9
+ const lines = content.split('\n')
10
+ let currentKey: string | null = null
11
+ let currentValue: string[] = []
12
+ let inMultiline = false
13
+
14
+ const save = () => {
15
+ if (!currentKey) return
16
+ if (options?.allowedKeys && !options.allowedKeys.includes(currentKey)) {
17
+ currentKey = null
18
+ currentValue = []
19
+ inMultiline = false
20
+ return
21
+ }
22
+ let value = currentValue.join('\n')
23
+ // remove surrounding quotes
24
+ if (
25
+ (value.startsWith('"') && value.endsWith('"')) ||
26
+ (value.startsWith("'") && value.endsWith("'"))
27
+ ) {
28
+ value = value.slice(1, -1)
29
+ }
30
+ value = value.replace(/\\"/g, '"')
31
+ result[currentKey] = value
32
+ currentKey = null
33
+ currentValue = []
34
+ inMultiline = false
35
+ }
36
+
37
+ for (const line of lines) {
38
+ if (inMultiline) {
39
+ currentValue.push(line)
40
+ if (line.trim().endsWith('"') || line.trim().endsWith("'")) {
41
+ save()
42
+ }
43
+ continue
44
+ }
45
+
46
+ const trimmed = line.trim()
47
+ if (!trimmed || trimmed.startsWith('#')) continue
48
+
49
+ const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/)
50
+ if (match && match[1]) {
51
+ if (currentKey) save()
52
+
53
+ currentKey = match[1]
54
+ const valueStart = match[2] || ''
55
+
56
+ if (
57
+ (valueStart.startsWith('"') && !valueStart.endsWith('"')) ||
58
+ (valueStart.startsWith("'") && !valueStart.endsWith("'"))
59
+ ) {
60
+ inMultiline = true
61
+ currentValue = [valueStart]
62
+ } else {
63
+ currentValue = [valueStart]
64
+ save()
65
+ }
66
+ }
67
+ }
68
+ if (currentKey) save()
69
+
70
+ return result
71
+ }
@@ -0,0 +1,44 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+
3
+ // collapse multi-line values (like PEM keys) to single line for yaml compatibility
4
+ function yamlSafe(value: string): string {
5
+ if (value.includes('\n')) {
6
+ return value.replace(/\n/g, '\\n')
7
+ }
8
+ return value
9
+ }
10
+
11
+ /**
12
+ * processes docker-compose file, replacing ${VAR:-default} with actual env values.
13
+ * handles multi-line values (PEM keys etc) by escaping newlines for yaml.
14
+ */
15
+ export function processComposeEnv(
16
+ composeFile: string,
17
+ outputFile: string,
18
+ envVars: Record<string, string | undefined>
19
+ ): void {
20
+ let content = readFileSync(composeFile, 'utf-8')
21
+
22
+ // replace all ${VAR:-default} patterns with actual env values
23
+ content = content.replace(
24
+ /\$\{([A-Z_][A-Z0-9_]*):-([^}]*)\}/g,
25
+ (_match, varName, defaultValue) => {
26
+ const value = envVars[varName]
27
+ if (value) {
28
+ console.info(
29
+ ` ${varName}: ${value.slice(0, 50)}${value.length > 50 ? '...' : ''}`
30
+ )
31
+ }
32
+ return value ? yamlSafe(value) : defaultValue
33
+ }
34
+ )
35
+
36
+ // replace standalone ${VAR} patterns
37
+ content = content.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_match, varName) => {
38
+ const value = envVars[varName]
39
+ return value ? yamlSafe(value) : _match
40
+ })
41
+
42
+ writeFileSync(outputFile, content)
43
+ console.info(`āœ… processed compose file: ${outputFile}`)
44
+ }
@@ -0,0 +1,23 @@
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
+ export async function testSSHConnection(host: string, sshKey: string): Promise<void> {
13
+ console.info('\nšŸ”‘ testing ssh connection...\n')
14
+
15
+ try {
16
+ await run(
17
+ `ssh -i ${sshKey} -o StrictHostKeyChecking=no -o ConnectTimeout=5 ${host} "echo 'SSH connection successful'"`
18
+ )
19
+ console.info('āœ… ssh connection verified')
20
+ } catch {
21
+ throw new Error(`cannot connect to ${host} - check ssh key: ${sshKey}`)
22
+ }
23
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * generic uncloud deployment helpers for ci/cd
3
+ */
4
+
5
+ import { existsSync } from 'node:fs'
6
+ import { mkdir, writeFile } from 'node:fs/promises'
7
+ import { homedir } from 'node:os'
8
+ import { join } from 'node:path'
9
+
10
+ import { time } from '@take-out/helpers'
11
+
12
+ import { run } from './run'
13
+
14
+ export async function checkUncloudConfigured(): Promise<boolean> {
15
+ return Boolean(process.env.DEPLOY_HOST && process.env.DEPLOY_DB)
16
+ }
17
+
18
+ export async function verifyDatabaseConfig(): Promise<{
19
+ valid: boolean
20
+ errors: string[]
21
+ }> {
22
+ const errors: string[] = []
23
+
24
+ const deployDb = process.env.DEPLOY_DB
25
+ const upstreamDb = process.env.ZERO_UPSTREAM_DB
26
+ const cvrDb = process.env.ZERO_CVR_DB
27
+ const changeDb = process.env.ZERO_CHANGE_DB
28
+
29
+ if (!deployDb) errors.push('DEPLOY_DB is not set')
30
+ if (!upstreamDb) errors.push('ZERO_UPSTREAM_DB is not set')
31
+ if (!cvrDb) errors.push('ZERO_CVR_DB is not set')
32
+ if (!changeDb) errors.push('ZERO_CHANGE_DB is not set')
33
+
34
+ if (errors.length > 0) {
35
+ return { valid: false, errors }
36
+ }
37
+
38
+ const getHost = (url: string): string | null => {
39
+ try {
40
+ const match = url.match(/@([^:/]+)/)
41
+ return match?.[1] || null
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ const deployHost = getHost(deployDb!)
48
+ const upstreamHost = getHost(upstreamDb!)
49
+ const cvrHost = getHost(cvrDb!)
50
+ const changeHost = getHost(changeDb!)
51
+
52
+ if (deployHost && upstreamHost && deployHost !== upstreamHost) {
53
+ errors.push(
54
+ `ZERO_UPSTREAM_DB host (${upstreamHost}) does not match DEPLOY_DB host (${deployHost})`
55
+ )
56
+ }
57
+ if (deployHost && cvrHost && deployHost !== cvrHost) {
58
+ errors.push(
59
+ `ZERO_CVR_DB host (${cvrHost}) does not match DEPLOY_DB host (${deployHost})`
60
+ )
61
+ }
62
+ if (deployHost && changeHost && deployHost !== changeHost) {
63
+ errors.push(
64
+ `ZERO_CHANGE_DB host (${changeHost}) does not match DEPLOY_DB host (${deployHost})`
65
+ )
66
+ }
67
+
68
+ return { valid: errors.length === 0, errors }
69
+ }
70
+
71
+ export async function installUncloudCLI(version = '0.16.0') {
72
+ console.info('šŸ”§ checking uncloud cli...')
73
+ try {
74
+ await run('uc --version', { silent: true, timeout: time.ms.seconds(5) })
75
+ console.info(' uncloud cli already installed')
76
+ } catch {
77
+ console.info(` installing uncloud cli v${version}...`)
78
+ await run(
79
+ `curl -fsS https://get.uncloud.run/install.sh | sh -s -- --version ${version}`,
80
+ { timeout: time.ms.seconds(30) }
81
+ )
82
+ console.info(' āœ“ uncloud cli installed')
83
+ }
84
+ }
85
+
86
+ export async function setupSSHKey() {
87
+ if (!process.env.DEPLOY_SSH_KEY) {
88
+ return
89
+ }
90
+
91
+ const sshKeyValue = process.env.DEPLOY_SSH_KEY
92
+
93
+ // check if it's a path to an existing file (local usage) or key content (CI usage)
94
+ if (existsSync(sshKeyValue)) {
95
+ console.info(` using ssh key from: ${sshKeyValue}`)
96
+ return
97
+ }
98
+
99
+ // CI usage - DEPLOY_SSH_KEY contains the actual key content
100
+ console.info('šŸ”‘ setting up ssh key from environment...')
101
+ const sshDir = join(homedir(), '.ssh')
102
+ const keyPath = join(sshDir, 'uncloud_deploy')
103
+
104
+ if (!existsSync(sshDir)) {
105
+ await mkdir(sshDir, { recursive: true })
106
+ }
107
+
108
+ // decode base64-encoded keys (github secrets often store keys as base64)
109
+ let keyContent = sshKeyValue
110
+ if (
111
+ !sshKeyValue.includes('-----BEGIN') &&
112
+ /^[A-Za-z0-9+/=\s]+$/.test(sshKeyValue.trim())
113
+ ) {
114
+ console.info(' detected base64-encoded key, decoding...')
115
+ keyContent = Buffer.from(sshKeyValue.trim(), 'base64').toString('utf-8')
116
+ }
117
+
118
+ // ensure trailing newline (github secrets can strip it)
119
+ if (!keyContent.endsWith('\n')) {
120
+ keyContent += '\n'
121
+ }
122
+
123
+ await writeFile(keyPath, keyContent, { mode: 0o600 })
124
+
125
+ // add host to known_hosts
126
+ if (process.env.DEPLOY_HOST) {
127
+ try {
128
+ await run(
129
+ `ssh-keyscan -H ${process.env.DEPLOY_HOST} >> ${join(sshDir, 'known_hosts')}`,
130
+ { silent: true, timeout: time.ms.seconds(10) }
131
+ )
132
+ } catch {
133
+ // ignore - ssh will prompt if needed
134
+ }
135
+ }
136
+
137
+ // override env var to point to the file we created
138
+ process.env.DEPLOY_SSH_KEY = keyPath
139
+ console.info(` ssh key written to ${keyPath}`)
140
+ }
@@ -0,0 +1,187 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import { run } from './run'
6
+
7
+ export interface UncloudConfig {
8
+ context?: string
9
+ }
10
+
11
+ function ucCmd(config?: UncloudConfig): string {
12
+ return config?.context ? `uc -c ${config.context}` : 'uc'
13
+ }
14
+
15
+ export async function checkUncloudCLI(): Promise<void> {
16
+ try {
17
+ const { stdout } = await run('uc --version', { silent: true, captureOutput: true })
18
+ console.info(`āœ… uncloud cli installed (${stdout.trim()})`)
19
+ } catch {
20
+ throw new Error(
21
+ `uncloud cli not found - install: curl -fsS https://get.uncloud.run/install.sh | sh`
22
+ )
23
+ }
24
+ }
25
+
26
+ function ensureUncloudContext(host: string, sshKey: string, contextName: string): void {
27
+ const configDir = join(homedir(), '.config', 'uncloud')
28
+ const configPath = join(configDir, 'config.yaml')
29
+
30
+ if (!existsSync(configDir)) {
31
+ mkdirSync(configDir, { recursive: true })
32
+ }
33
+
34
+ // check if config already has context pointing to correct host
35
+ if (existsSync(configPath)) {
36
+ const existing = readFileSync(configPath, 'utf-8')
37
+ if (existing.includes(`${contextName}:`) && existing.includes(host.split('@')[1])) {
38
+ console.info(`āœ… uncloud config already has ${contextName} context for ${host}`)
39
+ return
40
+ }
41
+ }
42
+
43
+ // only create config if it doesn't exist - don't overwrite existing config
44
+ // which may have other contexts
45
+ if (!existsSync(configPath)) {
46
+ const config = `current_context: ${contextName}
47
+ contexts:
48
+ ${contextName}:
49
+ connections:
50
+ - ssh: ${host}
51
+ ssh_key_file: ${sshKey}
52
+ `
53
+ writeFileSync(configPath, config)
54
+ console.info(`āœ… created uncloud config at ${configPath}`)
55
+ } else {
56
+ console.info(`āœ… using existing uncloud config (context: ${contextName})`)
57
+ }
58
+ }
59
+
60
+ export async function initUncloud(
61
+ host: string,
62
+ sshKey: string,
63
+ options: { noDNS?: boolean; noCaddy?: boolean; context?: string } = {}
64
+ ): Promise<void> {
65
+ console.info('\nšŸš€ initializing uncloud...\n')
66
+
67
+ const uc = ucCmd(options)
68
+ const contextName = options.context || 'default'
69
+
70
+ // check if we already have local context that works
71
+ try {
72
+ await run(`${uc} ls`, { silent: true })
73
+ console.info('āœ… local cluster context exists and connected')
74
+ return
75
+ } catch {
76
+ // no local context - check if server has uncloud running
77
+ }
78
+
79
+ const sshCmd = `ssh -i ${sshKey} -o StrictHostKeyChecking=no ${host}`
80
+
81
+ // check if server already has uncloud daemon running
82
+ let serverHasUncloud = false
83
+ try {
84
+ await run(`${sshCmd} "systemctl is-active uncloud.service"`, { silent: true })
85
+ serverHasUncloud = true
86
+ console.info('āœ… server has uncloud daemon running')
87
+ } catch {
88
+ try {
89
+ await run(`${sshCmd} "test -f /usr/local/bin/uncloudd"`, { silent: true })
90
+ serverHasUncloud = true
91
+ console.info('āœ… server has uncloud installed')
92
+ } catch {
93
+ // server doesn't have uncloud - needs fresh init
94
+ }
95
+ }
96
+
97
+ if (serverHasUncloud) {
98
+ ensureUncloudContext(host, sshKey, contextName)
99
+ console.info(' (skipping init to avoid cluster reset)')
100
+ return
101
+ }
102
+
103
+ // fresh server - need to initialize
104
+ console.info('šŸ“¦ uncloud not installed on server - initializing...')
105
+
106
+ const flags: string[] = []
107
+ if (options.noDNS) flags.push('--no-dns')
108
+ if (options.noCaddy) flags.push('--no-caddy')
109
+ const flagStr = flags.join(' ')
110
+
111
+ await run(`echo "y" | uc machine init ${host} -i ${sshKey} ${flagStr}`)
112
+
113
+ console.info('āœ… uncloud initialized')
114
+ }
115
+
116
+ export async function pushImage(
117
+ imageName: string,
118
+ config?: UncloudConfig
119
+ ): Promise<void> {
120
+ console.info('\nšŸ“¤ pushing image to cluster...\n')
121
+
122
+ await run(`${ucCmd(config)} image push ${imageName}`)
123
+ console.info('āœ… image pushed')
124
+ }
125
+
126
+ export async function deployStack(
127
+ composeFile: string,
128
+ options?: { profile?: string } & UncloudConfig
129
+ ): Promise<void> {
130
+ console.info('\nšŸ“¦ deploying stack...\n')
131
+
132
+ const profileFlag = options?.profile ? `--profile ${options.profile}` : ''
133
+ // --recreate ensures containers are recreated even if config unchanged (pulls fresh images)
134
+ await run(`${ucCmd(options)} deploy -f ${composeFile} ${profileFlag} --recreate --yes`)
135
+
136
+ console.info('\nāœ… deployment complete!')
137
+ }
138
+
139
+ export async function showStatus(config?: UncloudConfig): Promise<void> {
140
+ console.info('\nšŸ“Š deployment status:\n')
141
+
142
+ try {
143
+ await run(`${ucCmd(config)} ls`)
144
+ } catch {
145
+ console.error('could not fetch status')
146
+ }
147
+ }
148
+
149
+ export async function showContainers(config?: UncloudConfig): Promise<void> {
150
+ console.info('\nšŸ“¦ container status:\n')
151
+
152
+ try {
153
+ await run(`${ucCmd(config)} ps`)
154
+ } catch {
155
+ console.error('could not fetch container status')
156
+ }
157
+ }
158
+
159
+ export async function startService(
160
+ service?: string,
161
+ config?: UncloudConfig
162
+ ): Promise<void> {
163
+ const target = service || 'all services'
164
+ console.info(`\nā–¶ļø starting ${target}...\n`)
165
+
166
+ try {
167
+ await run(`${ucCmd(config)} start${service ? ` ${service}` : ''}`)
168
+ console.info(`āœ… ${target} started`)
169
+ } catch {
170
+ throw new Error(`failed to start ${target}`)
171
+ }
172
+ }
173
+
174
+ export async function stopService(
175
+ service?: string,
176
+ config?: UncloudConfig
177
+ ): Promise<void> {
178
+ const target = service || 'all services'
179
+ console.info(`\nā¹ļø stopping ${target}...\n`)
180
+
181
+ try {
182
+ await run(`${ucCmd(config)} stop${service ? ` ${service}` : ''}`)
183
+ console.info(`āœ… ${target} stopped`)
184
+ } catch {
185
+ throw new Error(`failed to stop ${target}`)
186
+ }
187
+ }