Skip to content

Commit ccba1bb

Browse files
baozhoutaoclaude
andauthored
fix(cli): os doctor 指名道姓报告非法 OS_TENANCY_POSTURE 并非零退出 (#5382) (#5390)
doctor 的两处 posture 读取都在 config 分析那个很宽的 try 里,抛错被它的 catch 转述成「Could not load config for analysis」并只记 warning —— 一个 os serve 会拒绝启动的环境,os doctor 报「Environment is functional」+ exit 0, 全程不出现 OS_TENANCY_POSTURE 这个词。归因错了,严重级也错了。 现在在 run() 顶部、任何 try 之外解析一次,非法值产出一条普通的 error 体检项 (修法清单由 TENANCY_POSTURES 生成,cause 直接引用解析器原话),由 doctor 自己的错误汇总给出非零退出码 —— 不 process.exit,doctor 的语义是「报告」。 两处读取复用同一个已解析值。 顺带:那两处此前都在 if (configExists()) 之内,没有配置文件的环境从来没读过 posture;现在与是否存在配置文件无关。 残留(文案里如实写明,另记为 #5387):doctor 不加载 .env*,写在 .env 里的 非法 posture 仍到不了这份报告。 Claude-Session: https://claude.ai/code/session_01VkPSGsX9o17MsGv3Lbxu2w Co-authored-by: Claude <noreply@anthropic.com>
1 parent ef7845a commit ccba1bb

3 files changed

Lines changed: 493 additions & 7 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os doctor` 指名道姓报告非法 `OS_TENANCY_POSTURE` 并以非零码退出,不再报成一句「Could not load config」(#5382)
6+
7+
`resolveTenancyPosture()`(`@objectstack/types`)对无法识别的值抛错。doctor 有两处读
8+
posture —— ADR-0120 D5e 的 unique-scope 闸门,以及 `findUnscopedGlobalUniques()` ——
9+
**两处都在 config 分析那个很宽的 `try`**,而它的 catch 只会打印
10+
11+
```
12+
⚠ Could not load config for analysis (config checks skipped)
13+
```
14+
15+
并记一个 warning。于是一个 `os serve`**拒绝启动**的环境,`os doctor` 报成:
16+
17+
```
18+
⚠️ Environment is functional but has some warnings. EXIT=0
19+
```
20+
21+
全程不出现 `OS_TENANCY_POSTURE` 这个词。一行里两个缺陷:**归因错了**(配置本身没问题,
22+
被指着的是配置),**严重级也错了**(exit 0 意味着任何把 `os doctor` 放进 CI/健康检查的
23+
地方,都不会因为这个「环境根本起不来」的配置错误变红)。这正是 #4801 / cloud#1020 那类
24+
「诊断面与运行时不一致」,而且落在最糟的位置 —— `os doctor` 就是运维在 `serve` 起不来
25+
之后会去跑的那条命令。
26+
27+
**现在的行为。** posture 在 `run()` 顶部、**任何 `try` 之外**解析一次。非法值产出一条
28+
普通的 `error` 体检项:
29+
30+
```
31+
✗ Tenancy posture OS_TENANCY_POSTURE="isolatd" is not a recognized tenancy posture — `os serve` refuses to boot this environment
32+
→ Set one of the accepted values:
33+
• OS_TENANCY_POSTURE=single — one organization, no organization wall — the default
34+
• OS_TENANCY_POSTURE=group — organization wall enforced by the open engine, one shared database
35+
• OS_TENANCY_POSTURE=isolated — organization wall + the enterprise @objectstack/organizations runtime …
36+
• or unset OS_TENANCY_POSTURE entirely — the posture then derives from
37+
OS_MULTI_ORG_ENABLED (true ⇒ isolated, anything else ⇒ single)
38+
Read from this process's environment only: unlike `os serve`, `os doctor` does not
39+
load `.env*` files, so a value set in one is not visible here.
40+
cause: Invalid OS_TENANCY_POSTURE="isolatd". …
41+
```
42+
43+
修法清单由 `@objectstack/spec/security``TENANCY_POSTURES` 生成,不是第二份字面量,
44+
新增一个 posture 不会让这段建议悄悄过期;`cause` 直接引用解析器自己的那句话,doctor 不
45+
维护会跟它跑偏的第二份措辞。
46+
47+
**#5359 / PR #5381`serve` 加的闸门同形,但裁决不同,且是刻意的**:serve**拒绝**
48+
(FATAL + 在任何启动动作之前 `process.exit(1)`),doctor 是**报告** —— 报告照常跑完,由
49+
doctor 自己的错误汇总给出非零退出码。doctor 的语义是「把所有问题一次说清」,不是「停下」。
50+
51+
两处读 posture 的地方现在复用同一个已解析值,不再各自重新解析。
52+
53+
**顺带修好的一个更大的洞:** 那两处读取此前都在 `if (configExists())` 之内 —— 一个没有
54+
`objectstack.config.ts` 的环境**从来没读过** posture,连那句归因错误的 warning 都不会有。
55+
现在与是否存在配置文件无关。
56+
57+
**一个如实说明的残留:** `os doctor` 不加载 `.env*`(`serve`/`dev`/`start` 用 dotenv-flow
58+
加载),所以写在提交进仓库的 `.env` 里的非法 posture 仍然到得了服务器、到不了这份报告。
59+
文案里明说了这一点,不冒充自己检查过 —— 该跨命令不一致另行记录为 #5387
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os doctor`'s tenancy-posture report (#5382).
5+
*
6+
* `resolveTenancyPosture()` in `@objectstack/types` refuses an unrecognized
7+
* `OS_TENANCY_POSTURE` by throwing. Doctor read the posture in two places — the
8+
* ADR-0120 D5e unique-scope gate and `findUnscopedGlobalUniques()` — and BOTH
9+
* sat inside the wide `try` that guards config analysis, whose `catch` prints
10+
*
11+
* ⚠ Could not load config for analysis (config checks skipped)
12+
*
13+
* and records a WARNING. So an environment `os serve` flatly refuses to boot
14+
* was reported by `os doctor` as:
15+
*
16+
* ⚠️ Environment is functional but has some warnings. EXIT=0
17+
*
18+
* with the string `OS_TENANCY_POSTURE` appearing nowhere in the run. Two
19+
* separate defects in one line: the attribution was wrong (the config was
20+
* fine), and the severity was wrong (exit 0 keeps every CI health check green
21+
* on an environment that cannot start).
22+
*
23+
* ── Sibling, not a copy ──────────────────────────────────────────────────
24+
*
25+
* #5359 / PR #5381 fixed the same shape in `serve`, and the two verdicts differ
26+
* on purpose: serve REFUSES (FATAL + `process.exit(1)` before any boot work),
27+
* doctor REPORTS (an `error` health check flowing through doctor's own error
28+
* summary, after the rest of the report has printed). Doctor's semantics are
29+
* "tell me everything that is wrong", not "stop".
30+
*
31+
* ── What `packages/cli` pinned before this file ──────────────────────────
32+
*
33+
* Nothing, for doctor. `git grep -n OS_TENANCY_POSTURE packages/cli/src` matched
34+
* serve's prose, `verify`'s back-compat tests, and PR #5381's serve gate test —
35+
* no assertion of any kind on `doctor`.
36+
*/
37+
38+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
39+
import fs from 'node:fs';
40+
import os from 'node:os';
41+
import path from 'node:path';
42+
import { fileURLToPath } from 'node:url';
43+
import { TENANCY_POSTURES } from '@objectstack/spec/security';
44+
45+
import Doctor, { resolveTenancyPostureOrFinding } from './doctor.js';
46+
47+
/** `packages/cli` — the oclif root the command is loaded against below. */
48+
const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
49+
50+
/**
51+
* `chalk` may or may not emit SGR codes depending on TTY detection.
52+
*
53+
* The escape is written as `\x1b`, never as the byte itself: one raw control
54+
* character makes grep treat the whole file as binary, and a test file nobody's
55+
* `git grep` can find is a test file that stops being maintained (#4890/#5157).
56+
*/
57+
const SGR = /\x1b\[[0-9;]*m/g;
58+
const plain = (s: string) => s.replace(SGR, '');
59+
60+
const TOUCHED = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED'] as const;
61+
let saved: Record<string, string | undefined> = {};
62+
63+
beforeEach(() => {
64+
saved = Object.fromEntries(TOUCHED.map((k) => [k, process.env[k]]));
65+
for (const k of TOUCHED) delete process.env[k];
66+
});
67+
68+
afterEach(() => {
69+
for (const k of TOUCHED) {
70+
if (saved[k] === undefined) delete process.env[k];
71+
else process.env[k] = saved[k];
72+
}
73+
});
74+
75+
describe('resolveTenancyPostureOrFinding — accepted values', () => {
76+
it('passes every posture the spec vocabulary declares', () => {
77+
for (const posture of TENANCY_POSTURES) {
78+
process.env.OS_TENANCY_POSTURE = posture;
79+
expect(resolveTenancyPostureOrFinding()).toEqual({ ok: true, posture });
80+
}
81+
});
82+
83+
it("keeps the legacy 'multi' spelling normalizing to isolated", () => {
84+
process.env.OS_TENANCY_POSTURE = 'multi';
85+
expect(resolveTenancyPostureOrFinding()).toEqual({ ok: true, posture: 'isolated' });
86+
});
87+
88+
it('unset falls back to the OS_MULTI_ORG_ENABLED derivation, not to a finding', () => {
89+
expect(resolveTenancyPostureOrFinding()).toEqual({ ok: true, posture: 'single' });
90+
91+
process.env.OS_MULTI_ORG_ENABLED = 'true';
92+
expect(resolveTenancyPostureOrFinding()).toEqual({ ok: true, posture: 'isolated' });
93+
});
94+
95+
it('treats a blank value as unset — reporting it would flag `OS_TENANCY_POSTURE=` in a .env', () => {
96+
process.env.OS_TENANCY_POSTURE = ' ';
97+
expect(resolveTenancyPostureOrFinding()).toEqual({ ok: true, posture: 'single' });
98+
});
99+
});
100+
101+
describe('resolveTenancyPostureOrFinding — the finding', () => {
102+
it('REPORTS AS A VALUE, never as a throw — the property the config catch destroyed', () => {
103+
process.env.OS_TENANCY_POSTURE = 'bogus';
104+
105+
// The point of the whole change. `resolveTenancyPosture()` throws here, and
106+
// doctor's posture reads lived inside the config-analysis `try`, so the
107+
// throw was caught by a `catch` that knows nothing about env vars and
108+
// downgraded "cannot start" to "config checks skipped". A verdict cannot be
109+
// caught by an unrelated catch.
110+
expect(() => resolveTenancyPostureOrFinding()).not.toThrow();
111+
112+
const reading = resolveTenancyPostureOrFinding();
113+
expect(reading.ok).toBe(false);
114+
});
115+
116+
it('is an ERROR health check — the severity that makes doctor exit non-zero', () => {
117+
process.env.OS_TENANCY_POSTURE = 'bogus';
118+
const reading = resolveTenancyPostureOrFinding();
119+
if (reading.ok) throw new Error('expected a finding');
120+
121+
// `status: 'error'` is load-bearing, not cosmetic: doctor's display loop
122+
// sets `hasErrors` from exactly this field, and `hasErrors` is what turns
123+
// the summary into `process.exit(1)`. A 'warning' here would reproduce the
124+
// defect — a correct sentence with exit code 0.
125+
expect(reading.result.status).toBe('error');
126+
});
127+
128+
it('names the fact: the variable and the value the operator actually typed', () => {
129+
process.env.OS_TENANCY_POSTURE = 'islolated'; // a real transposition typo
130+
const reading = resolveTenancyPostureOrFinding();
131+
if (reading.ok) throw new Error('expected a finding');
132+
133+
const text = plain(`${reading.result.message}\n${reading.result.fix ?? ''}`);
134+
expect(text).toContain('OS_TENANCY_POSTURE="islolated"');
135+
136+
// The misattribution is the defect. Neither word may reappear in the text
137+
// that replaces it: this is not a config problem and no config check was
138+
// skipped because of it.
139+
expect(text).not.toContain('Could not load config');
140+
expect(text).not.toContain('config checks skipped');
141+
});
142+
143+
it('prescribes a way out for EVERY posture the vocabulary declares (drift guard)', () => {
144+
process.env.OS_TENANCY_POSTURE = 'bogus';
145+
const reading = resolveTenancyPostureOrFinding();
146+
if (reading.ok) throw new Error('expected a finding');
147+
148+
const fix = plain(reading.result.fix ?? '');
149+
// Generated from TENANCY_POSTURES rather than restated, so a posture added
150+
// to the spec cannot leave this advice quietly incomplete.
151+
for (const posture of TENANCY_POSTURES) {
152+
expect(fix).toContain(`OS_TENANCY_POSTURE=${posture}`);
153+
}
154+
// …plus the escape the enumeration cannot express.
155+
expect(fix).toContain('unset OS_TENANCY_POSTURE');
156+
expect(fix).toContain('OS_MULTI_ORG_ENABLED');
157+
});
158+
159+
it("carries the resolver's own sentence as `cause` rather than paraphrasing it", () => {
160+
process.env.OS_TENANCY_POSTURE = 'bogus';
161+
const reading = resolveTenancyPostureOrFinding();
162+
if (reading.ok) throw new Error('expected a finding');
163+
164+
// `@objectstack/types` owns the vocabulary and its wording; doctor must not
165+
// maintain a second copy that can disagree with it.
166+
expect(plain(reading.result.fix ?? '')).toContain('cause: Invalid OS_TENANCY_POSTURE="bogus"');
167+
});
168+
169+
it('says what it did NOT read — doctor loads no .env, so a green report is not a serve guarantee', () => {
170+
process.env.OS_TENANCY_POSTURE = 'bogus';
171+
const reading = resolveTenancyPostureOrFinding();
172+
if (reading.ok) throw new Error('expected a finding');
173+
174+
// Deliberately the OPPOSITE of serve's gate text, which says it checked
175+
// "every .env file dotenv-flow loaded". serve calls `dotenvFlow.config()`;
176+
// doctor does not load `.env*` at all, so claiming the same coverage here
177+
// would be false. Overclaiming by one sentence is how a diagnostic stops
178+
// being trustworthy — the same reason PR #5381 refused to write "no port
179+
// has been bound".
180+
expect(plain(reading.result.fix ?? '')).toContain('does not\n load `.env*` files');
181+
});
182+
});
183+
184+
describe('os doctor reports an unrecognized posture and exits non-zero', () => {
185+
/**
186+
* The end-to-end assertion, run against the real `doctor` command in-process.
187+
*
188+
* This is the one that would have caught #5382, and it is written as a
189+
* DIFFERENTIAL over one variable: the same cwd, the same checks, the same
190+
* everything, with only `OS_TENANCY_POSTURE` changing between the two cases.
191+
* The valid-posture case on its own would pass against the broken code too
192+
* (it asserts an absence) — it is the control half, not the evidence.
193+
*
194+
* The temp cwd is built so the pre-existing checks cannot manufacture the
195+
* result: `node_modules/` exists, so the `Dependencies` check is `ok` rather
196+
* than the `error` that would exit 1 on its own and make the interesting
197+
* assertion pass for a reason having nothing to do with the posture.
198+
*/
199+
let tmp: string;
200+
let cwdSpy: ReturnType<typeof vi.spyOn>;
201+
202+
beforeEach(() => {
203+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'os-doctor-5382-'));
204+
// `Dependencies … Installed` — see above. Without this the baseline run
205+
// already has an error and the differential proves nothing.
206+
fs.mkdirSync(path.join(tmp, 'node_modules'));
207+
// Spying beats `process.chdir()`: doctor reads `process.cwd()` directly and
208+
// the spy works under every vitest pool, including worker threads where
209+
// `chdir` is not available at all.
210+
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmp);
211+
});
212+
213+
afterEach(() => {
214+
cwdSpy.mockRestore();
215+
fs.rmSync(tmp, { recursive: true, force: true });
216+
});
217+
218+
/** Run the real command, capturing stdout and any `process.exit`. */
219+
async function runDoctor(): Promise<{ out: string; exitCode: number | undefined }> {
220+
const logs: string[] = [];
221+
const logSpy = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => {
222+
logs.push(a.join(' '));
223+
});
224+
let exitCode: number | undefined;
225+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
226+
exitCode = code;
227+
throw new Error(`__PROCESS_EXIT__:${code}`);
228+
}) as never);
229+
230+
try {
231+
await Doctor.run([], { root: CLI_ROOT });
232+
} catch (err) {
233+
if (!(err instanceof Error) || !err.message.startsWith('__PROCESS_EXIT__')) throw err;
234+
} finally {
235+
logSpy.mockRestore();
236+
exitSpy.mockRestore();
237+
}
238+
return { out: plain(logs.join('\n')), exitCode };
239+
}
240+
241+
it('names OS_TENANCY_POSTURE, refuses to call the environment functional, and exits 1', async () => {
242+
// ── Control: the same environment with a posture that parses ──────────
243+
process.env.OS_TENANCY_POSTURE = 'isolated';
244+
const healthy = await runDoctor();
245+
246+
// Doctor completes normally. This is the sentence #5382 quoted, and here it
247+
// is CORRECT: this environment really can start.
248+
expect(healthy.exitCode).toBeUndefined();
249+
expect(healthy.out).toContain('Environment is functional');
250+
expect(healthy.out).not.toContain('Tenancy posture');
251+
252+
// ── The case: one character changed ──────────────────────────────────
253+
process.env.OS_TENANCY_POSTURE = 'isolatd';
254+
const broken = await runDoctor();
255+
256+
// Before this change every one of these four was the other way round: no
257+
// mention of the variable, "Environment is functional", exit 0.
258+
expect(broken.out).toContain('OS_TENANCY_POSTURE="isolatd"');
259+
expect(broken.out).toContain('is not a recognized tenancy posture');
260+
expect(broken.out).not.toContain('Environment is functional');
261+
expect(broken.exitCode).toBe(1);
262+
263+
// The prescription reaches the operator without `--verbose`: doctor prints
264+
// an error's `fix` unconditionally, and a diagnostic that names a problem
265+
// it will not tell you how to solve is half a diagnostic.
266+
expect(broken.out).toContain('Set one of the accepted values');
267+
for (const posture of TENANCY_POSTURES) {
268+
expect(broken.out).toContain(`OS_TENANCY_POSTURE=${posture}`);
269+
}
270+
271+
// And it is not blamed on the config. In this cwd there is no
272+
// `objectstack.config.ts` at all, so the config-analysis block never ran —
273+
// which is itself worth pinning: BEFORE the change, doctor's only posture
274+
// readers lived inside `if (configExists())`, so this environment produced
275+
// no posture diagnosis whatsoever, not even the misattributed one.
276+
expect(broken.out).not.toContain('Could not load config');
277+
// 60s, not the 5s default: this case imports and runs the REAL doctor
278+
// command in-process — twice — and each run shells out to `pnpm -v`,
279+
// `tsc -v` and `git --version`. On a loaded merge-queue shard that blew the
280+
// 5s default for PR #5381's equivalent case (queue run 30971902650), which
281+
// is what took that PR out of the queue. Same posture as the existing
282+
// `}, 60_000)` cases in this package (`utils/sqlite-occupancy.test.ts`,
283+
// `utils/schema-migrate.deferred-ddl.integration.test.ts`).
284+
}, 60_000);
285+
});

0 commit comments

Comments
 (0)