@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,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
|
+
}
|