Skip to content

Commit 209e6ec

Browse files
fix(setup): make doctor understand the compose env layout
Compose writes a single root .env (what docker-compose reads via env_file) but the checks required the three per-app files, so a successful compose install was followed by doctor printing three failures and exiting 1 — and the whole coherence catalog was skipped because it keyed off apps/sim/.env existing. Layout is now derived from what's on disk and every check consults it: file and schema checks iterate the layout's targets, consistency reports skip when there's only one file to mirror, and coherence/live read the layout's primary file. The wizard's existing-config detection counts root for the same reason — a compose install used to read as unconfigured and re-run from scratch.
1 parent d362e68 commit 209e6ec

2 files changed

Lines changed: 59 additions & 16 deletions

File tree

scripts/setup/checks.ts

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,43 @@ export interface Finding {
2626
autofix?: () => void
2727
}
2828

29+
/**
30+
* Which env-file topology this install uses. Compose mode writes a single root
31+
* `.env` (that's what `docker-compose.*.yml` reads via `env_file`), dev mode
32+
* writes the three per-app files. Checking for the wrong one reports a healthy
33+
* install as broken, so the layout is derived and every check consults it.
34+
*/
35+
export type EnvLayout = 'split' | 'root' | 'none'
36+
2937
export interface CheckContext {
3038
env: Record<EnvTarget, EnvFile>
39+
layout: EnvLayout
40+
/** The file holding app configuration for this layout — what coherence reads. */
41+
primary: EnvFile
3142
live: boolean
3243
}
3344

45+
/** Split wins when both exist: the per-app files are what a dev run actually loads. */
46+
function detectLayout(env: Record<EnvTarget, EnvFile>): EnvLayout {
47+
if (env.sim.exists || env.realtime.exists || env.db.exists) return 'split'
48+
return env.root.exists ? 'root' : 'none'
49+
}
50+
51+
/** Targets whose files this layout expects to exist. */
52+
function layoutTargets(layout: EnvLayout): EnvTarget[] {
53+
if (layout === 'split') return ['sim', 'realtime', 'db']
54+
return layout === 'root' ? ['root'] : []
55+
}
56+
3457
export function loadCheckContext(live: boolean): CheckContext {
35-
return {
36-
env: {
37-
sim: readEnvFile('sim'),
38-
realtime: readEnvFile('realtime'),
39-
db: readEnvFile('db'),
40-
root: readEnvFile('root'),
41-
},
42-
live,
58+
const env = {
59+
sim: readEnvFile('sim'),
60+
realtime: readEnvFile('realtime'),
61+
db: readEnvFile('db'),
62+
root: readEnvFile('root'),
4363
}
64+
const layout = detectLayout(env)
65+
return { env, layout, primary: layout === 'root' ? env.root : env.sim, live }
4466
}
4567

4668
const REQUIRED_KEYS: Partial<Record<EnvTarget, string[]>> = {
@@ -70,8 +92,18 @@ function rel(file: EnvFile): string {
7092
}
7193

7294
function checkFiles(ctx: CheckContext): Finding[] {
95+
if (ctx.layout === 'none') {
96+
return [
97+
{
98+
group: 'files',
99+
status: 'fail',
100+
message: 'no env files found',
101+
fix: 'run: bun run setup',
102+
},
103+
]
104+
}
73105
const findings: Finding[] = []
74-
for (const target of ['sim', 'realtime', 'db'] as const) {
106+
for (const target of layoutTargets(ctx.layout)) {
75107
const file = ctx.env[target]
76108
if (file.exists) {
77109
findings.push({ group: 'files', status: 'pass', message: `${rel(file)} exists` })
@@ -124,11 +156,13 @@ function autofixForMissing(
124156
function checkSchema(ctx: CheckContext): Finding[] {
125157
const findings: Finding[] = []
126158
const production = process.env.NODE_ENV === 'production'
127-
for (const target of ['sim', 'realtime', 'db'] as const) {
159+
for (const target of layoutTargets(ctx.layout)) {
128160
const file = ctx.env[target]
129161
if (!file.exists) continue
130162
const missing: string[] = []
131-
for (const key of REQUIRED_KEYS[target] ?? []) {
163+
// The single root file feeds both containers, so it must satisfy the app's
164+
// requirements — a superset of realtime's.
165+
for (const key of REQUIRED_KEYS[target === 'root' ? 'sim' : target] ?? []) {
132166
const value = file.vars.get(key)
133167
if (!value) {
134168
missing.push(key)
@@ -189,6 +223,13 @@ function checkSchema(ctx: CheckContext): Finding[] {
189223
}
190224

191225
function checkConsistency(ctx: CheckContext): Finding[] {
226+
// Consistency is about the same key agreeing across files; a single root
227+
// file has nothing to disagree with.
228+
if (ctx.layout !== 'split') {
229+
return ctx.layout === 'root'
230+
? [{ group: 'consistency', status: 'skip', message: 'single .env — nothing to mirror' }]
231+
: []
232+
}
192233
const findings: Finding[] = []
193234
const { sim, realtime, db } = ctx.env
194235
if (sim.exists && realtime.exists) {
@@ -233,7 +274,7 @@ function checkConsistency(ctx: CheckContext): Finding[] {
233274

234275
function checkCoherence(ctx: CheckContext): Finding[] {
235276
const findings: Finding[] = []
236-
const sim = ctx.env.sim
277+
const sim = ctx.primary
237278
if (!sim.exists) return findings
238279
if (isTruthy(sim.vars.get('TRIGGER_DEV_ENABLED'))) {
239280
const missing = ['TRIGGER_SECRET_KEY', 'TRIGGER_PROJECT_ID'].filter((k) => !sim.vars.get(k))
@@ -556,7 +597,7 @@ async function checkOllama(sim: EnvFile): Promise<Finding[]> {
556597
* report stays deterministic regardless of which probe settles first.
557598
*/
558599
async function checkLive(ctx: CheckContext): Promise<Finding[]> {
559-
const sim = ctx.env.sim
600+
const sim = ctx.primary
560601
const [database, redis, app, realtime, ollama] = await Promise.all([
561602
checkDatabase(sim),
562603
checkRedis(sim),

scripts/setup/wizard.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ export interface WizardFlags {
1717
}
1818

1919
async function handleExistingConfig(detection: Detection): Promise<'continue' | 'doctor'> {
20+
// Root counts: a compose install writes only `.env`, so excluding it made a
21+
// configured machine look unconfigured and silently re-run from scratch.
2022
const present = Object.entries(detection.envFiles)
21-
.filter(([target, exists]) => exists && target !== 'root')
22-
.map(([target]) => target)
23+
.filter(([, exists]) => exists)
24+
.map(([target]) => (target === 'root' ? '.env' : `${target}/.env`))
2325
if (present.length === 0) return 'continue'
2426
const choice = await p.select({
25-
message: `Found existing config (${present.map((t) => `${t}/.env`).join(', ')}) — what should we do?`,
27+
message: `Found existing config (${present.join(', ')}) — what should we do?`,
2628
options: [
2729
{ value: 'keep', label: 'Keep it', hint: 'run doctor against the current setup and exit' },
2830
{

0 commit comments

Comments
 (0)