@open-mercato/starter 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/steps.mjs ADDED
@@ -0,0 +1,613 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import crypto from 'node:crypto'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ import { detectDocker, parseComposePsOutput, runCaptureSync, runCompose, runStreamingSync } from './compose.mjs'
7
+ import { captureInterceptionCas, findVendorCaBundles, harvestWindowsStoreCas, hostTrustEnv, probeTlsInterception, provisionDockerCerts, provisionRancherDesktopCa, summarizeProbeResults, writeCaBundle } from './certs.mjs'
8
+ import { loadCompanyConfig, resolveCompanyCertBundles } from './company.mjs'
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'
11
+ import { ensureEnvFiles } from './env-setup.mjs'
12
+ import { readEnvValue } from './env-file.mjs'
13
+ import { ensureMcpSharedDir, infraUp } from './infra.mjs'
14
+ import { ensureLlmProvider } from './providers.mjs'
15
+ import { color, formatDuration, guideBox, statusLine, stepHeader } from './ui.mjs'
16
+
17
+ // The idempotent convergence pipeline behind `up`. Every step follows the same
18
+ // contract so re-running is always safe and always cheap when nothing changed:
19
+ // check(ctx) -> { ok, detail } did reality already match the goal?
20
+ // apply(ctx) -> void (throws on fail) converge; must be a no-op on re-run
21
+ // guide(ctx) -> string[] printed when the step cannot self-fix
22
+ // A step without apply() is detect-and-guide only (per policy: WSL2 and the
23
+ // container runtime are proposed, never installed by us).
24
+
25
+ export class StepBlocked extends Error {
26
+ constructor(stepId, guide) {
27
+ super(`step blocked: ${stepId}`)
28
+ this.stepId = stepId
29
+ this.guide = guide
30
+ }
31
+ }
32
+
33
+ function stateFile(repoRoot, name) {
34
+ return path.join(repoRoot, STARTER_STATE_DIR, name)
35
+ }
36
+
37
+ function readState(repoRoot, name) {
38
+ try {
39
+ return fs.readFileSync(stateFile(repoRoot, name), 'utf8').trim()
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ function writeState(repoRoot, name, value) {
46
+ fs.mkdirSync(path.join(repoRoot, STARTER_STATE_DIR), { recursive: true })
47
+ fs.writeFileSync(stateFile(repoRoot, name), `${value}\n`)
48
+ }
49
+
50
+ function hashFiles(repoRoot, files) {
51
+ const hash = crypto.createHash('sha256')
52
+ for (const file of files) {
53
+ const filePath = path.join(repoRoot, file)
54
+ hash.update(file)
55
+ hash.update(fs.existsSync(filePath) ? fs.readFileSync(filePath) : Buffer.alloc(0))
56
+ }
57
+ return hash.digest('hex')
58
+ }
59
+
60
+ // Fingerprint of every module's Migration*.ts files (name + size, sorted).
61
+ // Snapshot dot-files are excluded on purpose: they change alongside schema
62
+ // work without requiring `db:migrate`. A stable fingerprint + the initialized
63
+ // marker means the database step can skip entirely instead of paying a full
64
+ // `yarn db:migrate` no-op on every start.
65
+ function walkMigrationModules(repoRoot) {
66
+ const moduleRoots = []
67
+ for (const base of ['packages', path.join('external', 'official-modules', 'packages')]) {
68
+ const baseDir = path.join(repoRoot, base)
69
+ let entries = []
70
+ try {
71
+ entries = fs.readdirSync(baseDir)
72
+ } catch {
73
+ continue
74
+ }
75
+ for (const entry of entries) moduleRoots.push(path.join(baseDir, entry, 'src', 'modules'))
76
+ }
77
+ moduleRoots.push(path.join(repoRoot, 'apps', 'mercato', 'src', 'modules'))
78
+ const modulesWithMigrations = new Map()
79
+ for (const modulesDir of moduleRoots) {
80
+ let modules = []
81
+ try {
82
+ modules = fs.readdirSync(modulesDir)
83
+ } catch {
84
+ continue
85
+ }
86
+ for (const moduleName of modules) {
87
+ const migrationsDir = path.join(modulesDir, moduleName, 'migrations')
88
+ let migrationFiles = []
89
+ try {
90
+ migrationFiles = fs.readdirSync(migrationsDir)
91
+ } catch {
92
+ continue
93
+ }
94
+ for (const file of migrationFiles) {
95
+ if (!file.startsWith('Migration')) continue
96
+ let size = 0
97
+ try {
98
+ size = fs.statSync(path.join(migrationsDir, file)).size
99
+ } catch {
100
+ size = 0
101
+ }
102
+ const files = modulesWithMigrations.get(moduleName) ?? []
103
+ files.push(`${path.relative(repoRoot, path.join(migrationsDir, file))}:${size}`)
104
+ modulesWithMigrations.set(moduleName, files)
105
+ }
106
+ }
107
+ }
108
+ return modulesWithMigrations
109
+ }
110
+
111
+ function listMigrationFiles(repoRoot) {
112
+ return [...walkMigrationModules(repoRoot).values()].flat().sort()
113
+ }
114
+
115
+ export function listMigrationModules(repoRoot) {
116
+ return new Set(walkMigrationModules(repoRoot).keys())
117
+ }
118
+
119
+ export function migrationsFingerprint(repoRoot) {
120
+ return crypto.createHash('sha256').update(listMigrationFiles(repoRoot).join('\n')).digest('hex')
121
+ }
122
+
123
+ // Host-side markers can lie: `db:migrate` may have run against a stale module
124
+ // registry (skipping a module's migrations entirely), or the postgres volume
125
+ // may have been recreated since the marker was written. Ask the database which
126
+ // modules actually have migration bookkeeping (MikroORM keeps one
127
+ // mikro_orm_migrations_<module> table per module) so the database step
128
+ // converges on reality instead of on its own notes. Returns null when the
129
+ // probe cannot run (postgres not up yet, compose unavailable) — callers fall
130
+ // back to marker-only behavior.
131
+ export function readAppliedMigrationModules(ctx, { runComposeImpl = runCompose } = {}) {
132
+ const dbUrl = readEnvValue(path.join(ctx.repoRoot, 'apps', 'mercato', '.env'), 'DATABASE_URL') ?? ''
133
+ const dbName = dbUrl.split('/').pop()?.split('?')[0]?.trim() || 'open-mercato'
134
+ let result
135
+ try {
136
+ result = runComposeImpl(ctx.repoRoot, [
137
+ 'exec', '-T', 'postgres',
138
+ 'psql', '-U', 'postgres', '-d', dbName, '-t', '-A', '-c',
139
+ "select table_name from information_schema.tables where table_schema='public' and table_name like 'mikro_orm_migrations%'",
140
+ ], { stdio: 'pipe' })
141
+ } catch {
142
+ return null
143
+ }
144
+ if (result.status !== 0) return null
145
+ return new Set(
146
+ String(result.stdout ?? '')
147
+ .split('\n')
148
+ .map((line) => line.trim())
149
+ .filter(Boolean)
150
+ .map((tableName) => tableName.replace(/^mikro_orm_migrations_?/, ''))
151
+ .filter(Boolean),
152
+ )
153
+ }
154
+
155
+ // `--clean` support: drop the convergence markers (install/build/migrations)
156
+ // so the next `up` re-runs those steps. Keeps the remembered mode AND the
157
+ // db-initialized markers — --clean does not touch data, and `yarn initialize`
158
+ // hard-aborts when it finds existing users; wiping THAT state is `reset`'s job.
159
+ export function clearConvergenceState(repoRoot) {
160
+ const stateDir = path.join(repoRoot, STARTER_STATE_DIR)
161
+ let entries = []
162
+ try {
163
+ entries = fs.readdirSync(stateDir)
164
+ } catch {
165
+ return []
166
+ }
167
+ const cleared = []
168
+ for (const entry of entries) {
169
+ if (entry === 'mode' || entry.startsWith('db-initialized-')) continue
170
+ fs.rmSync(path.join(stateDir, entry), { force: true })
171
+ cleared.push(entry)
172
+ }
173
+ return cleared
174
+ }
175
+
176
+ // On managed Windows machines Node often lives in Program Files where
177
+ // `corepack enable` cannot write the yarn shim without admin; corepack itself
178
+ // still runs the project-pinned yarn. Callers switch via ctx.yarnViaCorepack.
179
+ export function yarnInvocation(ctx, args) {
180
+ return ctx.yarnViaCorepack
181
+ ? { command: 'corepack', args: ['yarn', ...args] }
182
+ : { command: 'yarn', args }
183
+ }
184
+
185
+ function runYarn(ctx, args) {
186
+ const invocation = yarnInvocation(ctx, args)
187
+ const status = runStreamingSync(invocation.command, invocation.args, { cwd: ctx.repoRoot, env: ctx.env })
188
+ if (status !== 0) {
189
+ throw new Error(`yarn ${args.join(' ')} failed (exit ${status}) — fix the error above and re-run; completed steps are skipped.`)
190
+ }
191
+ }
192
+
193
+ // ── Steps ────────────────────────────────────────────────────────────────────
194
+
195
+ export const prerequisitesStep = {
196
+ id: 'prerequisites',
197
+ expectation: 'instant (activates yarn via corepack when missing)',
198
+ title: 'Toolchain (Node 24, yarn, git)',
199
+ async check(ctx) {
200
+ const node = checkNodeVersion()
201
+ const git = checkGit()
202
+ const yarnRun = runCaptureSync('yarn', ['--version'])
203
+ const yarnOk = !yarnRun.error && yarnRun.status === 0
204
+ if (node.level === 'fail' || git.level === 'fail') {
205
+ const blockers = [node, git].filter((entry) => entry.level === 'fail')
206
+ throw new StepBlocked(this.id, blockers.flatMap((entry) => [`${entry.title}: ${entry.detail}`, ...entry.guide]))
207
+ }
208
+ ctx.hasYarn = yarnOk
209
+ return { ok: yarnOk, detail: yarnOk ? `node v${process.versions.node}, yarn ${String(yarnRun.stdout).trim()}` : 'yarn missing — activating via corepack' }
210
+ },
211
+ async apply(ctx) {
212
+ // Node exists (we are running on it), so yarn is ours to install: enable
213
+ // corepack and activate the exact version pinned in package.json.
214
+ const spec = JSON.parse(fs.readFileSync(path.join(ctx.repoRoot, 'package.json'), 'utf8')).packageManager.split('+')[0]
215
+ const enable = runStreamingSync('corepack', ['enable'], { cwd: ctx.repoRoot, env: ctx.env })
216
+ if (enable !== 0) ctx.log('⚠️ corepack enable failed (PATH not writable?) — falling back to running yarn through corepack.')
217
+ const prepare = runStreamingSync('corepack', ['prepare', spec, '--activate'], { cwd: ctx.repoRoot, env: ctx.env })
218
+ if (prepare !== 0) {
219
+ throw new Error(`corepack prepare ${spec} failed. Behind a corporate proxy? Set HTTPS_PROXY and re-run — the starter provisions the corporate CA automatically in the next step; if this step itself is blocked, set COREPACK_NPM_REGISTRY to your internal mirror (see starters/company/).`)
220
+ }
221
+ const shim = runCaptureSync('yarn', ['--version'], { cwd: ctx.repoRoot, env: ctx.env })
222
+ if (shim.error || shim.status !== 0) {
223
+ // Managed devices: Node in Program Files means `corepack enable` cannot
224
+ // write the yarn shim without admin. Corepack itself still runs the
225
+ // activated yarn, so route every yarn call through it.
226
+ const viaCorepack = runCaptureSync('corepack', ['yarn', '--version'], { cwd: ctx.repoRoot, env: ctx.env })
227
+ if (viaCorepack.error || viaCorepack.status !== 0) {
228
+ throw new Error('yarn is still not runnable after corepack activation. Ask IT to allow `corepack enable`, or install yarn per-user, then re-run.')
229
+ }
230
+ ctx.yarnViaCorepack = true
231
+ ctx.log(` yarn ${String(viaCorepack.stdout).trim()} activated (no PATH shim — running yarn through corepack)`)
232
+ }
233
+ },
234
+ }
235
+
236
+ export const containerRuntimeStep = {
237
+ id: 'container-runtime',
238
+ expectation: 'instant check; install guidance if missing',
239
+ title: 'Container runtime (Docker Desktop / Rancher Desktop / engine)',
240
+ appliesTo: (ctx) => !ctx.flags.noInfra,
241
+ async check(ctx) {
242
+ const runtime = checkContainerRuntime()
243
+ if (runtime.level === 'pass') return { ok: true, detail: runtime.detail }
244
+ // Policy: we never install WSL2 or a container runtime — propose only.
245
+ const wsl = checkWsl2()
246
+ const guide = []
247
+ if (wsl && wsl.level !== 'pass') guide.push(`WSL2 first: ${wsl.detail}`, ...wsl.guide, '')
248
+ guide.push(...runtime.guide)
249
+ guide.push('', 'Re-run this starter once the runtime is up — every completed step is skipped.')
250
+ if (ctx.mode === 'hybrid') guide.push('No container runtime at all? `up --no-infra` starts just the app against services you point .env at.')
251
+ throw new StepBlocked(this.id, guide)
252
+ },
253
+ }
254
+
255
+ export const corporateCertsStep = {
256
+ id: 'corporate-certs',
257
+ expectation: 'a few seconds (network probes)',
258
+ title: 'Corporate TLS trust (proxy CA capture + provisioning)',
259
+ async check(ctx) {
260
+ const companyBundles = resolveCompanyCertBundles(ctx.repoRoot, ctx.company)
261
+ const vendorBundles = findVendorCaBundles()
262
+ const probes = await probeTlsInterception()
263
+ const { intercepted, unreachable } = summarizeProbeResults(probes)
264
+ ctx.tlsProbes = probes
265
+ if (intercepted.length === 0 && companyBundles.length === 0 && vendorBundles.length === 0) {
266
+ if (unreachable.length > 0) {
267
+ ctx.log(` ⚠️ unreachable during TLS probe: ${unreachable.map((entry) => `${entry.host} (${entry.reason})`).join(', ')} — continuing; expect trouble if installs need those hosts.`)
268
+ }
269
+ return { ok: true, detail: 'no TLS interception detected' }
270
+ }
271
+ ctx.certsWork = { companyBundles: [...companyBundles, ...vendorBundles], intercepted }
272
+ return { ok: false, detail: intercepted.length > 0 ? `interception detected on ${intercepted.map((entry) => entry.host).join(', ')}` : 'corporate CA bundle(s) found' }
273
+ },
274
+ async apply(ctx) {
275
+ const { companyBundles, intercepted } = ctx.certsWork
276
+ let captured = []
277
+ if (intercepted.length > 0 && ctx.company.certs.capture) {
278
+ // Two capture channels, merged: what the proxy presents on the wire
279
+ // (intermediates rotate — Zscaler rotates weekly) and what group policy
280
+ // deployed into the Windows store (the root often is NOT on the wire).
281
+ captured = await captureInterceptionCas(intercepted.map((entry) => entry.host))
282
+ const harvested = harvestWindowsStoreCas()
283
+ const known = new Set(captured.map((cert) => cert.fingerprint))
284
+ for (const cert of harvested) {
285
+ if (!known.has(cert.fingerprint)) captured.push(cert)
286
+ }
287
+ for (const cert of captured) ctx.log(` trusted CA: ${cert.subject}`)
288
+ if (captured.length === 0 && companyBundles.length === 0) {
289
+ throw new Error('TLS interception detected but no CA could be captured. Ask IT for the corporate root CA (PEM) and point starters/company/config.mjs certs.bundles at it.')
290
+ }
291
+ }
292
+ const bundlePath = writeCaBundle(ctx.repoRoot, { companyBundles, capturedPems: captured })
293
+ if (!bundlePath) return
294
+ ctx.env = hostTrustEnv(bundlePath, ctx.env)
295
+ const provisioned = provisionDockerCerts(ctx.repoRoot, bundlePath)
296
+ ctx.log(` host tooling: NODE_EXTRA_CA_CERTS=${path.relative(ctx.repoRoot, bundlePath)} + --use-system-ca`)
297
+ for (const target of provisioned) ctx.log(` image builds: ${path.relative(ctx.repoRoot, target)} (baked into app/opencode images)`)
298
+ const rancherScript = provisionRancherDesktopCa(ctx.repoRoot, bundlePath)
299
+ if (rancherScript) ctx.log(` rancher engine: ${rancherScript} (applied on the next Rancher Desktop restart)`)
300
+ if (process.platform === 'win32') {
301
+ // Mixed fleets: schannel became the fresh-install default only in Git
302
+ // 2.48.1, upgrades keep the old openssl choice — set it explicitly so
303
+ // git trusts the GPO-deployed root from the Windows store.
304
+ const gitConfig = spawnSync('git', ['config', '--global', 'http.sslBackend', 'schannel'], { stdio: 'ignore', windowsHide: true })
305
+ if (!gitConfig.error && gitConfig.status === 0) ctx.log(' git: http.sslBackend=schannel (uses the Windows certificate store)')
306
+ ctx.log(' ↳ persist for your own shells: setx NODE_EXTRA_CA_CERTS "' + bundlePath + '"')
307
+ ctx.log(' ↳ Docker Desktop reads the Windows certificate store — restart it after IT installs new CAs.')
308
+ } else {
309
+ ctx.log(` ↳ persist for your own shells: export NODE_EXTRA_CA_CERTS="${bundlePath}"`)
310
+ }
311
+ },
312
+ }
313
+
314
+ export const buildToolchainStep = {
315
+ id: 'build-toolchain',
316
+ expectation: 'instant check; install guidance if missing',
317
+ title: 'C++ build toolchain (native Node modules)',
318
+ // Only hybrid builds native addons on the host; --mode docker compiles them
319
+ // in the container. The check itself is Windows-only (returns null elsewhere).
320
+ appliesTo: (ctx) => ctx.mode === 'hybrid' && process.platform === 'win32',
321
+ async check(ctx) {
322
+ const toolchain = (ctx.checkBuildToolchainImpl ?? checkBuildToolchain)()
323
+ if (!toolchain || toolchain.level === 'pass') {
324
+ return { ok: true, detail: toolchain?.detail ?? 'not required on this platform' }
325
+ }
326
+ // The toolchain only matters when `yarn install` is about to (re)build
327
+ // native addons. Prebuilt binaries cover most modules, so a workspace
328
+ // that already converged runs fine without VC++ — blocking it would
329
+ // regress a working environment. Warn and move on; the hard gate stays
330
+ // for runs where an install is actually pending.
331
+ const installConverged = fs.existsSync(path.join(ctx.repoRoot, 'node_modules'))
332
+ && readState(ctx.repoRoot, 'install.hash') === hashFiles(ctx.repoRoot, ['yarn.lock', 'package.json'])
333
+ if (installConverged) {
334
+ ctx.log(' ⚠️ VC++ build toolchain not found — dependencies are already installed, so this is not blocking now.')
335
+ ctx.log(' ↳ the next dependency change may need it: winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"')
336
+ return { ok: true, detail: 'toolchain missing, but the workspace install already converged (prebuilt binaries)' }
337
+ }
338
+ // Fail fast with the fix instead of letting `yarn install` faceplant on
339
+ // node-gyp after several minutes. Propose-only: we never install it.
340
+ throw new StepBlocked(this.id, toolchain.guide)
341
+ },
342
+ }
343
+
344
+ export const envFilesStep = {
345
+ id: 'env-files',
346
+ expectation: 'instant; secrets generated once, never rotated',
347
+ title: 'Environment files (.env, secrets)',
348
+ async check() {
349
+ // ensureEnvFiles is fill-missing-only and cheap — always converge.
350
+ return { ok: false, detail: 'converging (never overwrites existing values)' }
351
+ },
352
+ async apply(ctx) {
353
+ ensureEnvFiles(ctx.repoRoot, { log: (line) => ctx.log(` ${line}`), warn: (line) => ctx.log(` ⚠️ ${line}`), extraDefaults: ctx.company.env })
354
+ },
355
+ }
356
+
357
+ export const workspaceInstallStep = {
358
+ id: 'workspace-install',
359
+ expectation: 'a few minutes on the first run, skipped afterwards',
360
+ title: 'Workspace dependencies (yarn install)',
361
+ async check(ctx) {
362
+ const current = hashFiles(ctx.repoRoot, ['yarn.lock', 'package.json'])
363
+ ctx.installHash = current
364
+ const previous = readState(ctx.repoRoot, 'install.hash')
365
+ const nodeModulesExists = fs.existsSync(path.join(ctx.repoRoot, 'node_modules'))
366
+ return { ok: nodeModulesExists && previous === current, detail: nodeModulesExists && previous === current ? 'lockfile unchanged' : 'lockfile changed or first run' }
367
+ },
368
+ async apply(ctx) {
369
+ if (ctx.company.mirrors.npmRegistry) {
370
+ ctx.env = { ...ctx.env, COREPACK_NPM_REGISTRY: ctx.company.mirrors.npmRegistry, YARN_NPM_REGISTRY_SERVER: ctx.company.mirrors.npmRegistry }
371
+ }
372
+ runYarn(ctx, ['install'])
373
+ writeState(ctx.repoRoot, 'install.hash', ctx.installHash)
374
+ ctx.installChanged = true
375
+ },
376
+ }
377
+
378
+ export const workspaceBuildStep = {
379
+ id: 'workspace-build',
380
+ expectation: '1-3 minutes on the first run, cached by turbo afterwards',
381
+ title: 'Build workspace packages + generate module artifacts',
382
+ async check(ctx) {
383
+ // The database step shells out to the `mercato` CLI, which only exists
384
+ // after packages are built (turbo's db:migrate task declares no build
385
+ // dependency) — so this must converge before any DB work on a fresh clone.
386
+ const cliBuilt = fs.existsSync(path.join(ctx.repoRoot, 'packages', 'cli', 'dist', 'bin.js'))
387
+ return { ok: cliBuilt && !ctx.installChanged, detail: cliBuilt && !ctx.installChanged ? 'packages built, dependencies unchanged' : 'building (turbo caches make re-runs fast)' }
388
+ },
389
+ async apply(ctx) {
390
+ runYarn(ctx, ['build:packages'])
391
+ runYarn(ctx, ['generate'])
392
+ },
393
+ }
394
+
395
+ export const llmProviderStep = {
396
+ id: 'llm-provider',
397
+ expectation: 'waits for your input on first run; have an API key ready',
398
+ title: 'AI provider (LLM API key)',
399
+ async apply(ctx) {
400
+ const outcome = await ensureLlmProvider({
401
+ rootEnv: path.join(ctx.repoRoot, '.env'),
402
+ appEnv: path.join(ctx.repoRoot, 'apps', 'mercato', '.env'),
403
+ }, {
404
+ skipPrompt: ctx.flags.skipLlmPrompt,
405
+ nonInteractive: ctx.flags.nonInteractive,
406
+ log: (line) => ctx.log(` ${line}`),
407
+ warn: (line) => ctx.log(` ⚠️ ${line}`),
408
+ })
409
+ if (outcome === 'failed') {
410
+ throw new Error('No LLM provider configured. Pass --skip-llm-prompt to continue without AI, or set a provider key in .env.')
411
+ }
412
+ },
413
+ async check() {
414
+ return { ok: false, detail: 'checking configured provider' }
415
+ },
416
+ }
417
+
418
+ export function resolveOpencodeBaseImage(repoRoot, env = process.env) {
419
+ return env.OPENCODE_BASE_IMAGE
420
+ ?? readEnvValue(path.join(repoRoot, '.env'), 'OPENCODE_BASE_IMAGE')?.trim()
421
+ ?? DEFAULT_OPENCODE_BASE_IMAGE
422
+ }
423
+
424
+ // The published image is a BASE (OpenCode binary + user, no entrypoint or
425
+ // agents — docker/opencode/BASE_IMAGE.md); the runnable service image is the
426
+ // thin local build FROM it. The only network step is the base pull, which
427
+ // goes through the engine's trust (OS certificate store on Docker/Rancher
428
+ // Desktop) and therefore survives corporate TLS interception that breaks
429
+ // build-stage egress; the thin build itself only COPYs project files.
430
+ // --rebuild forces the thin rebuild (e.g. after yarn generate in prod mode).
431
+ export function ensureOpencodeImage(ctx) {
432
+ const baseImage = resolveOpencodeBaseImage(ctx.repoRoot, ctx.env)
433
+ const baseExists = spawnSync('docker', ['image', 'inspect', baseImage], { stdio: 'ignore' }).status === 0
434
+ if (baseExists) {
435
+ ctx.log(` opencode base image: ${baseImage} (already present)`)
436
+ } else {
437
+ ctx.log(` pulling OpenCode base image ${baseImage} ...`)
438
+ const pull = spawnSync('docker', ['pull', baseImage], { stdio: 'inherit' })
439
+ if (pull.status !== 0) {
440
+ throw new Error(`Could not pull the OpenCode base image ${baseImage}. Behind a proxy that blocks registry-1.docker.io? Set OPENCODE_BASE_IMAGE in .env to your internal mirror (build/push runbook: docker/opencode/BASE_IMAGE.md), then re-run.`)
441
+ }
442
+ }
443
+ const serviceExists = spawnSync('docker', ['image', 'inspect', OPENCODE_SERVICE_IMAGE], { stdio: 'ignore' }).status === 0
444
+ if (serviceExists && !ctx.flags.rebuild) {
445
+ ctx.log(` opencode image: ${OPENCODE_SERVICE_IMAGE} (already built)`)
446
+ return
447
+ }
448
+ ctx.log(` building ${OPENCODE_SERVICE_IMAGE} from ${baseImage} (project files only — no network) ...`)
449
+ const build = spawnSync('docker', [
450
+ 'build',
451
+ '-t', OPENCODE_SERVICE_IMAGE,
452
+ '--build-arg', `OPENCODE_BASE_IMAGE=${baseImage}`,
453
+ path.join(ctx.repoRoot, 'docker', 'opencode'),
454
+ ], { stdio: 'inherit', cwd: ctx.repoRoot })
455
+ if (build.status !== 0) {
456
+ throw new Error('Local build of the OpenCode service image failed — see the output above, then re-run.')
457
+ }
458
+ }
459
+
460
+ export const infraUpStep = {
461
+ id: 'infra-up',
462
+ expectation: 'first run pulls the OpenCode base image (a few minutes); seconds afterwards',
463
+ title: 'Infra containers (postgres, redis, meilisearch, opencode)',
464
+ appliesTo: (ctx) => ctx.mode === 'hybrid' && !ctx.flags.noInfra,
465
+ async check(ctx) {
466
+ const ps = runCompose(ctx.repoRoot, ['ps', '--format', 'json'], { stdio: 'pipe' })
467
+ const services = parseComposePsOutput(ps.stdout ?? '')
468
+ const running = new Set(services.filter((entry) => entry.State === 'running').map((entry) => entry.Service))
469
+ const required = ['postgres', 'redis', 'meilisearch', 'opencode']
470
+ const missing = required.filter((service) => !running.has(service))
471
+ ctx.infraMissing = missing
472
+ return { ok: missing.length === 0 && !ctx.flags.rebuild, detail: missing.length === 0 ? 'all infra containers running' : `starting: ${missing.join(', ')}` }
473
+ },
474
+ async apply(ctx) {
475
+ const ports = resolveStackPorts(ctx.repoRoot)
476
+ ensureMcpSharedDir(ctx.repoRoot)
477
+ ensureOpencodeImage(ctx)
478
+ const status = infraUp(ctx.repoRoot, { build: false, profiles: ctx.flags.profiles })
479
+ if (status !== 0) {
480
+ const hints = ['docker compose up failed — see the output above. Re-running resumes from this step.']
481
+ if (ctx.tlsProbes?.some((entry) => entry.status === 'intercepted')) {
482
+ hints.push('TLS/certificate errors from the engine usually mean it does not trust the corporate CA yet: restart Docker Desktop (it re-imports the Windows certificate store) or Rancher Desktop (the starter wrote a provisioning script for it), then re-run.')
483
+ }
484
+ hints.push(`Port conflicts? Something else may own ${ports.postgres}/${ports.redis}/${ports.meilisearch}/${ports.opencode} — override the *_PORT vars in .env.`)
485
+ throw new Error(hints.join('\n'))
486
+ }
487
+ },
488
+ }
489
+
490
+ export const databaseStep = {
491
+ id: 'database',
492
+ expectation: 'migrations + seed take a minute or two on first run',
493
+ title: 'Database (migrations + first-run initialization)',
494
+ appliesTo: (ctx) => !ctx.flags.skipDb,
495
+ async check(ctx) {
496
+ const dbUrl = readEnvValue(path.join(ctx.repoRoot, 'apps', 'mercato', '.env'), 'DATABASE_URL') ?? ''
497
+ const dbKey = crypto.createHash('sha256').update(dbUrl).digest('hex').slice(0, 12)
498
+ ctx.dbMarker = `db-initialized-${dbKey}`
499
+ ctx.dbMigrationsMarker = `db-migrations-${dbKey}`
500
+ ctx.migrationsFingerprint = migrationsFingerprint(ctx.repoRoot)
501
+ const initialized = readState(ctx.repoRoot, ctx.dbMarker) !== null
502
+ const migrated = readState(ctx.repoRoot, ctx.dbMigrationsMarker) === ctx.migrationsFingerprint
503
+
504
+ const applied = readAppliedMigrationModules(ctx)
505
+ if (applied) {
506
+ if (applied.size === 0 && initialized) {
507
+ // Markers say converged but the database has no migration bookkeeping
508
+ // at all: the postgres volume was recreated underneath us.
509
+ ctx.dbReinitialize = true
510
+ return { ok: false, detail: 'database is empty (volume recreated?) — migrate + initialize' }
511
+ }
512
+ // Modules that stayed without bookkeeping after the last successful
513
+ // apply are disabled modules — expected to be missing, not pending.
514
+ const knownAbsent = new Set((readState(ctx.repoRoot, `${ctx.dbMigrationsMarker}-absent`) ?? '').split(',').filter(Boolean))
515
+ const missing = [...listMigrationModules(ctx.repoRoot)].filter(
516
+ (moduleName) => !applied.has(moduleName) && !knownAbsent.has(moduleName),
517
+ )
518
+ if (missing.length > 0) {
519
+ return { ok: false, detail: `no migration bookkeeping for: ${missing.join(', ')} — applying pending migrations` }
520
+ }
521
+ }
522
+
523
+ if (initialized && migrated) {
524
+ return { ok: true, detail: 'database initialized, no new migration files' }
525
+ }
526
+ return { ok: false, detail: initialized ? 'new migration files — applying pending migrations' : 'first run — migrate + initialize' }
527
+ },
528
+ async apply(ctx) {
529
+ runYarn(ctx, ['db:migrate'])
530
+ writeState(ctx.repoRoot, ctx.dbMigrationsMarker, ctx.migrationsFingerprint)
531
+ const applied = readAppliedMigrationModules(ctx)
532
+ if (applied && applied.size > 0) {
533
+ const stillAbsent = [...listMigrationModules(ctx.repoRoot)].filter((moduleName) => !applied.has(moduleName))
534
+ writeState(ctx.repoRoot, `${ctx.dbMigrationsMarker}-absent`, stillAbsent.join(','))
535
+ }
536
+ if (readState(ctx.repoRoot, ctx.dbMarker) === null || ctx.dbReinitialize) {
537
+ runYarn(ctx, ['initialize'])
538
+ writeState(ctx.repoRoot, ctx.dbMarker, new Date().toISOString())
539
+ }
540
+ },
541
+ }
542
+
543
+ export function buildUpSteps(ctx) {
544
+ const base = [
545
+ prerequisitesStep,
546
+ containerRuntimeStep,
547
+ buildToolchainStep,
548
+ corporateCertsStep,
549
+ envFilesStep,
550
+ workspaceInstallStep,
551
+ workspaceBuildStep,
552
+ llmProviderStep,
553
+ infraUpStep,
554
+ databaseStep,
555
+ ]
556
+ const disabled = new Set(ctx.company.steps.disable)
557
+ return [...base.filter((step) => !disabled.has(step.id)), ...ctx.company.steps.extra]
558
+ }
559
+
560
+ export async function createStepContext(repoRoot, { mode = 'hybrid', flags = {}, log = console.log } = {}) {
561
+ const company = await loadCompanyConfig(repoRoot)
562
+ const existingBundle = path.join(repoRoot, CAPTURED_CA_BUNDLE)
563
+ // --use-system-ca / proxy env fixes apply even before any capture ran; the
564
+ // bundle is added when it already exists from a previous run.
565
+ const env = hostTrustEnv(fs.existsSync(existingBundle) ? existingBundle : null)
566
+ return {
567
+ repoRoot,
568
+ company,
569
+ mode,
570
+ env,
571
+ log,
572
+ flags: {
573
+ nonInteractive: false,
574
+ skipLlmPrompt: false,
575
+ skipDb: false,
576
+ noInfra: false,
577
+ rebuild: false,
578
+ clean: false,
579
+ profiles: [],
580
+ ...flags,
581
+ },
582
+ }
583
+ }
584
+
585
+ export async function runSteps(steps, ctx) {
586
+ const active = steps.filter((step) => !step.appliesTo || step.appliesTo(ctx))
587
+ let index = 0
588
+ for (const step of active) {
589
+ index += 1
590
+ stepHeader(index, active.length, step.title, step.expectation, { log: ctx.log })
591
+ const startedAt = Date.now()
592
+ try {
593
+ const checked = step.check ? await step.check(ctx) : { ok: false, detail: '' }
594
+ if (checked.ok) {
595
+ statusLine('ok', `${checked.detail || 'already satisfied'} ${color.dim('(skipped — already done)')}`, { log: ctx.log })
596
+ continue
597
+ }
598
+ if (checked.detail) statusLine('info', checked.detail, { log: ctx.log })
599
+ if (!step.apply) continue
600
+ await step.apply(ctx)
601
+ statusLine('ok', `done ${color.dim(`in ${formatDuration(Date.now() - startedAt)}`)}`, { log: ctx.log })
602
+ } catch (error) {
603
+ if (error instanceof StepBlocked) {
604
+ statusLine('fail', 'blocked — this starter proposes, it does not install system components', { log: ctx.log })
605
+ guideBox('What to do next', error.guide, { log: ctx.log })
606
+ return { ok: false, blockedStep: step.id }
607
+ }
608
+ statusLine('fail', error instanceof Error ? error.message : String(error), { log: ctx.log })
609
+ return { ok: false, failedStep: step.id }
610
+ }
611
+ }
612
+ return { ok: true }
613
+ }