@open-mercato/starter 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/bin/om-start.mjs +78 -0
- package/package.json +27 -0
- package/platform/start.cmd +24 -0
- package/platform/start.ps1 +174 -0
- package/platform/start.sh +112 -0
- package/src/__tests__/starter.test.mjs +359 -0
- package/src/certs.mjs +266 -0
- package/src/cli.mjs +355 -0
- package/src/company.mjs +80 -0
- package/src/compose.mjs +144 -0
- package/src/constants.mjs +85 -0
- package/src/doctor.mjs +426 -0
- package/src/env-file.mjs +75 -0
- package/src/env-setup.mjs +119 -0
- package/src/infra.mjs +85 -0
- package/src/providers.mjs +189 -0
- package/src/secrets.mjs +11 -0
- package/src/spawn.mjs +89 -0
- package/src/steps.mjs +613 -0
- package/src/supervise.mjs +220 -0
- package/src/ui.mjs +122 -0
- package/src/waits.mjs +102 -0
package/src/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
|
+
}
|