@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,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 fs from 'node:fs'
2
+
3
+ import { sleep } from '@o/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
+ fs.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,57 @@
1
+ import fs from 'node:fs'
2
+
3
+ // make values safe for yaml map syntax (KEY: value)
4
+ // handles multi-line values, special chars that break yaml parsing
5
+ function yamlSafe(value: string): string {
6
+ const needsQuoting =
7
+ value.includes('\n') ||
8
+ value.includes(':') ||
9
+ value.includes('#') ||
10
+ value.startsWith('-') ||
11
+ value.startsWith('{') ||
12
+ value.startsWith('[') ||
13
+ value.startsWith('"') ||
14
+ value.startsWith("'")
15
+
16
+ if (!needsQuoting) return value
17
+
18
+ // escape backslashes and double quotes, collapse newlines
19
+ const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
20
+
21
+ return `"${escaped}"`
22
+ }
23
+
24
+ /**
25
+ * processes docker-compose file, replacing ${VAR:-default} with actual env values.
26
+ * handles multi-line values (PEM keys etc) by escaping newlines for yaml.
27
+ */
28
+ export function processComposeEnv(
29
+ composeFile: string,
30
+ outputFile: string,
31
+ envVars: Record<string, string | undefined>
32
+ ): void {
33
+ let content = fs.readFileSync(composeFile, 'utf-8')
34
+
35
+ // replace all ${VAR:-default} patterns with actual env values
36
+ content = content.replace(
37
+ /\$\{([A-Z_][A-Z0-9_]*):-([^}]*)\}/g,
38
+ (_match, varName, defaultValue) => {
39
+ const value = envVars[varName]
40
+ if (value) {
41
+ console.info(
42
+ ` ${varName}: ${value.slice(0, 50)}${value.length > 50 ? '...' : ''}`
43
+ )
44
+ }
45
+ return value ? yamlSafe(value) : defaultValue
46
+ }
47
+ )
48
+
49
+ // replace standalone ${VAR} patterns
50
+ content = content.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_match, varName) => {
51
+ const value = envVars[varName]
52
+ return value ? yamlSafe(value) : _match
53
+ })
54
+
55
+ fs.writeFileSync(outputFile, content)
56
+ console.info(`✅ processed compose file: ${outputFile}`)
57
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * resolves a `$dep:package-name` value to the installed version from dependencies
3
+ */
4
+ export function resolveDepVersion(
5
+ value: string | boolean,
6
+ dependencies: Record<string, string> | undefined
7
+ ): string | undefined {
8
+ if (typeof value !== 'string' || !value.startsWith('$dep:')) return undefined
9
+ const depName = value.slice('$dep:'.length)
10
+ return dependencies?.[depName]?.replace(/^[\^~]/, '')
11
+ }