@open-mercato/starter 0.6.5 → 0.7.1-develop.7194.1.ab4fc81f82

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/starter",
3
- "version": "0.6.5",
3
+ "version": "0.7.1-develop.7194.1.ab4fc81f82",
4
4
  "description": "One-command, cross-platform bootstrap for Open Mercato dev environments: prerequisite doctor, corporate TLS trust, idempotent setup steps, and a supervised hybrid or fully containerized stack.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,5 +23,6 @@
23
23
  "type": "git",
24
24
  "url": "https://github.com/open-mercato/open-mercato",
25
25
  "directory": "packages/starter"
26
- }
27
- }
26
+ },
27
+ "stableVersion": "0.7.0"
28
+ }
@@ -11,8 +11,10 @@ import { DEFAULT_OPENCODE_BASE_IMAGE, DEFAULT_PORTS, resolveStackPorts } from '.
11
11
  import { addEnvValue, readEnvValue, setEnvValue } from '../env-file.mjs'
12
12
  import { ensureLlmProvider, syncProviderConfigToAppEnv } from '../providers.mjs'
13
13
  import { ensureWindowsUtf8Console, resolveSpawnCommand } from '../spawn.mjs'
14
- import { StepBlocked, buildToolchainStep, clearConvergenceState, listMigrationModules, migrationsFingerprint, readAppliedMigrationModules, resolveOpencodeBaseImage, runSteps } from '../steps.mjs'
15
- import { checkBuildToolchain } from '../doctor.mjs'
14
+ import { StepBlocked, buildToolchainStep, clearConvergenceState, databaseIsInitialized, listMigrationModules, migrationsFingerprint, probePostgresCredentials, readAppliedMigrationModules, resolveOpencodeBaseImage, runSteps } from '../steps.mjs'
15
+ import { ensureEnvFiles } from '../env-setup.mjs'
16
+ import { removeLeftoverComposeResources } from '../infra.mjs'
17
+ import { checkBuildToolchain, defenderExclusionCovers, detectHostGateway, hostIpCandidates } from '../doctor.mjs'
16
18
 
17
19
  function makeTempDir(prefix) {
18
20
  return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
@@ -357,3 +359,176 @@ test('buildToolchainStep warns instead of blocking when the workspace install al
357
359
  fs.writeFileSync(path.join(repo, 'yarn.lock'), 'changed\n')
358
360
  await assert.rejects(() => buildToolchainStep.check(ctx), (error) => error instanceof StepBlocked)
359
361
  })
362
+
363
+ test('removeLeftoverComposeResources sweeps fixed-name containers and volumes down cannot see', () => {
364
+ const calls = []
365
+ const runComposeImpl = () => ({
366
+ status: 0,
367
+ stdout: JSON.stringify({
368
+ services: { postgres: { container_name: 'mercato-postgres' } },
369
+ volumes: { postgres_data: { name: 'mercato-postgres-data' } },
370
+ }),
371
+ })
372
+ const runCaptureImpl = (command, args) => {
373
+ const key = args.join(' ')
374
+ calls.push(key)
375
+ if (key === 'ps -a --format {{.Names}}') return { status: 0, stdout: 'mercato-postgres\nunrelated-container\n' }
376
+ if (key === 'volume ls --format {{.Name}}') return { status: 0, stdout: 'mercato-postgres-data\nunrelated-volume\n' }
377
+ return { status: 0, stdout: '' }
378
+ }
379
+ const clean = removeLeftoverComposeResources('/repo', { runComposeImpl, runCaptureImpl, log: () => {}, warn: () => {} })
380
+ assert.equal(clean, true)
381
+ assert.ok(calls.includes('rm -f mercato-postgres'))
382
+ assert.ok(calls.includes('volume rm mercato-postgres-data'))
383
+ assert.ok(!calls.some((key) => key.includes('unrelated')))
384
+ })
385
+
386
+ test('removeLeftoverComposeResources reports in-use volumes with their holders and returns false', () => {
387
+ const warnings = []
388
+ const runComposeImpl = () => ({
389
+ status: 0,
390
+ stdout: JSON.stringify({ services: {}, volumes: { postgres_data: { name: 'mercato-postgres-data' } } }),
391
+ })
392
+ const runCaptureImpl = (command, args) => {
393
+ const key = args.join(' ')
394
+ if (key === 'ps -a --format {{.Names}}') return { status: 0, stdout: '' }
395
+ if (key === 'volume ls --format {{.Name}}') return { status: 0, stdout: 'mercato-postgres-data\n' }
396
+ if (key === 'volume rm mercato-postgres-data') return { status: 1, stderr: 'volume is in use' }
397
+ if (key.startsWith('ps -a --filter')) return { status: 0, stdout: 'old-checkout-postgres\n' }
398
+ return { status: 0, stdout: '' }
399
+ }
400
+ const clean = removeLeftoverComposeResources('/repo', { runComposeImpl, runCaptureImpl, log: () => {}, warn: (line) => warnings.push(line) })
401
+ assert.equal(clean, false)
402
+ assert.ok(warnings.some((line) => line.includes('old-checkout-postgres')))
403
+ })
404
+
405
+ test('detectHostGateway prefers the default pin, falls back to host IPs, and reports unreachable', () => {
406
+ const pinOk = detectHostGateway(3001, { probeImpl: () => ({ ran: true, mcpAnswered: true, output: '{"status":"ok"}' }), candidates: [] })
407
+ assert.deepEqual(pinOk, { status: 'pin-ok' })
408
+
409
+ const probes = []
410
+ const secondCandidateWins = detectHostGateway(3001, {
411
+ probeImpl: (port, addHost) => {
412
+ probes.push(addHost)
413
+ return { ran: true, mcpAnswered: addHost === 'host.docker.internal:172.20.0.1', output: '' }
414
+ },
415
+ candidates: ['192.168.1.10', '172.20.0.1'],
416
+ })
417
+ assert.deepEqual(secondCandidateWins, { status: 'use-ip', ip: '172.20.0.1' })
418
+ assert.deepEqual(probes, ['host.docker.internal:host-gateway', 'host.docker.internal:192.168.1.10', 'host.docker.internal:172.20.0.1'])
419
+
420
+ const unreachable = detectHostGateway(3001, { probeImpl: () => ({ ran: true, mcpAnswered: false, output: '' }), candidates: ['192.168.1.10'] })
421
+ assert.deepEqual(unreachable, { status: 'unreachable' })
422
+
423
+ const pullBlocked = detectHostGateway(3001, {
424
+ probeImpl: () => ({ ran: true, mcpAnswered: false, output: 'Unable to find image busybox:1.37 locally' }),
425
+ candidates: ['192.168.1.10'],
426
+ })
427
+ assert.deepEqual(pullBlocked, { status: 'skip' })
428
+ })
429
+
430
+ test('hostIpCandidates lists external IPv4s with WSL adapters first', () => {
431
+ const candidates = hostIpCandidates({
432
+ 'Ethernet': [{ family: 'IPv4', internal: false, address: '192.168.1.10' }],
433
+ 'vEthernet (WSL (Hyper-V firewall))': [{ family: 'IPv4', internal: false, address: '172.20.0.1' }],
434
+ 'Loopback Pseudo-Interface 1': [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],
435
+ })
436
+ assert.deepEqual(candidates, ['172.20.0.1', '192.168.1.10'])
437
+ })
438
+
439
+ test('defenderExclusionCovers matches the repo or an ancestor, case-insensitively', () => {
440
+ assert.equal(defenderExclusionCovers('C:\\dev\\open-mercato', ['C:\\dev']), true)
441
+ assert.equal(defenderExclusionCovers('C:\\dev\\open-mercato', ['c:\\DEV\\open-mercato\\']), true)
442
+ assert.equal(defenderExclusionCovers('C:\\dev\\open-mercato', ['C:/dev/open-mercato']), true)
443
+ assert.equal(defenderExclusionCovers('C:\\dev\\open-mercato', ['C:\\dev\\open']), false)
444
+ assert.equal(defenderExclusionCovers('C:\\dev\\open-mercato', ['D:\\other', null, '']), false)
445
+ assert.equal(defenderExclusionCovers('C:\\dev\\open-mercato', []), false)
446
+ })
447
+
448
+ function makeFakeAppRepo() {
449
+ const repo = makeFakeRepo()
450
+ fs.mkdirSync(path.join(repo, 'apps', 'mercato'), { recursive: true })
451
+ fs.writeFileSync(
452
+ path.join(repo, 'apps', 'mercato', '.env.example'),
453
+ 'DATABASE_URL=postgres://postgres:postgres@localhost:5432/open-mercato\nJWT_SECRET=change-me\nAUTH_SECRET=change-me\nLOOKUP_HASH_PEPPER=change-me\n',
454
+ )
455
+ return repo
456
+ }
457
+
458
+ test('ensureEnvFiles writes the documented dev credentials and threads them into DATABASE_URL', () => {
459
+ const repo = makeFakeAppRepo()
460
+ ensureEnvFiles(repo, { log: () => {}, warn: () => {} })
461
+ assert.equal(readEnvValue(path.join(repo, '.env'), 'POSTGRES_PASSWORD'), 'postgres')
462
+ assert.equal(readEnvValue(path.join(repo, '.env'), 'OM_INIT_SUPERADMIN_PASSWORD'), 'secret')
463
+ assert.equal(readEnvValue(path.join(repo, 'apps', 'mercato', '.env'), 'OM_INIT_SUPERADMIN_PASSWORD'), 'secret')
464
+ // The .env.example default says localhost, which breaks on Windows (::1).
465
+ assert.equal(
466
+ readEnvValue(path.join(repo, 'apps', 'mercato', '.env'), 'DATABASE_URL'),
467
+ 'postgres://postgres:postgres@127.0.0.1:5432/open-mercato',
468
+ )
469
+ })
470
+
471
+ test('ensureEnvFiles never overwrites credentials already present in .env', () => {
472
+ const repo = makeFakeAppRepo()
473
+ fs.writeFileSync(path.join(repo, '.env'), 'POSTGRES_PASSWORD=custom-pass\nOM_INIT_SUPERADMIN_PASSWORD=custom-admin\n')
474
+ ensureEnvFiles(repo, { log: () => {}, warn: () => {} })
475
+ assert.equal(readEnvValue(path.join(repo, '.env'), 'POSTGRES_PASSWORD'), 'custom-pass')
476
+ assert.equal(readEnvValue(path.join(repo, '.env'), 'OM_INIT_SUPERADMIN_PASSWORD'), 'custom-admin')
477
+ assert.equal(readEnvValue(path.join(repo, 'apps', 'mercato', '.env'), 'OM_INIT_SUPERADMIN_PASSWORD'), 'custom-admin')
478
+ assert.equal(
479
+ readEnvValue(path.join(repo, 'apps', 'mercato', '.env'), 'DATABASE_URL'),
480
+ 'postgres://postgres:custom-pass@127.0.0.1:5432/open-mercato',
481
+ )
482
+ })
483
+
484
+ test('probePostgresCredentials probes the container IP (not loopback) and classifies password mismatches', () => {
485
+ const repo = makeFakeRepo()
486
+ fs.mkdirSync(path.join(repo, 'apps', 'mercato'), { recursive: true })
487
+ fs.writeFileSync(
488
+ path.join(repo, 'apps', 'mercato', '.env'),
489
+ "DATABASE_URL=postgres://app:s3c%40ret@127.0.0.1:5432/open-mercato\n",
490
+ )
491
+ const ctx = { repoRoot: repo }
492
+
493
+ let seenArgs
494
+ const okCompose = (root, args) => {
495
+ seenArgs = args
496
+ return { status: 0, stdout: '1\n', stderr: '' }
497
+ }
498
+ assert.equal(probePostgresCredentials(ctx, { runComposeImpl: okCompose }), 'ok')
499
+ // Credentials ride in -e env vars (URL-decoded), never in the shell string.
500
+ assert.ok(seenArgs.includes('PGPASSWORD=s3c@ret'))
501
+ assert.ok(seenArgs.includes('PGUSER=app'))
502
+ const shellCmd = seenArgs[seenArgs.length - 1]
503
+ assert.match(shellCmd, /hostname -i/)
504
+ assert.doesNotMatch(shellCmd, /127\.0\.0\.1/)
505
+ assert.doesNotMatch(shellCmd, /s3c@ret/)
506
+
507
+ const authFailCompose = () => ({ status: 2, stdout: '', stderr: 'psql: error: FATAL: password authentication failed for user "app"' })
508
+ assert.equal(probePostgresCredentials(ctx, { runComposeImpl: authFailCompose }), 'auth-failed')
509
+
510
+ const downCompose = () => ({ status: 1, stdout: '', stderr: 'service "postgres" is not running' })
511
+ assert.equal(probePostgresCredentials(ctx, { runComposeImpl: downCompose }), null)
512
+
513
+ const throwingCompose = () => {
514
+ throw new Error('docker compose could not be executed')
515
+ }
516
+ assert.equal(probePostgresCredentials(ctx, { runComposeImpl: throwingCompose }), null)
517
+
518
+ // No app .env at all: no verdict, never a block.
519
+ assert.equal(probePostgresCredentials({ repoRoot: makeFakeRepo() }, { runComposeImpl: okCompose }), null)
520
+ })
521
+
522
+ test('databaseIsInitialized reads the seeded-users probe and tolerates a missing table or a down probe', () => {
523
+ const repo = makeFakeRepo()
524
+ fs.mkdirSync(path.join(repo, 'apps', 'mercato'), { recursive: true })
525
+ fs.writeFileSync(path.join(repo, 'apps', 'mercato', '.env'), 'DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/open-mercato\n')
526
+ const ctx = { repoRoot: repo }
527
+
528
+ assert.equal(databaseIsInitialized(ctx, { runComposeImpl: () => ({ status: 0, stdout: '3\n' }) }), true)
529
+ assert.equal(databaseIsInitialized(ctx, { runComposeImpl: () => ({ status: 0, stdout: '0\n' }) }), false)
530
+ // to_regclass guard returns -1 when the users table does not exist yet.
531
+ assert.equal(databaseIsInitialized(ctx, { runComposeImpl: () => ({ status: 0, stdout: '-1\n' }) }), false)
532
+ assert.equal(databaseIsInitialized(ctx, { runComposeImpl: () => ({ status: 1, stdout: '' }) }), null)
533
+ assert.equal(databaseIsInitialized(ctx, { runComposeImpl: () => { throw new Error('down') } }), null)
534
+ })
package/src/cli.mjs CHANGED
@@ -22,10 +22,11 @@ import path from 'node:path'
22
22
  import readline from 'node:readline'
23
23
 
24
24
  import { detectDocker, resolveRepoRoot, runCompose, runStreamingSync, spawnStreaming } from './compose.mjs'
25
+ import { readEnvValue } from './env-file.mjs'
25
26
  import { FULLAPP_DEV_COMPOSE_FILE, STARTER_STATE_DIR, resolveStackPorts, stackUrls } from './constants.mjs'
26
27
  import { loadCompanyConfig } from './company.mjs'
27
28
  import { printDoctorReport, runDoctor } from './doctor.mjs'
28
- import { infraDown, infraUp, ensureMcpSharedDir } from './infra.mjs'
29
+ import { infraDown, infraUp, ensureMcpSharedDir, removeLeftoverComposeResources } from './infra.mjs'
29
30
  import { buildUpSteps, clearConvergenceState, createStepContext, ensureOpencodeImage, runSteps, yarnInvocation } from './steps.mjs'
30
31
  import { collectStatus, printStatus, readRunState, isPidAlive, startDetached, stopDetached, tailLogs } from './supervise.mjs'
31
32
  import { ensureWindowsUtf8Console } from './spawn.mjs'
@@ -187,7 +188,7 @@ async function commandUp(flags) {
187
188
  console.log(` App: ${color.cyan(urls.app)} ${color.dim('(login page appears once the app finishes booting)')}`)
188
189
  console.log(` MCP: ${color.cyan(urls.mcp)}`)
189
190
  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(` Superadmin sign-in: ${readEnvValue(path.join(repoRoot, '.env'), 'OM_INIT_SUPERADMIN_EMAIL')?.trim() || 'superadmin@acme.com'} / ${readEnvValue(path.join(repoRoot, '.env'), 'OM_INIT_SUPERADMIN_PASSWORD')?.trim() || 'secret'}`))
191
192
  console.log(color.dim(' Stop: Ctrl+C (infra containers keep running; `stop` shuts everything down)'))
192
193
  console.log('')
193
194
 
@@ -242,7 +243,9 @@ async function commandUpDocker(ctx, flags) {
242
243
  }
243
244
  console.log('')
244
245
  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
+ const superEmail = readEnvValue(path.join(repoRoot, '.env'), 'OM_INIT_SUPERADMIN_EMAIL')?.trim() || 'superadmin@acme.com'
247
+ const superPassword = readEnvValue(path.join(repoRoot, '.env'), 'OM_INIT_SUPERADMIN_PASSWORD')?.trim() || 'secret'
248
+ console.log(` App: ${color.cyan(urls.app)} ${color.dim(`(sign in: ${superEmail} / ${superPassword})`)}`)
246
249
  console.log(` OpenCode: ${color.cyan(`http://127.0.0.1:${ports.opencode}`)}`)
247
250
  console.log(color.dim(' Manage it with the stop / status / logs subcommands.'))
248
251
  }
@@ -295,12 +298,30 @@ async function commandReset(flags) {
295
298
  console.log('Aborted.')
296
299
  return
297
300
  }
301
+ // Kept env files can disagree with the recreated database (a stale
302
+ // POSTGRES_PASSWORD / DATABASE_URL from a previous volume), but they also
303
+ // hold the pasted LLM provider API key — so removal stays an explicit
304
+ // opt-in and --yes alone never deletes them.
305
+ const removeEnvFiles = flags.yes
306
+ ? false
307
+ : await confirm('Also remove the generated env files (.env, apps/mercato/.env)? They are regenerated with dev defaults on the next run, but the saved LLM API key goes with them.', flags)
298
308
  await stopDetached(repoRoot)
299
309
  const docker = detectDocker()
300
310
  if (docker.ok) {
301
- runCompose(repoRoot, ['down', '--volumes'], { composeFile: mode === 'docker' ? FULLAPP_DEV_COMPOSE_FILE : undefined })
311
+ const composeFile = mode === 'docker' ? FULLAPP_DEV_COMPOSE_FILE : undefined
312
+ runCompose(repoRoot, ['down', '--volumes'], { composeFile })
313
+ // down only sees THIS project's resources; fixed-name containers/volumes
314
+ // created by another checkout of the repo survive it — sweep by name.
315
+ if (!removeLeftoverComposeResources(repoRoot, { composeFile })) {
316
+ console.warn('⚠️ Some volumes could not be removed — the next run would reuse the old data. Fix the items above and re-run reset.')
317
+ }
302
318
  }
303
319
  fs.rmSync(path.join(repoRoot, STARTER_STATE_DIR), { recursive: true, force: true })
320
+ if (removeEnvFiles) {
321
+ fs.rmSync(path.join(repoRoot, '.env'), { force: true })
322
+ fs.rmSync(path.join(repoRoot, 'apps', 'mercato', '.env'), { force: true })
323
+ console.log(' removed .env and apps/mercato/.env')
324
+ }
304
325
  console.log('✅ Reset complete. Run the starter again for a fresh environment.')
305
326
  }
306
327
 
package/src/doctor.mjs CHANGED
@@ -313,6 +313,60 @@ export function checkClonePath(repoRoot) {
313
313
  })
314
314
  }
315
315
 
316
+ function normalizeWindowsPath(value) {
317
+ return String(value ?? '').trim().replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
318
+ }
319
+
320
+ export function defenderExclusionCovers(repoRoot, exclusionPaths) {
321
+ const repo = normalizeWindowsPath(repoRoot)
322
+ if (!repo) return false
323
+ return (exclusionPaths ?? []).some((entry) => {
324
+ const exclusion = normalizeWindowsPath(entry)
325
+ if (!exclusion) return false
326
+ return repo === exclusion || repo.startsWith(`${exclusion}\\`)
327
+ })
328
+ }
329
+
330
+ // EPERM renames during compilation and slow installs are almost always
331
+ // Defender's real-time scan holding a handle on freshly written files. The
332
+ // bootstrap only adds an exclusion when it can elevate, so audit it here.
333
+ // Non-admin sessions may see exclusions redacted ("N/A: Must be an
334
+ // administrator…") — surfaced as an unverifiable warn, not a pass.
335
+ export function checkDefenderExclusion(repoRoot) {
336
+ if (process.platform !== 'win32') return null
337
+ const script =
338
+ "$ErrorActionPreference='Stop'; try { $status = Get-MpComputerStatus; $prefs = Get-MpPreference; @{ rtp = [bool]$status.RealTimeProtectionEnabled; exclusions = @($prefs.ExclusionPath | ForEach-Object { [string]$_ }) } | ConvertTo-Json -Compress } catch { exit 1 }"
339
+ const output = commandOutput('powershell', ['-NoProfile', '-NonInteractive', '-Command', script])
340
+ if (!output) return null
341
+ let parsed
342
+ try {
343
+ parsed = JSON.parse(output.trim())
344
+ } catch {
345
+ return null
346
+ }
347
+ if (!parsed.rtp) {
348
+ return result('defender', 'Microsoft Defender', 'pass', 'real-time protection is off (or another security product manages this device)')
349
+ }
350
+ const exclusions = Array.isArray(parsed.exclusions) ? parsed.exclusions : []
351
+ if (defenderExclusionCovers(repoRoot, exclusions)) {
352
+ return result('defender', 'Microsoft Defender', 'pass', 'repo is excluded from real-time scanning')
353
+ }
354
+ const addCommand = `Add-MpPreference -ExclusionPath '${repoRoot.replace(/'/g, "''")}'`
355
+ const redacted = exclusions.some((entry) => /administrator/i.test(String(entry)))
356
+ return result(
357
+ 'defender',
358
+ 'Microsoft Defender',
359
+ 'warn',
360
+ redacted
361
+ ? 'real-time protection is on and the exclusion list is hidden from non-admins — cannot verify this repo is excluded (unexcluded checkouts hit EPERM renames during builds and slow installs)'
362
+ : 'real-time protection is on with no exclusion for this repo — the scanner locks freshly written build output mid-rename (EPERM) and slows installs',
363
+ {
364
+ guide: [`Exclude the repo (elevated PowerShell): ${addCommand}`],
365
+ it: [`Microsoft Defender exclusion for the dev checkout: ${addCommand}`],
366
+ },
367
+ )
368
+ }
369
+
316
370
  // Verifies a container can reach a host port through host.docker.internal —
317
371
  // the linchpin of the hybrid topology (OpenCode container -> host MCP). Two
318
372
  // probes: (1) with the same extra_hosts pin compose.infra.yml uses (unless
@@ -333,6 +387,36 @@ function probeContainerToHost(mcpPort, addHost) {
333
387
  }
334
388
  }
335
389
 
390
+ export function hostIpCandidates(interfaces = os.networkInterfaces()) {
391
+ const named = Object.entries(interfaces ?? {})
392
+ // WSL vEthernet adapters first — the usual winner on Rancher Desktop/WSL2.
393
+ named.sort(([a], [b]) => Number(/wsl/i.test(b)) - Number(/wsl/i.test(a)))
394
+ const ips = []
395
+ for (const [, addresses] of named) {
396
+ for (const address of addresses ?? []) {
397
+ const family = address?.family
398
+ if ((family === 'IPv4' || family === 4) && !address.internal) ips.push(address.address)
399
+ }
400
+ }
401
+ return [...new Set(ips)]
402
+ }
403
+
404
+ // Find a host.docker.internal mapping that actually reaches this machine:
405
+ // the compose default pin first (correct on Docker Desktop and native Linux),
406
+ // then every host IPv4. Callers must ensure something answers on mcpPort.
407
+ export function detectHostGateway(mcpPort, { probeImpl = probeContainerToHost, candidates = hostIpCandidates() } = {}) {
408
+ const pinned = probeImpl(mcpPort, 'host.docker.internal:host-gateway')
409
+ if (!pinned.ran || /unable to find image|error response from daemon|cannot connect to the docker daemon/i.test(pinned.output ?? '')) {
410
+ return { status: 'skip' }
411
+ }
412
+ if (pinned.mcpAnswered) return { status: 'pin-ok' }
413
+ for (const ip of candidates) {
414
+ const probe = probeImpl(mcpPort, `host.docker.internal:${ip}`)
415
+ if (probe.mcpAnswered) return { status: 'use-ip', ip }
416
+ }
417
+ return { status: 'unreachable' }
418
+ }
419
+
336
420
  export function checkContainerToHost(repoRoot, { mcpPort }) {
337
421
  const docker = detectDocker()
338
422
  if (!docker.ok) return null
@@ -387,6 +471,7 @@ export async function runDoctor(repoRoot, { company = null, stackRunning = false
387
471
  await checkTlsInterception(repoRoot),
388
472
  await checkPorts(repoRoot, { stackRunning }),
389
473
  checkClonePath(repoRoot),
474
+ checkDefenderExclusion(repoRoot),
390
475
  ]
391
476
  if (includeContainerProbe) checks.push(checkContainerToHost(repoRoot, { mcpPort: ports.mcp }))
392
477
  for (const custom of company?.checks ?? []) {
package/src/env-setup.mjs CHANGED
@@ -1,31 +1,21 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import { spawnSync } from 'node:child_process'
4
3
 
5
4
  import { addEnvValue, readEnvValue, replaceEnvLine, uncommentEnvLine, writeEnvFileText } from './env-file.mjs'
6
5
  import { secretFingerprint, secretHex } from './secrets.mjs'
7
6
 
8
7
  // Node port of the Windows launcher's Initialize-EnvFiles: fill-missing-only,
9
- // secrets are generated once and never overwritten on re-runs, and postgres
10
- // credentials are only generated while no initialized data volume exists yet
11
- // (changing them afterwards would break auth against the data inside it).
12
-
13
- function postgresVolumeExists() {
14
- const result = spawnSync('docker', ['volume', 'ls', '--format', '{{.Name}}'], { encoding: 'utf8' })
15
- if (result.error || result.status !== 0) return false
16
- return String(result.stdout ?? '')
17
- .split('\n')
18
- .map((line) => line.trim())
19
- .includes('mercato-postgres-data')
20
- }
8
+ // machine-local secrets are generated once and never overwritten on re-runs.
9
+ // Postgres and superadmin credentials deliberately stay on the documented dev
10
+ // defaults (postgres/postgres, superadmin@acme.com/secret): the data volume
11
+ // outlives .env, so generated credentials drift from the volume on every
12
+ // re-clone or env regeneration, and the documented login stops working.
21
13
 
22
14
  export function ensureEnvFiles(repoRoot, { log = console.log, warn = console.warn, extraDefaults = {} } = {}) {
23
15
  const rootEnv = path.join(repoRoot, '.env')
24
16
  const appEnvExample = path.join(repoRoot, 'apps', 'mercato', '.env.example')
25
17
  const appEnv = path.join(repoRoot, 'apps', 'mercato', '.env')
26
18
 
27
- const volumeExists = postgresVolumeExists()
28
-
29
19
  if (!fs.existsSync(rootEnv)) {
30
20
  writeEnvFileText(rootEnv, '# Open Mercato compose environment (generated by the hybrid starter)\n')
31
21
  log(`Created ${rootEnv}`)
@@ -50,22 +40,11 @@ export function ensureEnvFiles(repoRoot, { log = console.log, warn = console.war
50
40
  for (const [key, value] of Object.entries(extraDefaults)) {
51
41
  setValue(key, value)
52
42
  }
53
-
54
- let postgresPassword = readEnvValue(rootEnv, 'POSTGRES_PASSWORD')?.trim() || ''
55
- if (volumeExists) {
56
- if (readEnvValue(rootEnv, 'OM_INIT_SUPERADMIN_PASSWORD') === null) {
57
- warn('Existing postgres volume detected — keeping compose default credentials. Remove the mercato-postgres-data volume for a clean slate.')
58
- }
59
- if (!postgresPassword) {
60
- warn('Existing postgres volume but no POSTGRES_PASSWORD in .env — the app will try the compose default (postgres), which only works if the volume was created with it.')
61
- }
62
- } else {
63
- setSecret('OM_INIT_SUPERADMIN_PASSWORD', secretHex(8))
64
- if (!postgresPassword) {
65
- postgresPassword = secretHex(16)
66
- setSecret('POSTGRES_PASSWORD', postgresPassword)
67
- }
68
- }
43
+ // Dev-default credentials, not secrets (see header). Set after extraDefaults
44
+ // so a company preset — like anything already in .env — wins.
45
+ setValue('OM_INIT_SUPERADMIN_PASSWORD', 'secret')
46
+ const postgresPassword = readEnvValue(rootEnv, 'POSTGRES_PASSWORD')?.trim() || 'postgres'
47
+ setValue('POSTGRES_PASSWORD', postgresPassword)
69
48
  // NOTE: MCP_SERVER_API_KEY is intentionally NOT generated here. `yarn dev`
70
49
  // provisions a real DB-backed key into .mercato/mcp-shared; a random env
71
50
  // value would shadow it and break OpenCode -> MCP authentication.
@@ -76,15 +55,13 @@ export function ensureEnvFiles(repoRoot, { log = console.log, warn = console.war
76
55
  content = replaceEnvLine(content, 'JWT_SECRET', jwtSecret)
77
56
  content = replaceEnvLine(content, 'AUTH_SECRET', secretHex(32))
78
57
  content = replaceEnvLine(content, 'LOOKUP_HASH_PEPPER', secretHex(16))
79
- if (postgresPassword) {
80
- // 127.0.0.1, never `localhost`: on modern Windows localhost resolves
81
- // to ::1 first while the published container ports listen on IPv4.
82
- content = replaceEnvLine(
83
- content,
84
- 'DATABASE_URL',
85
- `postgres://postgres:${postgresPassword}@127.0.0.1:5432/open-mercato`,
86
- )
87
- }
58
+ // 127.0.0.1, never `localhost`: on modern Windows localhost resolves
59
+ // to ::1 first while the published container ports listen on IPv4.
60
+ content = replaceEnvLine(
61
+ content,
62
+ 'DATABASE_URL',
63
+ `postgres://postgres:${postgresPassword}@127.0.0.1:5432/open-mercato`,
64
+ )
88
65
  // The hybrid starter runs a meilisearch container, so point the app at it
89
66
  // (the example ships these commented out).
90
67
  content = uncommentEnvLine(content, 'MEILISEARCH_HOST', 'http://127.0.0.1:7700')
@@ -111,7 +88,7 @@ export function ensureEnvFiles(repoRoot, { log = console.log, warn = console.war
111
88
  log(' set OM_INIT_SUPERADMIN_EMAIL in apps/mercato/.env')
112
89
  }
113
90
  if (superPassword && addEnvValue(appEnv, 'OM_INIT_SUPERADMIN_PASSWORD', superPassword)) {
114
- log(' set OM_INIT_SUPERADMIN_PASSWORD (sha256 …) in apps/mercato/.env')
91
+ log(' set OM_INIT_SUPERADMIN_PASSWORD in apps/mercato/.env')
115
92
  }
116
93
  }
117
94
 
package/src/infra.mjs CHANGED
@@ -7,7 +7,7 @@ import fs from 'node:fs'
7
7
  import path from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
9
 
10
- import { detectDocker, resolveRepoRoot, runCompose } from './compose.mjs'
10
+ import { detectDocker, resolveRepoRoot, runCompose, runCaptureSync } from './compose.mjs'
11
11
 
12
12
  export function ensureMcpSharedDir(repoRoot) {
13
13
  // Pre-create the bind-mount source so dockerd never creates it root-owned
@@ -35,6 +35,62 @@ export function infraDown(repoRoot, { volumes = false } = {}) {
35
35
  return runCompose(repoRoot ?? resolveRepoRoot(), ['down', ...(volumes ? ['--volumes'] : [])]).status ?? 1
36
36
  }
37
37
 
38
+ export function composeResourceNames(repoRoot, { composeFile, runComposeImpl = runCompose } = {}) {
39
+ const config = runComposeImpl(repoRoot, ['config', '--format', 'json'], { composeFile, stdio: 'pipe' })
40
+ if ((config.status ?? 1) !== 0) return { containers: [], volumes: [] }
41
+ try {
42
+ const parsed = JSON.parse(String(config.stdout ?? ''))
43
+ return {
44
+ containers: Object.values(parsed.services ?? {}).map((service) => service?.container_name).filter(Boolean),
45
+ volumes: Object.values(parsed.volumes ?? {}).map((volume) => volume?.name).filter(Boolean),
46
+ }
47
+ } catch {
48
+ return { containers: [], volumes: [] }
49
+ }
50
+ }
51
+
52
+ // `docker compose down --volumes` only removes resources labeled with the
53
+ // CURRENT project (named after the checkout directory). The fixed
54
+ // container_name/volume names in the compose files mean a second checkout of
55
+ // this repo reuses resources created by the first — down skips those
56
+ // silently, so a reset would leave the old database volume (and its
57
+ // incompatible secrets) alive. Sweep leftovers by their fixed names.
58
+ export function removeLeftoverComposeResources(repoRoot, { composeFile, runComposeImpl = runCompose, runCaptureImpl = runCaptureSync, log = console.log, warn = console.warn } = {}) {
59
+ const { containers, volumes } = composeResourceNames(repoRoot, { composeFile, runComposeImpl })
60
+ const listNames = (args) => {
61
+ const run = runCaptureImpl('docker', args)
62
+ if (run.error || (run.status ?? 1) !== 0) return new Set()
63
+ return new Set(String(run.stdout ?? '').split('\n').map((line) => line.trim()).filter(Boolean))
64
+ }
65
+
66
+ const existingContainers = listNames(['ps', '-a', '--format', '{{.Names}}'])
67
+ for (const name of containers) {
68
+ if (!existingContainers.has(name)) continue
69
+ const removed = runCaptureImpl('docker', ['rm', '-f', name])
70
+ if ((removed.status ?? 1) === 0) log(` removed leftover container ${name} (created by another checkout)`)
71
+ else warn(` could not remove container ${name}: ${String(removed.stderr ?? '').trim()}`)
72
+ }
73
+
74
+ let clean = true
75
+ const existingVolumes = listNames(['volume', 'ls', '--format', '{{.Name}}'])
76
+ for (const name of volumes) {
77
+ if (!existingVolumes.has(name)) continue
78
+ const removed = runCaptureImpl('docker', ['volume', 'rm', name])
79
+ if ((removed.status ?? 1) === 0) {
80
+ log(` removed leftover volume ${name} (created by another checkout)`)
81
+ continue
82
+ }
83
+ clean = false
84
+ warn(` could not remove volume ${name}: ${String(removed.stderr ?? '').trim()}`)
85
+ const holders = runCaptureImpl('docker', ['ps', '-a', '--filter', `volume=${name}`, '--format', '{{.Names}}'])
86
+ const holderNames = String(holders.stdout ?? '').split('\n').map((line) => line.trim()).filter(Boolean)
87
+ if (holderNames.length > 0) {
88
+ warn(` still mounted by: ${holderNames.join(', ')} — remove them (docker rm -f ${holderNames.join(' ')}) and re-run reset`)
89
+ }
90
+ }
91
+ return clean
92
+ }
93
+
38
94
  function main() {
39
95
  const args = process.argv.slice(2)
40
96
  const action = args[0]
package/src/steps.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { spawnSync } from 'node:child_process'
1
+ import { spawn, spawnSync } from 'node:child_process'
2
2
  import crypto from 'node:crypto'
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
@@ -7,9 +7,9 @@ import { detectDocker, parseComposePsOutput, runCaptureSync, runCompose, runStre
7
7
  import { captureInterceptionCas, findVendorCaBundles, harvestWindowsStoreCas, hostTrustEnv, probeTlsInterception, provisionDockerCerts, provisionRancherDesktopCa, summarizeProbeResults, writeCaBundle } from './certs.mjs'
8
8
  import { loadCompanyConfig, resolveCompanyCertBundles } from './company.mjs'
9
9
  import { CAPTURED_CA_BUNDLE, DEFAULT_OPENCODE_BASE_IMAGE, OPENCODE_SERVICE_IMAGE, STARTER_STATE_DIR, resolveStackPorts } from './constants.mjs'
10
- import { checkBuildToolchain, checkContainerRuntime, checkGit, checkNodeVersion, checkWsl2 } from './doctor.mjs'
10
+ import { checkBuildToolchain, checkContainerRuntime, checkGit, checkNodeVersion, checkWsl2, detectHostGateway } from './doctor.mjs'
11
11
  import { ensureEnvFiles } from './env-setup.mjs'
12
- import { readEnvValue } from './env-file.mjs'
12
+ import { addEnvValue, readEnvValue } from './env-file.mjs'
13
13
  import { ensureMcpSharedDir, infraUp } from './infra.mjs'
14
14
  import { ensureLlmProvider } from './providers.mjs'
15
15
  import { color, formatDuration, guideBox, statusLine, stepHeader } from './ui.mjs'
@@ -109,7 +109,7 @@ function walkMigrationModules(repoRoot) {
109
109
  }
110
110
 
111
111
  function listMigrationFiles(repoRoot) {
112
- return [...walkMigrationModules(repoRoot).values()].flat().sort()
112
+ return [...walkMigrationModules(repoRoot).values()].flat().sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
113
113
  }
114
114
 
115
115
  export function listMigrationModules(repoRoot) {
@@ -152,6 +152,71 @@ export function readAppliedMigrationModules(ctx, { runComposeImpl = runCompose }
152
152
  )
153
153
  }
154
154
 
155
+ // The compose healthcheck (pg_isready) never authenticates, so an existing
156
+ // data volume whose password no longer matches the env files still reports
157
+ // healthy — the volume keeps the password it was initialized with, and the
158
+ // run would only die later inside `yarn db:migrate` with a raw FATAL.
159
+ //
160
+ // The postgres image trusts loopback (pg_hba: `host all all 127.0.0.1/32
161
+ // trust`) and only demands a password via the catch-all
162
+ // `host all all all scram-sha-256` rule — i.e. for NON-loopback source
163
+ // addresses. The app hits that rule because it arrives from the docker
164
+ // gateway, so the probe must too: connect to the container's OWN routable IP
165
+ // (hostname -i), not 127.0.0.1, or the check trusts every password. User and
166
+ // password go through `-e` env vars (libpq reads PGUSER/PGPASSWORD) so they
167
+ // never enter the shell string — no injection even with `'`/`$` in a value.
168
+ // Returns 'ok', 'auth-failed', or null when no verdict is possible (URL
169
+ // unparsable, compose unavailable, postgres not managed here).
170
+ export function probePostgresCredentials(ctx, { runComposeImpl = runCompose } = {}) {
171
+ const dbUrl = readEnvValue(path.join(ctx.repoRoot, 'apps', 'mercato', '.env'), 'DATABASE_URL') ?? ''
172
+ let parsed
173
+ try {
174
+ parsed = new URL(dbUrl)
175
+ } catch {
176
+ return null
177
+ }
178
+ const user = decodeURIComponent(parsed.username || 'postgres')
179
+ const password = decodeURIComponent(parsed.password || 'postgres')
180
+ let result
181
+ try {
182
+ result = runComposeImpl(ctx.repoRoot, [
183
+ 'exec', '-T', '-e', `PGPASSWORD=${password}`, '-e', `PGUSER=${user}`, 'postgres',
184
+ 'sh', '-c', `exec psql -h "$(hostname -i | awk '{print $1}')" -d postgres -t -A -c 'select 1'`,
185
+ ], { stdio: 'pipe' })
186
+ } catch {
187
+ return null
188
+ }
189
+ if (result.status === 0) return 'ok'
190
+ const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`
191
+ return /password authentication failed|role ".*" does not exist/i.test(output) ? 'auth-failed' : null
192
+ }
193
+
194
+ // Distinguishes "the data volume already carries a seeded database" from a
195
+ // genuinely empty one. `yarn initialize` hard-aborts when it finds existing
196
+ // users, so on a re-clone (fresh .mercato markers, old postgres volume) the
197
+ // database step must adopt the volume instead of trying to seed it. Queries
198
+ // over the trust-auth socket (no password needed) and guards the users table
199
+ // existence in one round trip. Returns true (seeded), false (empty/no table),
200
+ // or null when the probe cannot run.
201
+ export function databaseIsInitialized(ctx, { runComposeImpl = runCompose } = {}) {
202
+ const dbUrl = readEnvValue(path.join(ctx.repoRoot, 'apps', 'mercato', '.env'), 'DATABASE_URL') ?? ''
203
+ const dbName = dbUrl.split('/').pop()?.split('?')[0]?.trim() || 'open-mercato'
204
+ let result
205
+ try {
206
+ result = runComposeImpl(ctx.repoRoot, [
207
+ 'exec', '-T', 'postgres',
208
+ 'psql', '-U', 'postgres', '-d', dbName, '-t', '-A', '-c',
209
+ "select case when to_regclass('public.users') is null then -1 else (select count(*) from users) end",
210
+ ], { stdio: 'pipe' })
211
+ } catch {
212
+ return null
213
+ }
214
+ if (result.status !== 0) return null
215
+ const value = Number.parseInt(String(result.stdout ?? '').trim(), 10)
216
+ if (!Number.isFinite(value) || value < 0) return false
217
+ return value > 0
218
+ }
219
+
155
220
  // `--clean` support: drop the convergence markers (install/build/migrations)
156
221
  // so the next `up` re-runs those steps. Keeps the remembered mode AND the
157
222
  // db-initialized markers — --clean does not touch data, and `yarn initialize`
@@ -523,9 +588,28 @@ export const databaseStep = {
523
588
  if (initialized && migrated) {
524
589
  return { ok: true, detail: 'database initialized, no new migration files' }
525
590
  }
591
+ // Re-clone / wiped-markers with an old data volume still attached: the
592
+ // schema + seed are already there, so `initialize` would hard-abort on the
593
+ // existing users. Detect it here so apply adopts the volume (skips the
594
+ // seed) instead of failing. dbReinitialize (empty volume) is the opposite
595
+ // case and must still seed, so it is excluded above.
596
+ if (!initialized && databaseIsInitialized(ctx) === true) {
597
+ ctx.dbAlreadyInitialized = true
598
+ return { ok: false, detail: migrated
599
+ ? 'existing data volume already initialized — recording state, skipping seed'
600
+ : 'existing data volume already initialized — applying pending migrations, skipping seed' }
601
+ }
526
602
  return { ok: false, detail: initialized ? 'new migration files — applying pending migrations' : 'first run — migrate + initialize' }
527
603
  },
528
604
  async apply(ctx) {
605
+ if (probePostgresCredentials(ctx) === 'auth-failed') {
606
+ throw new Error([
607
+ 'Postgres rejected the credentials from apps/mercato/.env (DATABASE_URL).',
608
+ 'The data volume keeps the password it was initialized with — a regenerated or freshly cloned .env no longer matches it.',
609
+ 'Either restore the original POSTGRES_PASSWORD in .env and in apps/mercato/.env (DATABASE_URL), then re-run,',
610
+ 'or start over with a fresh database (DELETES all local data): yarn om reset',
611
+ ].join('\n'))
612
+ }
529
613
  runYarn(ctx, ['db:migrate'])
530
614
  writeState(ctx.repoRoot, ctx.dbMigrationsMarker, ctx.migrationsFingerprint)
531
615
  const applied = readAppliedMigrationModules(ctx)
@@ -534,12 +618,82 @@ export const databaseStep = {
534
618
  writeState(ctx.repoRoot, `${ctx.dbMigrationsMarker}-absent`, stillAbsent.join(','))
535
619
  }
536
620
  if (readState(ctx.repoRoot, ctx.dbMarker) === null || ctx.dbReinitialize) {
537
- runYarn(ctx, ['initialize'])
621
+ // Adopt an already-seeded volume instead of aborting on its users.
622
+ const alreadyInitialized = ctx.dbAlreadyInitialized ?? (!ctx.dbReinitialize && databaseIsInitialized(ctx) === true)
623
+ if (alreadyInitialized) {
624
+ ctx.log(' database already initialized (existing data volume) — recording state, skipping first-run seed')
625
+ } else {
626
+ runYarn(ctx, ['initialize'])
627
+ }
538
628
  writeState(ctx.repoRoot, ctx.dbMarker, new Date().toISOString())
539
629
  }
540
630
  },
541
631
  }
542
632
 
633
+ function startHostProbeListener(port) {
634
+ const script = `require('node:http').createServer((req, res) => { res.setHeader('content-type', 'application/json'); res.end('{"status":"ok","probe":"open-mercato-starter"}') }).listen(${port}, '0.0.0.0', () => console.log('listening'))`
635
+ return new Promise((resolve) => {
636
+ const child = spawn(process.execPath, ['-e', script], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true })
637
+ // A busy port (the real MCP server from a running dev session) is fine —
638
+ // the probes then hit whatever already answers there.
639
+ const timer = setTimeout(() => resolve(child), 2000)
640
+ child.stdout?.on('data', (chunk) => {
641
+ if (String(chunk).includes('listening')) {
642
+ clearTimeout(timer)
643
+ resolve(child)
644
+ }
645
+ })
646
+ child.on('error', () => {
647
+ clearTimeout(timer)
648
+ resolve(null)
649
+ })
650
+ })
651
+ }
652
+
653
+ // Rancher Desktop/WSL2 can resolve host-gateway to the WSL distro instead of
654
+ // the Windows host, silently breaking OpenCode -> host MCP. Probe BEFORE the
655
+ // containers come up and pin a working IP into .env (compose feeds it to
656
+ // extra_hosts; a changed pin makes compose recreate opencode on the next up).
657
+ // A throwaway listener on the MCP port makes the probe exercise the exact
658
+ // firewall path the real server will use.
659
+ export const hostGatewayStep = {
660
+ id: 'host-gateway',
661
+ expectation: 'a few seconds of container→host probing (Windows only)',
662
+ title: 'Container → host networking (OM_HOST_GATEWAY)',
663
+ appliesTo: (ctx) => process.platform === 'win32' && !ctx.flags.noInfra,
664
+ async check(ctx) {
665
+ if (readEnvValue(path.join(ctx.repoRoot, '.env'), 'OM_HOST_GATEWAY')?.trim()) {
666
+ return { ok: true, detail: 'OM_HOST_GATEWAY already pinned in .env' }
667
+ }
668
+ if (readState(ctx.repoRoot, 'host-gateway') === 'pin-ok') {
669
+ return { ok: true, detail: 'default host-gateway mapping verified earlier' }
670
+ }
671
+ return { ok: false, detail: 'probing whether containers can reach this machine' }
672
+ },
673
+ async apply(ctx) {
674
+ const ports = resolveStackPorts(ctx.repoRoot)
675
+ const listener = await startHostProbeListener(ports.mcp)
676
+ try {
677
+ const outcome = detectHostGateway(ports.mcp)
678
+ if (outcome.status === 'pin-ok') {
679
+ writeState(ctx.repoRoot, 'host-gateway', 'pin-ok')
680
+ ctx.log(' default host-gateway mapping reaches this machine')
681
+ } else if (outcome.status === 'use-ip') {
682
+ addEnvValue(path.join(ctx.repoRoot, '.env'), 'OM_HOST_GATEWAY', outcome.ip)
683
+ ctx.log(` host-gateway does not reach this machine (Rancher Desktop/WSL2) — pinned OM_HOST_GATEWAY=${outcome.ip} in .env`)
684
+ } else if (outcome.status === 'skip') {
685
+ ctx.log(' probe container could not run — skipping (run `yarn om doctor` for the full audit)')
686
+ } else {
687
+ ctx.log(` ⚠️ containers cannot reach this machine on port ${ports.mcp} — Windows Firewall likely blocks the WSL adapter`)
688
+ ctx.log(` ↳ allow it (elevated PowerShell): New-NetFirewallRule -DisplayName "Open Mercato MCP dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${ports.mcp} -Profile Any`)
689
+ ctx.log(' ↳ then re-run — OpenCode waits for the MCP server and recovers once the port answers')
690
+ }
691
+ } finally {
692
+ listener?.kill()
693
+ }
694
+ },
695
+ }
696
+
543
697
  export function buildUpSteps(ctx) {
544
698
  const base = [
545
699
  prerequisitesStep,
@@ -550,6 +704,7 @@ export function buildUpSteps(ctx) {
550
704
  workspaceInstallStep,
551
705
  workspaceBuildStep,
552
706
  llmProviderStep,
707
+ hostGatewayStep,
553
708
  infraUpStep,
554
709
  databaseStep,
555
710
  ]