@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.
@@ -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}` : 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
+ }