@open-mercato/starter 0.6.5
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/README.md +46 -0
- package/bin/om-start.mjs +78 -0
- package/package.json +27 -0
- package/platform/start.cmd +24 -0
- package/platform/start.ps1 +174 -0
- package/platform/start.sh +112 -0
- package/src/__tests__/starter.test.mjs +359 -0
- package/src/certs.mjs +266 -0
- package/src/cli.mjs +355 -0
- package/src/company.mjs +80 -0
- package/src/compose.mjs +144 -0
- package/src/constants.mjs +85 -0
- package/src/doctor.mjs +426 -0
- package/src/env-file.mjs +75 -0
- package/src/env-setup.mjs +119 -0
- package/src/infra.mjs +85 -0
- package/src/providers.mjs +189 -0
- package/src/secrets.mjs +11 -0
- package/src/spawn.mjs +89 -0
- package/src/steps.mjs +613 -0
- package/src/supervise.mjs +220 -0
- package/src/ui.mjs +122 -0
- package/src/waits.mjs +102 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { DEV_LOG_DIR, RUN_STATE_DIR, resolveStackPorts, stackUrls } from './constants.mjs'
|
|
6
|
+
import { color, icons } from './ui.mjs'
|
|
7
|
+
|
|
8
|
+
// Detached ("PM2-like") lifecycle for the host processes. `yarn dev` is the
|
|
9
|
+
// actual supervisor (it restarts the app and the MCP server with backoff);
|
|
10
|
+
// this module only detaches it, tracks the pid, and exposes stop/status/logs.
|
|
11
|
+
|
|
12
|
+
const PID_FILE = 'dev.json'
|
|
13
|
+
|
|
14
|
+
function runStatePath(repoRoot) {
|
|
15
|
+
return path.join(repoRoot, RUN_STATE_DIR, PID_FILE)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readRunState(repoRoot) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(fs.readFileSync(runStatePath(repoRoot), 'utf8'))
|
|
21
|
+
return typeof parsed?.pid === 'number' ? parsed : null
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isPidAlive(pid) {
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, 0)
|
|
30
|
+
return true
|
|
31
|
+
} catch (error) {
|
|
32
|
+
// EPERM means the process exists but belongs to someone else — count it
|
|
33
|
+
// as alive so we never clobber a foreign pid.
|
|
34
|
+
return error?.code === 'EPERM'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function startDetached(repoRoot, devArgs = [], { env = process.env, log = console.log } = {}) {
|
|
39
|
+
const existing = readRunState(repoRoot)
|
|
40
|
+
if (existing && isPidAlive(existing.pid)) {
|
|
41
|
+
log(`✅ Dev runtime already running (pid ${existing.pid}). Use \`status\` / \`logs\` / \`stop\`.`)
|
|
42
|
+
return existing.pid
|
|
43
|
+
}
|
|
44
|
+
fs.mkdirSync(path.join(repoRoot, RUN_STATE_DIR), { recursive: true })
|
|
45
|
+
fs.mkdirSync(path.join(repoRoot, DEV_LOG_DIR), { recursive: true })
|
|
46
|
+
const logFile = path.join(repoRoot, DEV_LOG_DIR, 'detached-dev.log')
|
|
47
|
+
const outFd = fs.openSync(logFile, 'a')
|
|
48
|
+
const child = spawn(process.execPath, [path.join(repoRoot, 'scripts', 'dev.mjs'), ...devArgs], {
|
|
49
|
+
cwd: repoRoot,
|
|
50
|
+
detached: true,
|
|
51
|
+
stdio: ['ignore', outFd, outFd],
|
|
52
|
+
env: {
|
|
53
|
+
...env,
|
|
54
|
+
// Headless: no browser popup, no interactive assumptions.
|
|
55
|
+
OM_DEV_AUTO_OPEN: '0',
|
|
56
|
+
FORCE_COLOR: '0',
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
fs.closeSync(outFd)
|
|
60
|
+
child.unref()
|
|
61
|
+
fs.writeFileSync(runStatePath(repoRoot), JSON.stringify({ pid: child.pid, startedAt: new Date().toISOString(), args: devArgs, logFile }, null, 2))
|
|
62
|
+
log(`✅ Dev runtime started in the background (pid ${child.pid}).`)
|
|
63
|
+
log(` logs: ${path.relative(repoRoot, logFile)} (or \`logs --follow\`)`)
|
|
64
|
+
return child.pid
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function killTree(pid) {
|
|
68
|
+
if (process.platform === 'win32') {
|
|
69
|
+
// Windows never propagates signals to grandchildren — kill the whole tree.
|
|
70
|
+
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
// detached:true made the child a process-group leader; signal the group so
|
|
74
|
+
// yarn -> turbo -> next descendants all get SIGTERM.
|
|
75
|
+
try {
|
|
76
|
+
process.kill(-pid, 'SIGTERM')
|
|
77
|
+
} catch {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, 'SIGTERM')
|
|
80
|
+
} catch {
|
|
81
|
+
// already gone
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function stopDetached(repoRoot, { log = console.log, graceMs = 8000 } = {}) {
|
|
87
|
+
const state = readRunState(repoRoot)
|
|
88
|
+
if (!state || !isPidAlive(state.pid)) {
|
|
89
|
+
if (state) fs.rmSync(runStatePath(repoRoot), { force: true })
|
|
90
|
+
log('ℹ️ No detached dev runtime is running.')
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
log(`Stopping dev runtime (pid ${state.pid}) ...`)
|
|
94
|
+
killTree(state.pid)
|
|
95
|
+
const deadline = Date.now() + graceMs
|
|
96
|
+
while (Date.now() < deadline && isPidAlive(state.pid)) {
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
98
|
+
}
|
|
99
|
+
if (isPidAlive(state.pid) && process.platform !== 'win32') {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(-state.pid, 'SIGKILL')
|
|
102
|
+
} catch {
|
|
103
|
+
// gone
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const stopped = !isPidAlive(state.pid)
|
|
107
|
+
if (stopped) {
|
|
108
|
+
fs.rmSync(runStatePath(repoRoot), { force: true })
|
|
109
|
+
log('✅ Dev runtime stopped.')
|
|
110
|
+
} else {
|
|
111
|
+
log(`❌ Process ${state.pid} survived SIGKILL — inspect it manually.`)
|
|
112
|
+
}
|
|
113
|
+
return stopped
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function fetchJson(url, timeoutMs = 3000) {
|
|
117
|
+
try {
|
|
118
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
119
|
+
if (!response.ok) return null
|
|
120
|
+
return await response.json()
|
|
121
|
+
} catch {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function probeHttp(url, timeoutMs = 3000) {
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
129
|
+
return response.ok
|
|
130
|
+
} catch {
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function collectStatus(repoRoot, { runCompose }) {
|
|
136
|
+
const ports = resolveStackPorts(repoRoot)
|
|
137
|
+
const urls = stackUrls(ports)
|
|
138
|
+
const state = readRunState(repoRoot)
|
|
139
|
+
const detachedAlive = state ? isPidAlive(state.pid) : false
|
|
140
|
+
|
|
141
|
+
const [appUp, mcpUp, opencodeUp, splash] = await Promise.all([
|
|
142
|
+
probeHttp(urls.app),
|
|
143
|
+
probeHttp(urls.mcpHealth),
|
|
144
|
+
probeHttp(urls.opencodeHealth),
|
|
145
|
+
fetchJson(`${urls.splash}/status`),
|
|
146
|
+
])
|
|
147
|
+
|
|
148
|
+
let containers = []
|
|
149
|
+
try {
|
|
150
|
+
const psResult = runCompose(repoRoot, ['ps', '--format', 'json'], { stdio: 'pipe' })
|
|
151
|
+
const { parseComposePsOutput } = await import('./compose.mjs')
|
|
152
|
+
containers = parseComposePsOutput(psResult.stdout).map((entry) => ({
|
|
153
|
+
service: entry.Service ?? entry.Name ?? 'unknown',
|
|
154
|
+
state: entry.State ?? 'unknown',
|
|
155
|
+
health: entry.Health ?? '',
|
|
156
|
+
}))
|
|
157
|
+
} catch {
|
|
158
|
+
containers = []
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { ports, urls, detached: state && detachedAlive ? state : null, appUp, mcpUp, opencodeUp, splash, containers }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function printStatus(status, { log = console.log } = {}) {
|
|
165
|
+
log('')
|
|
166
|
+
log(color.bold('Open Mercato — stack status'))
|
|
167
|
+
log('')
|
|
168
|
+
const flag = (up) => (up ? `${icons.ok} up ` : `${icons.fail} down`)
|
|
169
|
+
log(` app ${flag(status.appUp)} ${color.cyan(status.urls.app)}`)
|
|
170
|
+
log(` mcp ${flag(status.mcpUp)} ${color.cyan(status.urls.mcpHealth)}`)
|
|
171
|
+
log(` opencode ${flag(status.opencodeUp)} ${color.cyan(status.urls.opencodeHealth)}`)
|
|
172
|
+
if (status.detached) log(` supervisor ${icons.ok} detached (pid ${status.detached.pid}, since ${status.detached.startedAt})`)
|
|
173
|
+
else log(` supervisor ${icons.info} not detached (foreground \`yarn dev\` or stopped)`)
|
|
174
|
+
if (status.splash?.phase || status.splash?.activity) {
|
|
175
|
+
log(` runtime ${status.splash.phase ?? ''}${status.splash.activity ? ` — ${status.splash.activity}` : ''}`)
|
|
176
|
+
}
|
|
177
|
+
log('')
|
|
178
|
+
if (status.containers.length === 0) {
|
|
179
|
+
log(` containers: none running ${color.dim('(start them with `up`)')}`)
|
|
180
|
+
} else {
|
|
181
|
+
for (const container of status.containers) {
|
|
182
|
+
const health = container.health ? ` (${container.health})` : ''
|
|
183
|
+
const healthy = container.state === 'running' && (!container.health || container.health === 'healthy')
|
|
184
|
+
log(` container ${healthy ? icons.ok : icons.warn} ${container.service.padEnd(14)} ${container.state}${health}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
log('')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function tailLogs(repoRoot, { follow = false, lines = 60, log = console.log } = {}) {
|
|
191
|
+
const logDir = path.join(repoRoot, DEV_LOG_DIR)
|
|
192
|
+
let files = []
|
|
193
|
+
try {
|
|
194
|
+
files = fs.readdirSync(logDir)
|
|
195
|
+
.filter((name) => name.endsWith('.log'))
|
|
196
|
+
.map((name) => ({ name, file: path.join(logDir, name), mtime: fs.statSync(path.join(logDir, name)).mtimeMs }))
|
|
197
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
198
|
+
} catch {
|
|
199
|
+
files = []
|
|
200
|
+
}
|
|
201
|
+
if (files.length === 0) {
|
|
202
|
+
log(`ℹ️ No logs yet under ${path.relative(repoRoot, logDir)} — start the stack first.`)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
const newest = files[0]
|
|
206
|
+
const printTail = (fromSize = null) => {
|
|
207
|
+
const content = fs.readFileSync(newest.file, 'utf8')
|
|
208
|
+
const slice = fromSize === null
|
|
209
|
+
? content.split('\n').slice(-lines).join('\n')
|
|
210
|
+
: content.slice(fromSize)
|
|
211
|
+
if (slice.trim()) log(slice.replace(/\n$/, ''))
|
|
212
|
+
return content.length
|
|
213
|
+
}
|
|
214
|
+
log(`── ${path.relative(repoRoot, newest.file)} ──`)
|
|
215
|
+
let size = printTail()
|
|
216
|
+
if (!follow) return
|
|
217
|
+
fs.watchFile(newest.file, { interval: 500 }, () => {
|
|
218
|
+
size = printTail(size)
|
|
219
|
+
})
|
|
220
|
+
}
|
package/src/ui.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Console UX for the starter: colors, banner, staged progress, spinners.
|
|
2
|
+
// Node stdlib only. Honors NO_COLOR and degrades cleanly when not a TTY
|
|
3
|
+
// (CI logs get plain, timestamped lines instead of animations).
|
|
4
|
+
|
|
5
|
+
const isTty = Boolean(process.stdout.isTTY)
|
|
6
|
+
const colorEnabled = isTty && !process.env.NO_COLOR && process.env.TERM !== 'dumb'
|
|
7
|
+
|
|
8
|
+
function paint(code) {
|
|
9
|
+
return (text) => (colorEnabled ? `[${code}m${text}[0m` : String(text))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const color = {
|
|
13
|
+
bold: paint('1'),
|
|
14
|
+
dim: paint('2'),
|
|
15
|
+
red: paint('31'),
|
|
16
|
+
green: paint('32'),
|
|
17
|
+
yellow: paint('33'),
|
|
18
|
+
blue: paint('34'),
|
|
19
|
+
magenta: paint('35'),
|
|
20
|
+
cyan: paint('36'),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The Open Mercato two-tone wordmark (carried over from the retired Windows
|
|
24
|
+
// launcher). Pure 7-bit ASCII on purpose: conhost and OEM code pages on
|
|
25
|
+
// managed Windows machines mangle box-drawing characters.
|
|
26
|
+
const LOGO_LEFT = [
|
|
27
|
+
' ___ ____ _____ _ _ ',
|
|
28
|
+
' / _ \\| _ \\| ____| \\ | |',
|
|
29
|
+
'| | | | |_) | _| | \\| |',
|
|
30
|
+
'| |_| | __/| |___| |\\ |',
|
|
31
|
+
' \\___/|_| |_____|_| \\_|',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
const LOGO_RIGHT = [
|
|
35
|
+
' __ __ _____ ____ ____ _ _____ ___',
|
|
36
|
+
'| \\/ | ____| _ \\ / ___| / \\|_ _/ _ \\',
|
|
37
|
+
'| |\\/| | _| | |_) | | / _ \\ | || | | |',
|
|
38
|
+
'| | | | |___| _ <| |___ / ___ \\| || |_| |',
|
|
39
|
+
'|_| |_|_____|_| \\_\\\\____/_/ \\_\\_| \\___/',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
export function printBanner(subtitle, { log = console.log } = {}) {
|
|
43
|
+
log('')
|
|
44
|
+
for (let index = 0; index < LOGO_LEFT.length; index++) {
|
|
45
|
+
log(` ${color.dim(color.cyan(LOGO_LEFT[index]))} ${color.cyan(LOGO_RIGHT[index])}`)
|
|
46
|
+
}
|
|
47
|
+
log('')
|
|
48
|
+
if (subtitle) log(color.dim(` ${subtitle}`))
|
|
49
|
+
log('')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function stepHeader(index, total, title, expectation, { log = console.log } = {}) {
|
|
53
|
+
log('')
|
|
54
|
+
log(`${color.bold(color.blue(`── Step ${index}/${total}`))} ${color.bold(title)}`)
|
|
55
|
+
if (expectation) log(color.dim(` Expected: ${expectation}`))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const icons = {
|
|
59
|
+
ok: color.green('OK'),
|
|
60
|
+
warn: color.yellow('!!'),
|
|
61
|
+
fail: color.red('XX'),
|
|
62
|
+
info: color.cyan('--'),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function statusLine(kind, message, { log = console.log } = {}) {
|
|
66
|
+
log(` ${icons[kind] ?? icons.info} ${message}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Framed remediation guide so "what do I do next" never drowns in output.
|
|
70
|
+
export function guideBox(title, lines, { log = console.log } = {}) {
|
|
71
|
+
log('')
|
|
72
|
+
log(color.yellow(color.bold(` ${title}`)))
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
log(line === '' ? '' : color.yellow(` | `) + line)
|
|
75
|
+
}
|
|
76
|
+
log('')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SPINNER_FRAMES = ['|', '/', '-', '\\']
|
|
80
|
+
|
|
81
|
+
// Spinner for long waits that produce no streamed output. In non-TTY contexts
|
|
82
|
+
// it prints the label once and then only meaningful updates via update().
|
|
83
|
+
export function createSpinner(label) {
|
|
84
|
+
let frame = 0
|
|
85
|
+
let currentLabel = label
|
|
86
|
+
let timer = null
|
|
87
|
+
const startedAt = Date.now()
|
|
88
|
+
|
|
89
|
+
const render = () => {
|
|
90
|
+
const elapsed = Math.round((Date.now() - startedAt) / 1000)
|
|
91
|
+
process.stdout.write(`\r ${color.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length])} ${currentLabel} ${color.dim(`(${elapsed}s)`)} `)
|
|
92
|
+
frame += 1
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (isTty) {
|
|
96
|
+
timer = setInterval(render, 120)
|
|
97
|
+
render()
|
|
98
|
+
} else {
|
|
99
|
+
console.log(` ... ${label}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
update(nextLabel) {
|
|
104
|
+
if (nextLabel === currentLabel) return
|
|
105
|
+
currentLabel = nextLabel
|
|
106
|
+
if (!isTty) console.log(` ... ${nextLabel}`)
|
|
107
|
+
},
|
|
108
|
+
stop(kind = null, message = null) {
|
|
109
|
+
if (timer) {
|
|
110
|
+
clearInterval(timer)
|
|
111
|
+
process.stdout.write('\r' + ' '.repeat(100) + '\r')
|
|
112
|
+
}
|
|
113
|
+
if (kind && message) statusLine(kind, message)
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatDuration(ms) {
|
|
119
|
+
const totalSeconds = Math.round(ms / 1000)
|
|
120
|
+
if (totalSeconds < 60) return `${totalSeconds}s`
|
|
121
|
+
return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`
|
|
122
|
+
}
|
package/src/waits.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { parseComposePsOutput, runCompose } from './compose.mjs'
|
|
2
|
+
import { createSpinner } from './ui.mjs'
|
|
3
|
+
|
|
4
|
+
// Health-wait loops for the fully containerized mode, ported from the retired
|
|
5
|
+
// Windows launcher (start-dev.ps1) so every platform gets the same sequencing:
|
|
6
|
+
// infra healthy -> app serving -> mcp + opencode healthy -> e2e MCP wiring.
|
|
7
|
+
// Crash-loop detection beats compose healthchecks here: the generous
|
|
8
|
+
// start_periods keep a wedged service "starting" for minutes, while a service
|
|
9
|
+
// stuck in restarting/exited flips visibly within three polls.
|
|
10
|
+
|
|
11
|
+
const POLL_INTERVAL_MS = 5000
|
|
12
|
+
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function serviceStates(repoRoot, composeFile) {
|
|
18
|
+
const ps = runCompose(repoRoot, ['ps', '-a', '--format', 'json'], { composeFile, stdio: 'pipe' })
|
|
19
|
+
const states = {}
|
|
20
|
+
for (const entry of parseComposePsOutput(ps.stdout ?? '')) {
|
|
21
|
+
if (!entry?.Service) continue
|
|
22
|
+
states[entry.Service] = { state: entry.State ?? 'unknown', health: entry.Health ?? '', exitCode: entry.ExitCode }
|
|
23
|
+
}
|
|
24
|
+
return states
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function dumpServiceLogs(repoRoot, composeFile, service, log) {
|
|
28
|
+
log(`── last 40 log lines of '${service}' ──`)
|
|
29
|
+
runCompose(repoRoot, ['logs', '--tail', '40', service], { composeFile, stdio: 'inherit' })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function waitForHealthyServices(repoRoot, composeFile, services, { timeoutMs = 5 * 60 * 1000, log = console.log } = {}) {
|
|
33
|
+
const crashTicks = {}
|
|
34
|
+
const deadline = Date.now() + timeoutMs
|
|
35
|
+
const spinner = createSpinner(`waiting for ${services.join(', ')}`)
|
|
36
|
+
try {
|
|
37
|
+
while (Date.now() < deadline) {
|
|
38
|
+
const states = serviceStates(repoRoot, composeFile)
|
|
39
|
+
const pending = []
|
|
40
|
+
for (const service of services) {
|
|
41
|
+
const entry = states[service]
|
|
42
|
+
if (!entry) {
|
|
43
|
+
pending.push(`${service} (not created)`)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
const crashed = entry.state === 'restarting' || (entry.state === 'exited' && Number(entry.exitCode ?? 0) !== 0)
|
|
47
|
+
crashTicks[service] = crashed ? (crashTicks[service] ?? 0) + 1 : 0
|
|
48
|
+
if (crashTicks[service] >= 3) {
|
|
49
|
+
spinner.stop('fail', `Service '${service}' is crash-looping (${entry.state}).`)
|
|
50
|
+
dumpServiceLogs(repoRoot, composeFile, service, log)
|
|
51
|
+
return { ok: false, failed: service }
|
|
52
|
+
}
|
|
53
|
+
const healthy = entry.health ? entry.health === 'healthy' : entry.state === 'running'
|
|
54
|
+
if (!healthy) pending.push(`${service} (${entry.health || entry.state})`)
|
|
55
|
+
}
|
|
56
|
+
if (pending.length === 0) {
|
|
57
|
+
spinner.stop('ok', `${services.join(', ')} healthy`)
|
|
58
|
+
return { ok: true }
|
|
59
|
+
}
|
|
60
|
+
spinner.update(`waiting for ${pending.join(', ')}`)
|
|
61
|
+
await sleep(POLL_INTERVAL_MS)
|
|
62
|
+
}
|
|
63
|
+
} finally {
|
|
64
|
+
spinner.stop()
|
|
65
|
+
}
|
|
66
|
+
return { ok: false, failed: 'timeout' }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function waitForHttp(url, { timeoutMs = 10 * 60 * 1000, label = url, log = console.log, validate = null } = {}) {
|
|
70
|
+
const deadline = Date.now() + timeoutMs
|
|
71
|
+
let lastError = ''
|
|
72
|
+
const spinner = createSpinner(`waiting for ${label}`)
|
|
73
|
+
try {
|
|
74
|
+
while (Date.now() < deadline) {
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(5000) })
|
|
77
|
+
if (response.ok) {
|
|
78
|
+
if (!validate) {
|
|
79
|
+
spinner.stop('ok', `${label} ready`)
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
const body = await response.text()
|
|
83
|
+
if (validate(body)) {
|
|
84
|
+
spinner.stop('ok', `${label} ready`)
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
lastError = 'endpoint up, validation pending'
|
|
88
|
+
} else {
|
|
89
|
+
lastError = `HTTP ${response.status}`
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
lastError = error?.cause?.code ?? error?.name ?? 'unreachable'
|
|
93
|
+
}
|
|
94
|
+
spinner.update(`waiting for ${label} (${lastError})`)
|
|
95
|
+
await sleep(POLL_INTERVAL_MS)
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
spinner.stop()
|
|
99
|
+
}
|
|
100
|
+
log(`Timed out waiting for ${label} (${lastError})`)
|
|
101
|
+
return false
|
|
102
|
+
}
|