diff --git a/.changeset/doctor-config-env-overlay.md b/.changeset/doctor-config-env-overlay.md new file mode 100644 index 0000000000..4225e368a6 --- /dev/null +++ b/.changeset/doctor-config-env-overlay.md @@ -0,0 +1,66 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os doctor` 按 `os serve` 的环境载入 `objectstack.config.ts`(#5397) + +#5387 让 doctor **读**到了 `os serve` 的那份 `.env*` cascade,但 overlay 只套在一处读取上: +env 派生检查(posture,以及以它为闸门的 ADR-0120 D5e 建议)。`loadConfig()` 留在了外面。 +于是两条命令仍然在**两份不同的环境**下打包同一个配置文件: + +``` +# .env +OS_DATABASE_URL=postgres://… + +# objectstack.config.ts —— 顶层读环境变量是常见写法,不是刁钻写法 +const url = process.env.OS_DATABASE_URL; +if (!url) throw new Error('OS_DATABASE_URL is required'); + +$ os serve # dotenvFlow.config()(serve.ts:520)→ 之后才 bundleRequire → 正常启动 +$ os doctor # 无 overlay 直接 bundleRequire → 抛错 → 被 config 分析那个很宽的 try 吞掉 + # → 「⚠ Could not load config for analysis (config checks skipped)」,warning,exit 0 +``` + +两种危害,安静的那种更糟: + +1. **响的** —— 上面那句话把责任推给配置文件,而配置文件没问题,同一个目录 `os serve` + 正常启动。它正是 #5382 判定为「归因错误」的那句,残存在 #5387 刻意没动的这条路径上。 +2. **哑的** —— 配置文件只要**按环境值分支**(条件声明的 object / datasource),它对 doctor + 和对服务器就声明了不同的形状,于是下面每一项检查(循环依赖、未引用对象、孤儿视图、 + 仪表盘完整性、spec 版本)判定的都是一份**服务器不会运行**的配置。全程不打印任何东西。 + 这一半没有任何 warning 会浮现出来。 + +**现在的行为。** `loadConfig()` 套上 `run()` 顶部已经解析好的**同一份** `dotenvReading` +(不是第二次 `readDotenvFiles()` —— 一轮 doctor 只解析一次 cascade,否则 `Environment files` +那一行就未必是 config 载入真正看到的那份)。 + +`withDotenvOverlayAsync` 而不是既有的同步版:配置文件的顶层跑在 `bundleRequire` 的动态 +`import()` 里面,同步版的 `finally` 会在 `loadConfig()` 交回 pending promise 的那一刻就把 +overlay 摘掉 —— 套上了,又在被读之前摘掉,等于没套。两者共用同一套 apply/revert +(dotenv-flow 自己 `unload()` 的判定:只删掉仍然等于写入值的那些),不是抄一份。 + +**判定面的变化是本单的修复内容,不是副作用**(与 #5398 对 D5e 建议的处理同一姿态)。 +可观察的差异有三类,都如实呈现: + +- 此前因缺值抛错而被跳过的配置,现在**载入成功**,那句归因错误的 warning 不再出现,而 + 下面那一整组 config 检查**开始运行** —— 因此可能新增此前从未打印过的 warning + (真机复现:`⚠ Object "account" is defined but not referenced by any view, flow, app, or lookup field`, + 在修复前整块被跳过,一条都看不到); +- 按环境值分支的配置,doctor 判定的对象/视图集合改为与 `os serve` 一致; +- 配置**确实**坏掉时,`Could not load config for analysis (config checks skipped)` + **照旧触发**。套上 cascade 之后仍然载入不了的配置,`os serve` 同样载入不了 —— 这句话 + 从此归因正确,而不是被消音。把它一并静默,等于用「没有 warning」换掉「归因错误的 + warning」。 + +**来源口径不变:只报来源,从不报值。** 这一点在本次改动后更吃重:overlay 现在携带的是 +配置文件想读的**任意**变量,而不再是 `DOCTOR_ENV_INPUTS` 这个声明过的子集,而 `.env` 正是 +密钥的常见住处。变量名无法预先枚举,提供它们的**文件**可以 —— `Environment files` 仍是 +报告 cascade 的唯一一处,并在其中说明这些文件同样施加于配置载入: + +``` + These files are also applied while objectstack.config.ts is loaded, so a config + that reads process.env at top level sees the values `os serve` gives it. +``` + +overlay 的边界也照旧:它在配置文件**载入**期间有效,而不是常驻整轮运行 —— doctor 分析的 +一切都是模块求值时读出的普通值,载入结束即摘除(回调抛出时同样摘除,有测试钉住)。 diff --git a/packages/cli/src/commands/doctor-config-env-overlay.test.ts b/packages/cli/src/commands/doctor-config-env-overlay.test.ts new file mode 100644 index 0000000000..36febe192b --- /dev/null +++ b/packages/cli/src/commands/doctor-config-env-overlay.test.ts @@ -0,0 +1,352 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os doctor` loads `objectstack.config.ts` under the `.env*` cascade `os serve` + * loads it under (#5397). + * + * ── The defect ─────────────────────────────────────────────────────────── + * + * #5387 taught doctor to READ serve's `.env*` cascade, but applied the overlay + * around one reader only: the env-derived checks (the tenancy posture, and the + * ADR-0120 D5e advisory gated on it). `loadConfig()` was left outside it. So the + * two commands still bundled the user's config file under two different + * environments: + * + * # .env + * OS_DATABASE_URL=postgres://… + * + * # objectstack.config.ts — reading env at top level is the ordinary shape + * const url = process.env.OS_DATABASE_URL; + * if (!url) throw new Error('OS_DATABASE_URL is required'); + * + * $ os serve # dotenvFlow.config() (serve.ts:520) THEN bundleRequire → boots + * $ os doctor # bundleRequire with no overlay → throws → caught by the + * # wide config-analysis `try` → "⚠ Could not load config for + * # analysis (config checks skipped)", warning, exit 0 + * + * Two distinct harms, and the quiet one is the worse one: + * + * 1. LOUD — the sentence above blames the config file, which is fine, on a + * run where `os serve` boots the identical directory. It is the same + * misattribution #5382 was opened over, surviving in the one code path + * #5387 deliberately left alone. + * 2. QUIET — a config that merely *branches* on an environment value declares + * a different shape for doctor than for the server, so every check below + * (circular deps, unused objects, orphan views, dashboard integrity, spec + * version) judged a config the server never runs. Nothing is printed. This + * is the half no warning would ever have surfaced. + * + * ── What is pinned here ────────────────────────────────────────────────── + * + * • the config load sees the cascade (both harms, differentially); + * • a genuinely broken config STILL warns — the fix must not be a silencer; + * • the overlay is bounded: applied, awaited ACROSS the async load, reverted, + * including when the load throws (#5387's revert-on-throw pin, one call + * shape along); + * • the report still names sources and never values, with the config load's + * use of the cascade stated in the one place that reports it. + * + * The synchronous `withDotenvOverlay` half stays in + * `doctor-env-provenance.test.ts` (#5387), whose machinery this extends. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import Doctor, { + readDotenvFiles, + withDotenvOverlay, + withDotenvOverlayAsync, + environmentSourcesCheck, +} from './doctor.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +/** `packages/cli` — the oclif root the real command is loaded against below. */ +const CLI_ROOT = path.resolve(HERE, '..', '..'); + +/** + * The escape is written as `\x1b`, never as the byte itself: one raw control + * character makes `grep` treat the whole file as binary, and a test file no + * `git grep` can find stops being maintained (#4890 / #5157). + */ +const SGR = /\x1b\[[0-9;]*m/g; +const plain = (s: string) => s.replace(SGR, ''); + +/** Every variable these cases touch, restored between them. */ +const TOUCHED = ['OS_5397_PROBE', 'OS_5397_DB_URL', 'OS_5397_FEATURE'] as const; +let saved: Record = {}; + +beforeEach(() => { + saved = Object.fromEntries(TOUCHED.map((k) => [k, process.env[k]])); + for (const k of TOUCHED) delete process.env[k]; +}); + +afterEach(() => { + for (const k of TOUCHED) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe('withDotenvOverlayAsync — the overlay survives the await, then leaves', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-doctor-5397-unit-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const write = (name: string, body: string) => fs.writeFileSync(path.join(dir, name), body); + + it('still exposes the file value AFTER an await — the reason this variant exists', async () => { + write('.env', 'OS_5397_PROBE=from-file\n'); + const reading = readDotenvFiles(dir, 'production'); + + const seen = await withDotenvOverlayAsync(reading, async () => { + // The read a config file performs happens inside `bundleRequire`'s dynamic + // `import()`, i.e. at least one microtask after `loadConfig()` was called. + // This `await` stands in for that gap. + await Promise.resolve(); + return process.env.OS_5397_PROBE; + }); + + expect(seen).toBe('from-file'); + expect(Object.prototype.hasOwnProperty.call(process.env, 'OS_5397_PROBE')).toBe(false); + }); + + it('the SYNCHRONOUS wrapper does not — pinning why the call site is not "simplified" back', async () => { + write('.env', 'OS_5397_PROBE=from-file\n'); + const reading = readDotenvFiles(dir, 'production'); + + let seenAfterAwait: string | undefined = 'sentinel'; + const pending = withDotenvOverlay(reading, async () => { + await Promise.resolve(); + seenAfterAwait = process.env.OS_5397_PROBE; + }); + + // `withDotenvOverlay` returned the instant its callback handed back a + // pending promise, and its `finally` has already run — so by the time the + // callback resumes, the overlay it was given is gone. Swapping the async + // wrapper for the sync one at the config call site therefore does not fail + // loudly; it silently applies an overlay nothing ever reads, which is the + // bug this change fixes, restored. That is worth a test rather than a + // comment. + await pending; + expect(seenAfterAwait).toBeUndefined(); + }); + + it('never overwrites a value the shell already set', async () => { + write('.env', 'OS_5397_PROBE=from-file\n'); + process.env.OS_5397_PROBE = 'from-shell'; + const reading = readDotenvFiles(dir, 'production'); + + // dotenv-flow's precedence, unchanged: `os serve` would not overwrite it + // either, so the config file really does see the shell's value there. + const seen = await withDotenvOverlayAsync(reading, async () => process.env.OS_5397_PROBE); + expect(seen).toBe('from-shell'); + expect(process.env.OS_5397_PROBE).toBe('from-shell'); + }); + + it('reverts when the awaited callback REJECTS — the config-throws path is the normal one', async () => { + write('.env', 'OS_5397_PROBE=from-file\n'); + const reading = readDotenvFiles(dir, 'production'); + + // A config that throws on a missing value is precisely the case #5397 was + // opened about, so the rejecting path is the one this feature exists for, + // not an edge. A `finally` that only ran on success would leak the overlay + // into the rest of the run on exactly the runs that report a problem — + // #5387 pinned the same thing for the synchronous wrapper. + await expect( + withDotenvOverlayAsync(reading, async () => { + await Promise.resolve(); + throw new Error('config boom'); + }), + ).rejects.toThrow('config boom'); + + expect(Object.prototype.hasOwnProperty.call(process.env, 'OS_5397_PROBE')).toBe(false); + }); + + it('leaves a variable the callback deliberately changed alone', async () => { + write('.env', 'OS_5397_PROBE=from-file\n'); + const reading = readDotenvFiles(dir, 'production'); + + await withDotenvOverlayAsync(reading, async () => { + process.env.OS_5397_PROBE = 'rewritten'; + }); + // dotenv-flow's own `unload()` test, shared with the synchronous wrapper + // rather than restated: delete only what still holds the value written. + expect(process.env.OS_5397_PROBE).toBe('rewritten'); + }); +}); + +describe('environmentSourcesCheck — the config load is reported, not silent', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-doctor-5397-report-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('says the same files are applied while the config is loaded', () => { + fs.writeFileSync(path.join(dir, '.env'), 'OS_5397_DB_URL=postgres://secret-host/db\n'); + const check = environmentSourcesCheck(readDotenvFiles(dir, 'production'), {} as NodeJS.ProcessEnv); + const text = plain(`${check.message}\n${check.fix ?? ''}`); + + // A config file reads whatever variables its author chose, so the KEYS + // cannot be enumerated the way `DOCTOR_ENV_INPUTS` are. The files can, and + // saying which files reach the config load is what keeps a wider overlay a + // reported fact instead of the silent merge #5387 refused. + expect(text).toContain('objectstack.config.ts'); + expect(text).toContain('.env'); + }); + + it('still reports SOURCES and never VALUES, now that the whole cascade reaches a reader', () => { + fs.writeFileSync(path.join(dir, '.env'), 'OS_5397_DB_URL=postgres://secret-host/db\n'); + const check = environmentSourcesCheck(readDotenvFiles(dir, 'production'), {} as NodeJS.ProcessEnv); + const text = plain(`${check.message}\n${check.fix ?? ''}`); + + // The provenance discipline is load-bearing in a way it was not before: the + // overlay now carries variables doctor never declared, and a `.env` is the + // usual home for credentials. Doctor prints neither their names nor their + // values — only the files. + expect(text).not.toContain('postgres://secret-host/db'); + expect(text).not.toContain('secret-host'); + expect(text).not.toContain('OS_5397_DB_URL'); + }); +}); + +describe('os doctor, end to end, against a config that reads .env at top level', () => { + /** + * `node_modules/` exists in the temp cwd on purpose — without it doctor's + * `Dependencies` check is itself an `error` and exits 1 on its own, which + * would make an assertion pass for a reason having nothing to do with this + * change (the trap PR #5390 wrote down and #5398 inherited). + */ + let tmp: string; + let cwdSpy: ReturnType; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'os-doctor-5397-e2e-')); + fs.mkdirSync(path.join(tmp, 'node_modules')); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmp); + }); + + afterEach(() => { + cwdSpy.mockRestore(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + const writeFile = (name: string, body: string) => fs.writeFileSync(path.join(tmp, name), body); + + async function runDoctor(): Promise<{ out: string; exitCode: number | undefined }> { + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { + logs.push(a.join(' ')); + }); + let exitCode: number | undefined; + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCode = code; + throw new Error(`__PROCESS_EXIT__:${code}`); + }) as never); + + try { + await Doctor.run([], { root: CLI_ROOT }); + } catch (err) { + if (!(err instanceof Error) || !err.message.startsWith('__PROCESS_EXIT__')) throw err; + } finally { + logSpy.mockRestore(); + exitSpy.mockRestore(); + } + return { out: plain(logs.join('\n')), exitCode }; + } + + it('loads a config that THROWS without its .env value — the misattributed warning is gone', async () => { + writeFile('.env', 'OS_5397_DB_URL=postgres://from-dotenv/db\n'); + writeFile( + 'objectstack.config.ts', + [ + "const url = process.env.OS_5397_DB_URL;", + "if (!url) throw new Error('OS_5397_DB_URL is required');", + 'export default {', + " manifest: { name: 'os5397', label: 'Env Overlay', version: '1.0.0' },", + " objects: [{ name: 'account', label: 'Account', fields: [{ name: 'name', type: 'text', label: 'Name' }] }],", + '};', + '', + ].join('\n'), + ); + + const run = await runDoctor(); + + // Before this change: the bundle threw, the wide `try` swallowed it, and + // doctor printed a warning about the config — while `os serve`, which loads + // `.env` before bundling, boots this exact directory. + expect(run.out).not.toContain('Could not load config for analysis'); + // The config checks did not merely stop failing — they RAN. + expect(run.out).toContain('Platform spec'); + expect(run.out).toContain('No circular references detected'); + expect(run.exitCode).toBeUndefined(); + + // The overlay must not outlive the load: the next command in the same + // process would otherwise inherit a value from a file it never read. + expect(Object.prototype.hasOwnProperty.call(process.env, 'OS_5397_DB_URL')).toBe(false); + }, 60_000); + + it('sees the SHAPE serve sees when the config branches on an env value', async () => { + // The quiet harm, and the one no warning would ever have surfaced: the + // config loads fine either way, it just declares something different. + writeFile( + 'objectstack.config.ts', + [ + "const beta = process.env.OS_5397_FEATURE === 'on';", + 'export default {', + " manifest: { name: 'os5397b', label: 'Branching', version: '1.0.0' },", + ' objects: [', + " { name: 'account', label: 'Account', fields: [{ name: 'name', type: 'text', label: 'Name' }] },", + " ...(beta ? [{ name: 'beta_widget', label: 'Beta Widget', fields: [{ name: 'name', type: 'text', label: 'Name' }] }] : []),", + ' ],', + '};', + '', + ].join('\n'), + ); + + // ── Control: the feature is off, and nothing declares `beta_widget` ──── + writeFile('.env', 'OS_5397_FEATURE=off\n'); + const off = await runDoctor(); + expect(off.out).toContain('Object "account" is defined but not referenced'); + expect(off.out).not.toContain('beta_widget'); + + // ── The case: one word changed, inside the file doctor used to ignore ── + writeFile('.env', 'OS_5397_FEATURE=on\n'); + const on = await runDoctor(); + + // `os serve` declares `beta_widget` here. Before this change doctor's + // unused-object pass never saw it, so the diagnostic was reporting on a + // config the server does not run — silently, with no warning to hint that + // the two disagreed. + expect(on.out).toContain('Object "beta_widget" is defined but not referenced'); + + expect(Object.prototype.hasOwnProperty.call(process.env, 'OS_5397_FEATURE')).toBe(false); + }, 60_000); + + it('still warns when the config is genuinely broken — the fix is not a silencer', async () => { + writeFile('.env', 'OS_5397_DB_URL=postgres://from-dotenv/db\n'); + writeFile('objectstack.config.ts', "throw new Error('this config is genuinely broken');\n"); + + const run = await runDoctor(); + + // With the cascade applied, a config that STILL cannot be loaded is one + // `os serve` cannot load either — so the warning is now attributed + // correctly rather than removed. Over-silencing here would have traded a + // misattributed warning for no warning at all, which is the failure mode + // the narrow reading of #5397 invites. + expect(run.out).toContain('Could not load config for analysis (config checks skipped)'); + expect(run.out).toContain('Environment is functional but has some warnings'); + }, 60_000); +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index f326fc9ab5..f7b87ea687 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -32,7 +32,7 @@ interface HealthCheckResult { fix?: string; } -// ─── Environment sources (#5387) ──────────────────────────────────── +// ─── Environment sources (#5387, #5397) ───────────────────────────── // // `serve` / `dev` / `start` all load `.env*` through dotenv-flow before they // read a single `OS_*` variable (`serve.ts:520`, `dev.ts`, `start.ts`); doctor @@ -44,14 +44,24 @@ interface HealthCheckResult { // Doctor now reads the same cascade, with two deliberate constraints: // // • It does NOT merge the values into `process.env` for the run. The overlay -// is applied around the individual read that needs it and taken back off in -// a `finally` (`withDotenvOverlay`), so nothing downstream — the config -// bundle, a spawned tool — silently inherits a different environment than -// the one it inherited yesterday. +// is applied around each read that needs it and taken back off in a +// `finally` (`withDotenvOverlay` / `withDotenvOverlayAsync`), so nothing +// outside those windows — a spawned tool, whatever runs next in the same +// process — silently inherits a different environment than the one it +// inherited yesterday. // • Every env-derived value is REPORTED WITH ITS SOURCE (shell vs which // file). A silent merge would trade "doctor cannot see your `.env`" for // "doctor cannot tell you which of your four `.env*` files it believed", // which is the same class of defect one layer along. +// +// #5397 added the second such window: `loadConfig()`. It is listed here rather +// than left implicit because a user's `objectstack.config.ts` reads whatever +// variables it likes — not just `DOCTOR_ENV_INPUTS` — so the config load is the +// one place where the whole cascade, not a declared subset, reaches a reader. +// `environmentSourcesCheck` names the files for exactly that reason: the set of +// variables cannot be enumerated in advance, but the files that supplied them +// can, and an unreported overlay of an unbounded key set would be the silent +// merge in its worst form. /** * The environment variables doctor's own checks derive from. @@ -267,17 +277,80 @@ function dotenvOverlay( * and removed in a `finally` — using dotenv-flow's own `unload()` test, which * deletes a variable only when it still holds the value that was written, so a * value `fn` deliberately changed is left alone. + * + * For an asynchronous reader — the config bundle, whose top level runs inside a + * dynamic `import()` — use {@link withDotenvOverlayAsync}. This one takes the + * overlay back off the moment `fn` RETURNS, which for an async `fn` is the + * moment it hands back a pending promise, i.e. before it has read anything. */ export function withDotenvOverlay(reading: DotenvReading, fn: () => T): T { const overlay = dotenvOverlay(reading); - const names = Object.keys(overlay); - for (const name of names) process.env[name] = overlay[name]; + const applied = applyDotenvOverlay(overlay); try { return fn(); } finally { - for (const name of names) { - if (process.env[name] === overlay[name]) delete process.env[name]; - } + revertDotenvOverlay(overlay, applied); + } +} + +/** + * {@link withDotenvOverlay} for a reader that finishes asynchronously (#5397). + * + * Doctor's other consumer of the cascade is `loadConfig()`, and a user's + * `objectstack.config.ts` typically reads its environment at MODULE TOP LEVEL — + * a datasource URL, a feature switch, sometimes a `throw` when the value is + * missing. That top level runs inside `bundleRequire`'s dynamic `import()`, so + * it happens one or more microtasks after `loadConfig()` was called. The + * synchronous wrapper's `finally` fires when the call returns its pending + * promise, which is strictly BEFORE the config file has been bundled, let alone + * evaluated: it would apply an overlay nothing ever reads and revert it before + * the read. Hence a real `await` inside the `try` rather than a second call + * shape that merely looks equivalent. + * + * Everything else is deliberately identical to the synchronous wrapper — same + * overlay set, same dotenv-flow `unload()` revert test, same revert-on-throw — + * because both share {@link applyDotenvOverlay} / {@link revertDotenvOverlay} + * rather than restating the policy. Two hand-written copies of "which variables + * doctor is allowed to touch, and when it puts them back" is exactly the drift + * #5387 spent its length arguing against. + * + * The window is still bounded, and its bound is worth naming: the overlay is + * live for the config file's LOAD, not for the checks that run on the loaded + * object afterwards. Everything doctor analyses is a plain value read out of + * the module at evaluation time, so this covers the real reads; a config that + * deferred an environment read to a lazy getter invoked later would fall + * outside it. That is the same restraint as the synchronous case — doctor never + * leaves a merged environment lying around for whatever runs next — not an + * oversight. + */ +export async function withDotenvOverlayAsync( + reading: DotenvReading, + fn: () => Promise, +): Promise { + const overlay = dotenvOverlay(reading); + const applied = applyDotenvOverlay(overlay); + try { + return await fn(); + } finally { + revertDotenvOverlay(overlay, applied); + } +} + +/** Write the overlay into `process.env`. Returns the names actually written. */ +function applyDotenvOverlay(overlay: Record): string[] { + const names = Object.keys(overlay); + for (const name of names) process.env[name] = overlay[name]; + return names; +} + +/** + * Take the overlay back off, using dotenv-flow's own `unload()` test: delete a + * variable only while it still holds the value that was written, so a value the + * callback deliberately changed survives. + */ +function revertDotenvOverlay(overlay: Record, applied: string[]): void { + for (const name of applied) { + if (process.env[name] === overlay[name]) delete process.env[name]; } } @@ -290,6 +363,13 @@ export function withDotenvOverlay(reading: DotenvReading, fn: () => T): T { * report that consults four files without naming them has only moved the * blind spot from "doctor never read my `.env`" to "which `.env` did doctor * believe?". + * + * #5397 widened the cascade's reach from doctor's own declared inputs to the + * config file's load, and this line remains the ONE place that says so. The + * variables a user's `objectstack.config.ts` reads are the user's, not + * `DOCTOR_ENV_INPUTS`, so they are not enumerated here — but the files that + * supplied them are, which is what makes the wider overlay a reported fact + * rather than the silent merge #5387 refused. */ export function environmentSourcesCheck( reading: DotenvReading, @@ -313,7 +393,9 @@ export function environmentSourcesCheck( .join('\n') + '\n A variable set in this process wins over every `.env*` file, and a later file in\n' + ' the cascade wins over an earlier one — the same precedence `os serve` resolves.\n' - + ' Sources only: doctor reports where a value came from, never what it is.'; + + ' Sources only: doctor reports where a value came from, never what it is.\n' + + ' These files are also applied while objectstack.config.ts is loaded, so a config\n' + + ' that reads process.env at top level sees the values `os serve` gives it.'; if (reading.error) { return { @@ -1107,7 +1189,33 @@ export default class Doctor extends Command { if (configExists()) { printStep('Loading configuration for analysis...'); try { - const { config: rawConfig } = await loadConfig(); + // #5397 — load the config under the SAME `.env*` cascade resolved at the + // top of `run()`, because that is the environment `os serve` hands the + // config file. Reading `process.env` at a config's top level is ordinary + // (a datasource URL, a feature switch), and `serve` calls + // `dotenvFlow.config()` before it bundles the file (`serve.ts:520`), so + // without this the two commands were analysing two different configs: + // + // • quietly — a conditionally-declared object or datasource present + // for serve and absent for doctor, so every check below judged a + // shape the server never runs; + // • loudly — a config that throws on a missing value landed in this + // `catch` and printed `Could not load config for analysis`, blaming + // the config, while `os serve` booted the same directory. That + // sentence is exactly the misattribution #5382 was opened over, and + // #5387 closed only the half of it that doctor's own env-derived + // checks could see. + // + // `dotenvReading` — not a second `readDotenvFiles()`. One cascade + // resolution per run is what keeps the `Environment files` line an + // honest account of what the config load actually saw; two reads could + // disagree the moment a `.env` is written mid-run. + // + // Async wrapper, deliberately: a config's top level runs inside + // `bundleRequire`'s dynamic `import()`, so the synchronous + // `withDotenvOverlay` would revert the overlay while `loadConfig()`'s + // promise was still pending — applied, then removed, never read. + const { config: rawConfig } = await withDotenvOverlayAsync(dotenvReading, () => loadConfig()); const config: any = normalizeStackInput(rawConfig as Record); // Spec-version drift: installed platform newer than the app declares. @@ -1210,6 +1318,12 @@ export default class Doctor extends Command { } } } catch { + // #5397 — still fires, and deliberately so: with the `.env*` cascade now + // applied around the load, a config that STILL cannot be loaded is one + // `os serve` cannot load either. What changed is that this sentence is + // no longer reachable by a config whose only problem was that doctor + // withheld the environment from it. Silencing the warning outright would + // have traded a misattributed warning for no warning at all. printWarning('Could not load config for analysis (config checks skipped)'); hasWarnings = true; }