@open-mercato/starter 0.6.5 → 0.7.1-develop.7195.1.b5efd1d51a
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 +4 -3
- package/src/__tests__/starter.test.mjs +177 -2
- package/src/cli.mjs +25 -4
- package/src/doctor.mjs +85 -0
- package/src/env-setup.mjs +18 -41
- package/src/infra.mjs +57 -1
- package/src/steps.mjs +160 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/starter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1-develop.7195.1.b5efd1d51a",
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
10
|
-
//
|
|
11
|
-
// (
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
content
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
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
|
]
|