@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
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()
|
package/src/company.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/compose.mjs
ADDED
|
@@ -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'
|