@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/src/cli.mjs ADDED
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+ // Open Mercato starter CLI — the single cross-platform brain behind the three
3
+ // platform entry scripts (start.sh, start.ps1, start.cmd). The entries only
4
+ // guarantee Node 24 exists, then hand every command off to this file.
5
+ //
6
+ // up (default) idempotent converge + start: prerequisites -> corporate
7
+ // TLS trust -> env/secrets -> install -> infra -> database -> dev
8
+ // stop stop host processes and/or containers
9
+ // status processes, containers, health, URLs
10
+ // logs tail the newest dev log (--follow)
11
+ // doctor read-only audit with remediation + a "hand this to IT" sheet
12
+ // reset destructive cleanup (asks first)
13
+ // infra up/down for just the infra containers
14
+ //
15
+ // Modes: --mode hybrid (default; app + MCP on the host, services in
16
+ // containers) or --mode docker (everything containerized — for machines where
17
+ // running Node workloads on the host is not allowed). The chosen mode is
18
+ // remembered in .mercato/starter/mode.
19
+
20
+ import fs from 'node:fs'
21
+ import path from 'node:path'
22
+ import readline from 'node:readline'
23
+
24
+ import { detectDocker, resolveRepoRoot, runCompose, runStreamingSync, spawnStreaming } from './compose.mjs'
25
+ import { FULLAPP_DEV_COMPOSE_FILE, STARTER_STATE_DIR, resolveStackPorts, stackUrls } from './constants.mjs'
26
+ import { loadCompanyConfig } from './company.mjs'
27
+ import { printDoctorReport, runDoctor } from './doctor.mjs'
28
+ import { infraDown, infraUp, ensureMcpSharedDir } from './infra.mjs'
29
+ import { buildUpSteps, clearConvergenceState, createStepContext, ensureOpencodeImage, runSteps, yarnInvocation } from './steps.mjs'
30
+ import { collectStatus, printStatus, readRunState, isPidAlive, startDetached, stopDetached, tailLogs } from './supervise.mjs'
31
+ import { ensureWindowsUtf8Console } from './spawn.mjs'
32
+ import { color, guideBox, printBanner, statusLine } from './ui.mjs'
33
+ import { waitForHealthyServices, waitForHttp } from './waits.mjs'
34
+
35
+ ensureWindowsUtf8Console()
36
+
37
+ const repoRoot = resolveRepoRoot()
38
+ if (!repoRoot) {
39
+ console.error('❌ No Open Mercato checkout found above the current directory.')
40
+ console.error(' Run `npx @open-mercato/starter` (it clones for you) or cd into a clone first.')
41
+ process.exit(2)
42
+ }
43
+
44
+ function parseArgs(argv) {
45
+ const commands = new Set(['up', 'stop', 'status', 'logs', 'doctor', 'reset', 'infra'])
46
+ const args = [...argv]
47
+ const command = commands.has(args[0]) ? args.shift() : 'up'
48
+ const flags = {
49
+ mode: null,
50
+ detach: false,
51
+ follow: false,
52
+ nonInteractive: false,
53
+ skipLlmPrompt: false,
54
+ skipDb: false,
55
+ noInfra: false,
56
+ rebuild: false,
57
+ clean: false,
58
+ volumes: false,
59
+ yes: false,
60
+ keepInfra: false,
61
+ profiles: [],
62
+ passthrough: [],
63
+ infraAction: null,
64
+ }
65
+ while (args.length > 0) {
66
+ const arg = args.shift()
67
+ if (arg === '--') {
68
+ flags.passthrough = args.splice(0)
69
+ break
70
+ }
71
+ switch (arg) {
72
+ case '--mode':
73
+ flags.mode = args.shift() ?? null
74
+ break
75
+ case '--detach':
76
+ case '-d':
77
+ flags.detach = true
78
+ break
79
+ case '--follow':
80
+ case '-f':
81
+ flags.follow = true
82
+ break
83
+ case '--non-interactive':
84
+ flags.nonInteractive = true
85
+ break
86
+ case '--skip-llm-prompt':
87
+ flags.skipLlmPrompt = true
88
+ break
89
+ case '--skip-db':
90
+ flags.skipDb = true
91
+ break
92
+ case '--no-infra':
93
+ flags.noInfra = true
94
+ break
95
+ case '--rebuild':
96
+ flags.rebuild = true
97
+ break
98
+ case '--clean':
99
+ flags.clean = true
100
+ break
101
+ case '--volumes':
102
+ flags.volumes = true
103
+ break
104
+ case '--yes':
105
+ flags.yes = true
106
+ break
107
+ case '--keep-infra':
108
+ flags.keepInfra = true
109
+ break
110
+ case '--profile':
111
+ if (args[0]) flags.profiles.push(args.shift())
112
+ break
113
+ case 'up':
114
+ case 'down':
115
+ if (command === 'infra') flags.infraAction = arg
116
+ break
117
+ default:
118
+ console.error(`Unknown option: ${arg} (see starters/README.md)`)
119
+ process.exit(1)
120
+ }
121
+ }
122
+ if (!process.stdin.isTTY || process.env.CI === 'true') flags.nonInteractive = true
123
+ return { command, flags }
124
+ }
125
+
126
+ function modeStatePath() {
127
+ return path.join(repoRoot, STARTER_STATE_DIR, 'mode')
128
+ }
129
+
130
+ function resolveMode(flagMode) {
131
+ if (flagMode) {
132
+ if (!['hybrid', 'docker'].includes(flagMode)) {
133
+ console.error(`Unknown --mode '${flagMode}' — use hybrid or docker.`)
134
+ process.exit(1)
135
+ }
136
+ fs.mkdirSync(path.dirname(modeStatePath()), { recursive: true })
137
+ fs.writeFileSync(modeStatePath(), `${flagMode}\n`)
138
+ return flagMode
139
+ }
140
+ try {
141
+ const saved = fs.readFileSync(modeStatePath(), 'utf8').trim()
142
+ if (['hybrid', 'docker'].includes(saved)) return saved
143
+ } catch {
144
+ // fall through to default
145
+ }
146
+ return 'hybrid'
147
+ }
148
+
149
+ async function confirm(question, { nonInteractive }) {
150
+ if (nonInteractive) return false
151
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
152
+ const answer = await new Promise((resolve) => rl.question(`${question} [y/N] `, resolve))
153
+ rl.close()
154
+ return /^y/i.test(answer.trim())
155
+ }
156
+
157
+ async function commandUp(flags) {
158
+ const mode = resolveMode(flags.mode)
159
+ if (flags.clean) {
160
+ // Full re-converge: drop the completed-step markers (keeps the mode, all
161
+ // data, and the one-time initialize state — `reset` is the destructive
162
+ // variant) and rebuild the OpenCode service image.
163
+ flags.rebuild = true
164
+ const cleared = clearConvergenceState(repoRoot)
165
+ console.log(color.dim(` --clean: cleared ${cleared.length} convergence marker(s) — install, build, and migrations will re-run (data untouched).`))
166
+ }
167
+ const ctx = await createStepContext(repoRoot, { mode, flags })
168
+ printBanner(`dev environment starter — mode: ${mode}${ctx.company.name ? ` — company profile: ${ctx.company.name}` : ''}`)
169
+ console.log(color.dim(` Repo: ${repoRoot}`))
170
+ console.log(color.dim(' Every step is idempotent — re-running always resumes where it stopped.'))
171
+
172
+ if (mode === 'docker') return commandUpDocker(ctx, flags)
173
+
174
+ const outcome = await runSteps(buildUpSteps(ctx), ctx)
175
+ if (!outcome.ok) {
176
+ guideBox('Setup stopped', [
177
+ 'Fix the item above and re-run the same command — completed steps are skipped.',
178
+ 'Full environment audit with an IT handout: yarn om doctor (or: npx @open-mercato/starter doctor)',
179
+ ])
180
+ process.exit(2)
181
+ }
182
+
183
+ const ports = resolveStackPorts(repoRoot)
184
+ const urls = stackUrls(ports)
185
+ console.log('')
186
+ console.log(color.bold(color.green('Environment ready — starting the dev runtime')) + color.dim(' (app + queue workers + scheduler + MCP server)'))
187
+ console.log(` App: ${color.cyan(urls.app)} ${color.dim('(login page appears once the app finishes booting)')}`)
188
+ console.log(` MCP: ${color.cyan(urls.mcp)}`)
189
+ console.log(` OpenCode: ${color.cyan(`http://127.0.0.1:${ports.opencode}`)}`)
190
+ console.log(color.dim(' Superadmin credentials: OM_INIT_SUPERADMIN_EMAIL / OM_INIT_SUPERADMIN_PASSWORD in .env'))
191
+ console.log(color.dim(' Stop: Ctrl+C (infra containers keep running; `stop` shuts everything down)'))
192
+ console.log('')
193
+
194
+ if (flags.detach) {
195
+ startDetached(repoRoot, flags.passthrough, { env: ctx.env })
196
+ console.log('Manage it with the status / logs / stop subcommands.')
197
+ return
198
+ }
199
+ const dev = yarnInvocation(ctx, ['dev', ...flags.passthrough])
200
+ const child = spawnStreaming(dev.command, dev.args, { cwd: repoRoot, env: ctx.env })
201
+ child.on('close', (code) => process.exit(code ?? 0))
202
+ child.on('error', (error) => {
203
+ console.error(`❌ Could not start yarn dev: ${error.message}`)
204
+ process.exit(1)
205
+ })
206
+ }
207
+
208
+ async function commandUpDocker(ctx, flags) {
209
+ // Containerized mode: converge trust/env, then let compose own the stack.
210
+ const steps = buildUpSteps(ctx).filter((step) => ['prerequisites', 'container-runtime', 'corporate-certs', 'env-files', 'llm-provider'].includes(step.id))
211
+ const outcome = await runSteps(steps, ctx)
212
+ if (!outcome.ok) process.exit(2)
213
+
214
+ ensureMcpSharedDir(repoRoot)
215
+ ensureOpencodeImage(ctx)
216
+ const composeFile = FULLAPP_DEV_COMPOSE_FILE
217
+ const upArgs = ['up', '-d']
218
+ if (flags.rebuild) upArgs.push('--build')
219
+ console.log('')
220
+ console.log('── Starting the containerized stack (this builds the app image on the first run) ──')
221
+ const up = runCompose(repoRoot, upArgs, { composeFile })
222
+ if ((up.status ?? 1) !== 0) {
223
+ console.error('❌ docker compose up failed — see above. TLS errors inside builds usually mean the corporate CA changed: re-run with --rebuild.')
224
+ process.exit(up.status ?? 1)
225
+ }
226
+
227
+ const ports = resolveStackPorts(repoRoot)
228
+ const urls = stackUrls(ports)
229
+ console.log(color.dim(' Startup order: infra healthy -> app serving -> MCP -> OpenCode -> end-to-end wiring check.'))
230
+ const infraOk = await waitForHealthyServices(repoRoot, composeFile, ['postgres', 'redis', 'meilisearch'], { log: console.log })
231
+ if (!infraOk.ok) process.exit(1)
232
+ console.log(color.dim(' First boot runs migrations + seed inside the app container — expect several minutes.'))
233
+ if (!(await waitForHttp(urls.app, { label: `app ${urls.app}`, timeoutMs: 30 * 60 * 1000 }))) process.exit(1)
234
+ const mcpOk = await waitForHttp(urls.mcpHealth, { label: 'MCP /health' })
235
+ const opencodeOk = await waitForHttp(urls.opencodeHealth, { label: 'OpenCode /global/health' })
236
+ if (mcpOk && opencodeOk) {
237
+ await waitForHttp(urls.opencodeMcpStatus, {
238
+ label: 'OpenCode <-> MCP wiring',
239
+ timeoutMs: 2 * 60 * 1000,
240
+ validate: (body) => body.includes('connected'),
241
+ })
242
+ }
243
+ console.log('')
244
+ console.log(color.bold(color.green('Stack is up.')))
245
+ console.log(` App: ${color.cyan(urls.app)} ${color.dim('(superadmin credentials: OM_INIT_SUPERADMIN_EMAIL / _PASSWORD in .env)')}`)
246
+ console.log(` OpenCode: ${color.cyan(`http://127.0.0.1:${ports.opencode}`)}`)
247
+ console.log(color.dim(' Manage it with the stop / status / logs subcommands.'))
248
+ }
249
+
250
+ async function commandStop(flags) {
251
+ const mode = resolveMode(flags.mode)
252
+ if (flags.volumes && !flags.yes) {
253
+ console.error('❌ --volumes DELETES all database/search data. Re-run with --yes to confirm.')
254
+ process.exit(1)
255
+ }
256
+ if (mode === 'docker') {
257
+ const down = runCompose(repoRoot, ['down', ...(flags.volumes ? ['--volumes'] : [])], { composeFile: FULLAPP_DEV_COMPOSE_FILE })
258
+ process.exit(down.status ?? 1)
259
+ }
260
+ await stopDetached(repoRoot)
261
+ if (!flags.keepInfra) {
262
+ const docker = detectDocker()
263
+ if (docker.ok) {
264
+ const status = infraDown(repoRoot, { volumes: flags.volumes })
265
+ if (status === 0) console.log(flags.volumes ? '✅ Infra containers stopped and volumes deleted.' : '✅ Infra containers stopped (data preserved).')
266
+ process.exit(status)
267
+ }
268
+ }
269
+ console.log('ℹ️ Infra containers left running (--keep-infra or no docker). Foreground `yarn dev` sessions stop with Ctrl+C.')
270
+ }
271
+
272
+ async function commandStatus(flags) {
273
+ const mode = resolveMode(flags.mode)
274
+ const status = await collectStatus(repoRoot, {
275
+ runCompose: (root, args, opts) => runCompose(root, args, { ...opts, composeFile: mode === 'docker' ? FULLAPP_DEV_COMPOSE_FILE : undefined }),
276
+ })
277
+ printStatus(status)
278
+ }
279
+
280
+ async function commandDoctor(flags) {
281
+ const company = await loadCompanyConfig(repoRoot)
282
+ const runState = readRunState(repoRoot)
283
+ const stackRunning = Boolean(runState && isPidAlive(runState.pid))
284
+ const mode = resolveMode(flags?.mode)
285
+ const checks = await runDoctor(repoRoot, { company, stackRunning, includeContainerProbe: true, mode })
286
+ const ok = printDoctorReport(checks, { company })
287
+ process.exit(ok ? 0 : 2)
288
+ }
289
+
290
+ async function commandReset(flags) {
291
+ const mode = resolveMode(flags.mode)
292
+ console.log('Reset stops the stack, removes containers + volumes (ALL LOCAL DATA), and clears starter state.')
293
+ const confirmed = flags.yes || (await confirm('Continue?', flags))
294
+ if (!confirmed) {
295
+ console.log('Aborted.')
296
+ return
297
+ }
298
+ await stopDetached(repoRoot)
299
+ const docker = detectDocker()
300
+ if (docker.ok) {
301
+ runCompose(repoRoot, ['down', '--volumes'], { composeFile: mode === 'docker' ? FULLAPP_DEV_COMPOSE_FILE : undefined })
302
+ }
303
+ fs.rmSync(path.join(repoRoot, STARTER_STATE_DIR), { recursive: true, force: true })
304
+ console.log('✅ Reset complete. Run the starter again for a fresh environment.')
305
+ }
306
+
307
+ async function commandInfra(flags) {
308
+ const docker = detectDocker()
309
+ if (!docker.ok) {
310
+ console.error(`❌ Docker is not available (${docker.reason}). Run the doctor subcommand for guided setup.`)
311
+ process.exit(2)
312
+ }
313
+ if (flags.infraAction === 'down') {
314
+ if (flags.volumes && !flags.yes) {
315
+ console.error('❌ --volumes DELETES all database/search data. Re-run with --yes to confirm.')
316
+ process.exit(1)
317
+ }
318
+ process.exit(infraDown(repoRoot, { volumes: flags.volumes }))
319
+ }
320
+ ensureMcpSharedDir(repoRoot)
321
+ ensureOpencodeImage({ repoRoot, env: process.env, flags: { rebuild: flags.rebuild }, log: console.log })
322
+ const status = infraUp(repoRoot, { profiles: flags.profiles })
323
+ if (status === 0) {
324
+ const ports = resolveStackPorts(repoRoot)
325
+ console.log('')
326
+ console.log(`✅ Infra containers are up (opencode :${ports.opencode}, postgres :${ports.postgres}, redis :${ports.redis}, meilisearch :${ports.meilisearch}).`)
327
+ console.log(' Next: yarn dev (starts the app and the MCP server on this machine)')
328
+ }
329
+ process.exit(status)
330
+ }
331
+
332
+ async function main() {
333
+ const { command, flags } = parseArgs(process.argv.slice(2))
334
+ switch (command) {
335
+ case 'up':
336
+ return commandUp(flags)
337
+ case 'stop':
338
+ return commandStop(flags)
339
+ case 'status':
340
+ return commandStatus(flags)
341
+ case 'logs':
342
+ return tailLogs(repoRoot, { follow: flags.follow })
343
+ case 'doctor':
344
+ return commandDoctor(flags)
345
+ case 'reset':
346
+ return commandReset(flags)
347
+ case 'infra':
348
+ return commandInfra(flags)
349
+ default:
350
+ console.error(`Unknown command: ${command}`)
351
+ process.exit(1)
352
+ }
353
+ }
354
+
355
+ await main()
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ // Company overlay: lets an organization tailor the starter without forking it.
6
+ // Resolution order (later wins per key):
7
+ // 1. built-in defaults below
8
+ // 2. starters/company/config.mjs (committed, per-company policy)
9
+ // 3. starters/company/config.local.mjs (gitignored, per-machine override)
10
+ // Each file default-exports a plain object matching COMPANY_DEFAULTS' shape.
11
+
12
+ export const COMPANY_DEFAULTS = {
13
+ name: '',
14
+ // Where users are sent when a prerequisite needs IT. Shown verbatim in the
15
+ // doctor's "hand this to IT" sheet.
16
+ itContact: '',
17
+ mirrors: {
18
+ // e.g. https://registry.example.com/npm — applied via COREPACK_NPM_REGISTRY
19
+ // and yarn's npmRegistryServer for installs run by the starter.
20
+ npmRegistry: '',
21
+ // Forwarded into image builds as the ALPINE_MIRROR build arg.
22
+ alpineMirror: '',
23
+ // Base URL that mirrors https://nodejs.org/dist (used by start.sh/start.ps1).
24
+ nodeDist: '',
25
+ },
26
+ certs: {
27
+ // Extra PEM bundles (absolute or repo-relative) trusted in addition to any
28
+ // captured interception CA. Point this at the corporate root bundle when IT
29
+ // can provide one — capture is the fallback, not the preferred source.
30
+ bundles: [],
31
+ // Set false to disable active TLS-interception capture (audit-sensitive
32
+ // fleets); provisioning then relies solely on `bundles`.
33
+ capture: true,
34
+ },
35
+ // Extra doctor checks: [{ id, title, run: async (ctx) => ({ level, detail, guide? }) }]
36
+ checks: [],
37
+ // Step tweaks for `up`: ids listed in `disable` are skipped; `extra` steps
38
+ // (same shape as steps.mjs entries) run after the built-ins, before launch.
39
+ steps: { disable: [], extra: [] },
40
+ // Env defaults merged into generated .env files (never overwrite existing).
41
+ env: {},
42
+ }
43
+
44
+ function mergeConfig(base, overlay) {
45
+ if (!overlay || typeof overlay !== 'object') return base
46
+ const merged = { ...base, ...overlay }
47
+ merged.mirrors = { ...base.mirrors, ...(overlay.mirrors ?? {}) }
48
+ merged.certs = { ...base.certs, ...(overlay.certs ?? {}) }
49
+ merged.steps = {
50
+ disable: [...base.steps.disable, ...(overlay.steps?.disable ?? [])],
51
+ extra: [...base.steps.extra, ...(overlay.steps?.extra ?? [])],
52
+ }
53
+ merged.checks = [...base.checks, ...(overlay.checks ?? [])]
54
+ merged.env = { ...base.env, ...(overlay.env ?? {}) }
55
+ return merged
56
+ }
57
+
58
+ export async function loadCompanyConfig(repoRoot, { warn = console.warn } = {}) {
59
+ let config = COMPANY_DEFAULTS
60
+ for (const candidate of ['config.mjs', 'config.local.mjs']) {
61
+ const filePath = path.join(repoRoot, 'starters', 'company', candidate)
62
+ if (!fs.existsSync(filePath)) continue
63
+ try {
64
+ const module = await import(pathToFileURL(filePath).href)
65
+ config = mergeConfig(config, module.default)
66
+ } catch (error) {
67
+ warn(`⚠️ Ignoring starters/company/${candidate} — it failed to load: ${error instanceof Error ? error.message : String(error)}`)
68
+ }
69
+ }
70
+ return config
71
+ }
72
+
73
+ export function resolveCompanyCertBundles(repoRoot, company) {
74
+ const bundles = []
75
+ for (const entry of company.certs.bundles) {
76
+ const resolved = path.isAbsolute(entry) ? entry : path.join(repoRoot, entry)
77
+ if (fs.existsSync(resolved)) bundles.push(resolved)
78
+ }
79
+ return bundles
80
+ }
@@ -0,0 +1,144 @@
1
+ import { spawn, spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ import { resolveSpawnCommand } from './spawn.mjs'
6
+
7
+ export const INFRA_COMPOSE_FILE = path.join('starters', 'docker', 'compose.infra.yml')
8
+
9
+ function isOpenMercatoRoot(dir) {
10
+ // Works for the monorepo AND for standalone apps scaffolded by create-app:
11
+ // both carry the starters/docker compose layout at their root.
12
+ if (fs.existsSync(path.join(dir, INFRA_COMPOSE_FILE))) return true
13
+ try {
14
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
15
+ return pkg?.name === 'open-mercato'
16
+ } catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ // The starter can run from anywhere (npx, package bin inside node_modules, a
22
+ // subdirectory of the clone) — never assume a location relative to this file.
23
+ export function resolveRepoRoot(startDir = process.cwd()) {
24
+ let dir = path.resolve(startDir)
25
+ while (true) {
26
+ if (isOpenMercatoRoot(dir)) return dir
27
+ const parent = path.dirname(dir)
28
+ if (parent === dir) return null
29
+ dir = parent
30
+ }
31
+ }
32
+
33
+ // Canonical invocation: --project-directory anchors .env interpolation and
34
+ // every relative path in the compose file at the repo root, keeping the
35
+ // compose project identity identical to the pre-starters layout.
36
+ export function composeArgs(repoRoot, extraArgs, composeFile = INFRA_COMPOSE_FILE) {
37
+ return ['compose', '--project-directory', repoRoot, '-f', path.join(repoRoot, composeFile), ...extraArgs]
38
+ }
39
+
40
+ export function runCompose(repoRoot, extraArgs, { composeFile, stdio = 'inherit' } = {}) {
41
+ const result = spawnSync('docker', composeArgs(repoRoot, extraArgs, composeFile), {
42
+ cwd: repoRoot,
43
+ stdio,
44
+ encoding: 'utf8',
45
+ })
46
+ if (result.error) {
47
+ throw new Error(`docker compose could not be executed: ${result.error.message}`)
48
+ }
49
+ return result
50
+ }
51
+
52
+ export function detectDocker() {
53
+ const cli = spawnSync('docker', ['--version'], { stdio: 'ignore' })
54
+ if (cli.error || cli.status !== 0) {
55
+ return { ok: false, reason: 'docker CLI not found' }
56
+ }
57
+ const composePlugin = spawnSync('docker', ['compose', 'version'], { stdio: 'ignore' })
58
+ if (composePlugin.error || composePlugin.status !== 0) {
59
+ return { ok: false, reason: 'docker compose v2 plugin not found' }
60
+ }
61
+ const engine = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { stdio: 'ignore' })
62
+ if (engine.error || engine.status !== 0) {
63
+ return { ok: false, reason: 'docker engine not running' }
64
+ }
65
+ return { ok: true }
66
+ }
67
+
68
+ // `compose ps --format json` prints NDJSON on compose >= 2.21 and a JSON array
69
+ // on older releases — tolerate both.
70
+ export function parseComposePsOutput(raw) {
71
+ const trimmed = String(raw ?? '').trim()
72
+ if (!trimmed) return []
73
+ if (trimmed.startsWith('[')) {
74
+ try {
75
+ return JSON.parse(trimmed)
76
+ } catch {
77
+ return []
78
+ }
79
+ }
80
+ const entries = []
81
+ for (const line of trimmed.split('\n')) {
82
+ if (!line.trim()) continue
83
+ try {
84
+ entries.push(JSON.parse(line))
85
+ } catch {
86
+ // Ignore non-JSON noise lines.
87
+ }
88
+ }
89
+ return entries
90
+ }
91
+
92
+ export function summarizeServiceStates(entries) {
93
+ const states = {}
94
+ for (const entry of entries) {
95
+ const service = entry?.Service
96
+ if (!service) continue
97
+ states[service] = {
98
+ state: entry.State ?? 'unknown',
99
+ health: entry.Health ?? '',
100
+ }
101
+ }
102
+ return states
103
+ }
104
+
105
+ // Both helpers route through resolveSpawnCommand (spawn.mjs): it validates
106
+ // command + args and only enables the cmd.exe shell for the .cmd shims
107
+ // (yarn/npm/corepack) that Node >= 18.20 cannot spawn directly on Windows.
108
+ export function spawnStreaming(command, commandArgs, { cwd, env } = {}) {
109
+ const resolved = resolveSpawnCommand(command, commandArgs)
110
+ return spawn(resolved.command, resolved.args, {
111
+ cwd: cwd ?? process.cwd(),
112
+ env: env ?? process.env,
113
+ stdio: 'inherit',
114
+ ...resolved.spawnOptions,
115
+ })
116
+ }
117
+
118
+ // Capture-mode sibling of runStreamingSync for probes that need the output
119
+ // (version checks, registry queries). Same Windows shim handling.
120
+ export function runCaptureSync(command, commandArgs, { cwd, env, timeout = 15000 } = {}) {
121
+ const resolved = resolveSpawnCommand(command, commandArgs)
122
+ return spawnSync(resolved.command, resolved.args, {
123
+ cwd: cwd ?? process.cwd(),
124
+ env: env ?? process.env,
125
+ encoding: 'utf8',
126
+ timeout,
127
+ windowsHide: true,
128
+ ...resolved.spawnOptions,
129
+ })
130
+ }
131
+
132
+ export function runStreamingSync(command, commandArgs, { cwd, env } = {}) {
133
+ const resolved = resolveSpawnCommand(command, commandArgs)
134
+ const result = spawnSync(resolved.command, resolved.args, {
135
+ cwd: cwd ?? process.cwd(),
136
+ env: env ?? process.env,
137
+ stdio: 'inherit',
138
+ ...resolved.spawnOptions,
139
+ })
140
+ if (result.error) {
141
+ throw new Error(`${command} could not be executed: ${result.error.message}`)
142
+ }
143
+ return result.status ?? 1
144
+ }
@@ -0,0 +1,85 @@
1
+ import path from 'node:path'
2
+
3
+ import { readEnvValue } from './env-file.mjs'
4
+
5
+ // Single source of truth for the local stack topology. Every consumer (steps,
6
+ // doctor, status, supervision) resolves ports through here so a port override
7
+ // in the root .env is honored everywhere at once.
8
+
9
+ export const DEFAULT_PORTS = {
10
+ app: 3000,
11
+ mcp: 3001,
12
+ splash: 4000,
13
+ opencode: 4096,
14
+ localstack: 4566,
15
+ verdaccio: 4873,
16
+ postgres: 5432,
17
+ redis: 6379,
18
+ meilisearch: 7700,
19
+ keycloak: 8080,
20
+ }
21
+
22
+ const PORT_ENV_KEYS = {
23
+ app: 'APP_PORT',
24
+ mcp: 'MCP_PORT',
25
+ splash: 'OM_DEV_SPLASH_PORT',
26
+ opencode: 'OPENCODE_PORT',
27
+ localstack: 'LOCALSTACK_PORT',
28
+ verdaccio: 'VERDACCIO_PORT',
29
+ postgres: 'POSTGRES_PORT',
30
+ redis: 'REDIS_PORT',
31
+ meilisearch: 'MEILISEARCH_PORT',
32
+ keycloak: 'KEYCLOAK_PORT',
33
+ }
34
+
35
+ export function resolveStackPorts(repoRoot, env = process.env) {
36
+ const rootEnvPath = path.join(repoRoot, '.env')
37
+ const ports = {}
38
+ for (const [service, fallback] of Object.entries(DEFAULT_PORTS)) {
39
+ const key = PORT_ENV_KEYS[service]
40
+ const raw = env[key] ?? readEnvValue(rootEnvPath, key) ?? ''
41
+ const parsed = Number.parseInt(String(raw).trim(), 10)
42
+ ports[service] = Number.isInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : fallback
43
+ }
44
+ return ports
45
+ }
46
+
47
+ // Host-side probes deliberately use 127.0.0.1, never `localhost`: on modern
48
+ // Windows (and some macOS setups) `localhost` resolves to ::1 first while the
49
+ // services listen on IPv4, which turns every health probe into a coin flip.
50
+ export const LOOPBACK_HOST = '127.0.0.1'
51
+
52
+ export function stackUrls(ports) {
53
+ return {
54
+ app: `http://${LOOPBACK_HOST}:${ports.app}`,
55
+ appLogin: `http://${LOOPBACK_HOST}:${ports.app}/backend/login`,
56
+ splash: `http://${LOOPBACK_HOST}:${ports.splash}`,
57
+ mcpHealth: `http://${LOOPBACK_HOST}:${ports.mcp}/health`,
58
+ mcp: `http://${LOOPBACK_HOST}:${ports.mcp}/mcp`,
59
+ opencodeHealth: `http://${LOOPBACK_HOST}:${ports.opencode}/global/health`,
60
+ opencodeMcpStatus: `http://${LOOPBACK_HOST}:${ports.opencode}/mcp`,
61
+ }
62
+ }
63
+
64
+ // TLS-interception probe targets: one per critical egress class (yarn
65
+ // registry, git hosting, container registry). Probing several hosts matters —
66
+ // interception proxies keep per-host bypass lists, so a single clean probe
67
+ // proves nothing.
68
+ export const TLS_PROBE_HOSTS = ['registry.yarnpkg.com', 'github.com', 'registry-1.docker.io']
69
+
70
+ export const STARTER_STATE_DIR = path.join('.mercato', 'starter')
71
+ export const RUN_STATE_DIR = path.join('.mercato', 'run')
72
+ export const CAPTURED_CA_BUNDLE = path.join('.mercato', 'certs', 'corporate-ca.pem')
73
+ export const DOCKER_CERTS_DIR = path.join('docker', 'certs')
74
+ export const OPENCODE_CERTS_DIR = path.join('docker', 'opencode', 'certs')
75
+ export const DEV_LOG_DIR = path.join('.mercato', 'logs')
76
+
77
+ export const FULLAPP_DEV_COMPOSE_FILE = path.join('starters', 'docker', 'compose.fullapp.dev.yml')
78
+
79
+ // The published OpenCode image is a BASE (binary + non-root user, no
80
+ // entrypoint/agents — docker/opencode/BASE_IMAGE.md). The runnable service
81
+ // image is the thin local build FROM it, tagged opencode-mvp. Keep the default
82
+ // tag in sync with the compose files' OPENCODE_BASE_IMAGE default and the
83
+ // docker/opencode/Dockerfile ARG.
84
+ export const DEFAULT_OPENCODE_BASE_IMAGE = 'docker.io/openmercatocom/open-mercato-opencode:1.18.3'
85
+ export const OPENCODE_SERVICE_IMAGE = 'opencode-mvp'