Skip to content

Commit 4e275f1

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22110-flow-text-slot-double-brace
2 parents f84cd82 + 3054516 commit 4e275f1

15 files changed

Lines changed: 534 additions & 37 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): with no built Console, `/_console/` says the Console is not built and names the remedy, instead of answering a bare 404 (#22225)
6+
7+
Clause-②: no
8+
9+
When the Console package `os serve` resolves has no built `dist/`, the server printed the "Console dist not found" boot warning and mounted nothing. `GET /_console/` then answered the router's `ENDPOINT_NOT_FOUND` and `GET /` an empty 404, which look exactly like a wrong URL.
10+
11+
Now, on that boot, the server mounts the Console's not-built answer on the routes the Console would own:
12+
13+
- **`GET /_console/` and every path under it** answer `503` with a short plain-text body. It says the Console is not built, then gives the remedy the boot warning names: `pnpm objectui:build` in a framework checkout, or reinstalling `@objectstack/cli`'s `@objectstack/console` dependency elsewhere. The same function picks the remedy for both. The HTTP form leaves out the absolute paths on the host, which only the terminal shows.
14+
- **`GET /` and `GET /_console`** redirect to `/_console/`, as they do when the Console is built.
15+
16+
A built Console is served exactly as before, and `--no-ui` / `--no-console` still mount nothing. The `--ui` flag's help text and the README row now name `@objectstack/console`, the package the resolver reads, instead of `@object-ui/console`.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): in the source checkout `os dev` serves, `os doctor`'s `Environment files` row names `os serve` / `os start` as the loaders of the `node_env=production` cascade it reports (#22249)
6+
7+
Clause-②: no
8+
9+
- **What changed.** With an `objectstack.config.ts` that boots as itself (alone, or beside its own compiled `dist/objectstack.json`), the `Environment files` row now says whose `.env*` cascade it resolved. A freshly scaffolded project with no `.env*` file reads ``✓ Environment files No .env* files here (node_env=production, the cascade `os serve` / `os start` load) — environment read from this process only — no environment input set``. When files are loaded, the row reads ``.env, … (node_env=production), the cascade `os serve` / `os start` load`` where it used to end ``the cascade `os serve` loads``. Before, the no-file form named no loader at all. That left `node_env=production` sitting directly above the `NODE_ENV` row that says `os dev` runs the project as development.
10+
- **What did not change.** Doctor still resolves the cascade exactly as `os serve` does, for `NODE_ENV || 'production'`. So it reads the same `.env*` files and every env-derived verdict, the tenancy-posture refusal among them, is unchanged in every posture. Outside the source checkout, the row is unchanged byte for byte. That covers an artifact-only project, a config beside `OS_ARTIFACT_URL` or beside an `OS_ARTIFACT_PATH` naming another artifact, and a directory with neither. Doctor's exit code is unchanged.

‎packages/cli/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ There are no short forms: `os -h` and `os -v` exit 2 with `command -h not found`
241241

242242
- `-p, --port <port>` — Server port. Resolution: `--port` › `$OS_PORT` › `$PORT` › `3000`. With `--dev` a busy port auto-hops to the next free one; in production mode it's a hard error (never silently drifts).
243243
- `--dev` — Run in development mode (load devPlugins, pretty logging)
244-
- `--ui` — Enable the bundled Console portal at `/_console/` when `@object-ui/console` is installed (default: true)
244+
- `--ui` — Enable the bundled Console portal at `/_console/`, served from `@objectstack/console` (default: true). With no built Console, `/_console/` answers `503` naming how to build or reinstall it.
245245
- `--no-server` — Skip starting HTTP server plugin
246246

247247
### `os generate`

‎packages/cli/src/commands/doctor-node-env-default.test.ts‎

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@
4747
* `#22163` blocks pin the posture itself and the row it selects. The new row's
4848
* wording is pinned only by the subjects it names (`os dev`, `os start`) and by
4949
* the production sentence being absent.
50+
*
51+
* ── #22249: the `Environment files` row above it, in the same posture ────
52+
*
53+
* #5387's ruling stands — doctor resolves the `.env*` cascade exactly as
54+
* `os serve` does, so that row still says `node_env=production` beside a
55+
* config `os dev` serves. In that posture only, it now names whose cascade
56+
* that is: `os serve` / `os start`. The `#22249` blocks pin the subjects it
57+
* names, that the cascade it reports did not move (the production file is
58+
* read, the development one is not), and — the control — that outside the
59+
* posture the row is the one every caller got before, field for field.
5060
*/
5161

5262
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -55,7 +65,13 @@ import os from 'node:os';
5565
import path from 'node:path';
5666
import { fileURLToPath } from 'node:url';
5767

58-
import Doctor, { nodeEnvCheck, nodeEnvSourcePosture, doctorNodeEnv } from './doctor.js';
68+
import Doctor, {
69+
nodeEnvCheck,
70+
nodeEnvSourcePosture,
71+
doctorNodeEnv,
72+
environmentSourcesCheck,
73+
readDotenvFiles,
74+
} from './doctor.js';
5975

6076
/** `packages/cli` — the oclif root the real command is loaded against below. */
6177
const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
@@ -230,6 +246,60 @@ describe('[#22163] nodeEnvSourcePosture — the source checkout `os dev` serves'
230246
});
231247
});
232248

249+
describe('[#22249] environmentSourcesCheck — in the source posture the row names its loaders', () => {
250+
let dir: string;
251+
beforeEach(() => {
252+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-doctor-22249-'));
253+
});
254+
afterEach(() => {
255+
fs.rmSync(dir, { recursive: true, force: true });
256+
});
257+
258+
const NO_ENV = {} as NodeJS.ProcessEnv;
259+
/**
260+
* The cascade the run resolves for an unset `NODE_ENV`, in both of the row's
261+
* shapes: nothing in it (the freshly scaffolded project), or a file in it.
262+
*/
263+
const SHAPES = ['no .env* file', 'a .env file'] as const;
264+
const readingFor = (shape: (typeof SHAPES)[number]) => {
265+
if (shape === 'a .env file') fs.writeFileSync(path.join(dir, '.env'), 'OS_TENANCY_POSTURE=isolated\n');
266+
return readDotenvFiles(dir, doctorNodeEnv(NO_ENV));
267+
};
268+
269+
it.each(SHAPES)('in the source posture (%s): `os serve` and `os start`, beside node_env=production', (shape) => {
270+
const check = environmentSourcesCheck(readingFor(shape), NO_ENV, { sourcePosture: true });
271+
272+
expect(check.status).toBe('ok');
273+
const message = plain(check.message);
274+
expect(message).toContain('node_env=production');
275+
expect(message).toContain('os serve');
276+
expect(message).toContain('os start');
277+
});
278+
279+
it.each(SHAPES)('outside it (%s): the row every caller got before, field for field', (shape) => {
280+
// The default IS "outside": a caller that never asked about the posture
281+
// keeps the row it always had. Same shape as #22163's pin on nodeEnvCheck.
282+
const reading = readingFor(shape);
283+
const explicit = environmentSourcesCheck(reading, NO_ENV, { sourcePosture: false });
284+
285+
expect(explicit).toEqual(environmentSourcesCheck(reading, NO_ENV));
286+
expect(plain(explicit.message)).toContain('node_env=production');
287+
expect(plain(explicit.message)).not.toContain('os start');
288+
});
289+
290+
it.each(SHAPES)('the posture moves the wording only — verdict, detail and attribution stay (%s)', (shape) => {
291+
const reading = readingFor(shape);
292+
const source = environmentSourcesCheck(reading, NO_ENV, { sourcePosture: true });
293+
const other = environmentSourcesCheck(reading, NO_ENV, { sourcePosture: false });
294+
295+
expect(source.status).toBe(other.status);
296+
expect(source.fix).toBe(other.fix);
297+
// The provenance clause closes the message in both postures, unchanged.
298+
const attribution = other.message.slice(other.message.lastIndexOf(' — '));
299+
expect(source.message.endsWith(attribution)).toBe(true);
300+
});
301+
});
302+
233303
describe('[#5673] os doctor, end to end — the row reaches the report', () => {
234304
/**
235305
* `node_modules/` exists in the temp cwd on purpose — without it doctor's
@@ -295,6 +365,36 @@ describe('[#5673] os doctor, end to end — the row reaches the report', () => {
295365
expect(run.out).toContain('Environment is functional');
296366
}, E2E_TIMEOUT);
297367

368+
/** The report rows whose name column is `Environment files`. */
369+
const envFilesRows = (out: string) =>
370+
out.split('\n').filter((line) => /^\s*\S+\s+Environment files {2,}/.test(line));
371+
/**
372+
* The row as every caller got it before #22249 — the posture-less call, which
373+
* the unit block above pins equal to `{ sourcePosture: false }` field for
374+
* field — for the cascade this run resolved.
375+
*/
376+
const unchangedRow = () =>
377+
environmentSourcesCheck(readDotenvFiles(tmp, doctorNodeEnv()), process.env).message;
378+
379+
// ── #22249 controls: outside the source posture the row does not move ──
380+
it.each([
381+
['an empty project directory', () => {}],
382+
['an artifact-only project (no config)', () => {
383+
fs.mkdirSync(path.join(tmp, 'dist'));
384+
fs.writeFileSync(path.join(tmp, 'dist', 'objectstack.json'), '{}\n');
385+
}],
386+
] as const)('[#22249] %s: the Environment files row is the unchanged one', async (_label, arrange) => {
387+
delete process.env.NODE_ENV;
388+
arrange();
389+
390+
const run = await runDoctor();
391+
392+
const rows = envFilesRows(run.out);
393+
expect(rows).toHaveLength(1);
394+
expect(rows[0].endsWith(unchangedRow())).toBe(true);
395+
expect(rows[0]).not.toContain('os start');
396+
}, E2E_TIMEOUT);
397+
298398
// Both directions, because "no row" has to hold for the environment that
299399
// agrees with the default as well as for the one that contradicts it: the row
300400
// reports that a DEFAULT was taken, not that the mode is production.
@@ -413,5 +513,50 @@ describe('[#5673] os doctor, end to end — the row reaches the report', () => {
413513
expect(run.exitCode).toBeUndefined();
414514
}
415515
}, E2E_TIMEOUT * 2);
516+
517+
// ── #22249 — the `Environment files` row above, in the same posture ──
518+
519+
it('(f) [#22249] the freshly scaffolded project: the sources row names `os serve` / `os start`', async () => {
520+
delete process.env.NODE_ENV;
521+
522+
const run = await runDoctor();
523+
524+
const rows = envFilesRows(run.out);
525+
expect(rows).toHaveLength(1);
526+
expect(rows[0]).toContain('node_env=production');
527+
expect(rows[0]).toContain('os serve');
528+
expect(rows[0]).toContain('os start');
529+
// …directly above #22163's row, which still names `os dev`.
530+
expect(nodeEnvRows(run.out)[0]).toContain('os dev');
531+
}, E2E_TIMEOUT);
532+
533+
it('(g) [#22249] …and the cascade it names did not move: the production file is read, the development one is not', async () => {
534+
delete process.env.NODE_ENV;
535+
// Comment-only files: present in a cascade, contributing no variable, so
536+
// nothing but the file list can tell the two cascades apart here.
537+
fs.writeFileSync(path.join(tmp, '.env.production'), '# production cascade\n');
538+
fs.writeFileSync(path.join(tmp, '.env.development'), '# development cascade\n');
539+
540+
const run = await runDoctor();
541+
542+
const rows = envFilesRows(run.out);
543+
expect(rows).toHaveLength(1);
544+
expect(rows[0]).toContain('.env.production');
545+
expect(rows[0]).not.toContain('.env.development');
546+
expect(rows[0]).toContain('node_env=production');
547+
expect(rows[0]).toContain('os start');
548+
}, E2E_TIMEOUT);
549+
550+
it('(h) [#22249] a config beside a NAMED artifact is outside the posture: the unchanged sources row', async () => {
551+
delete process.env.NODE_ENV;
552+
process.env.OS_ARTIFACT_PATH = path.join(tmp, 'release', 'objectstack.json');
553+
554+
const run = await runDoctor();
555+
556+
const rows = envFilesRows(run.out);
557+
expect(rows).toHaveLength(1);
558+
expect(rows[0].endsWith(unchangedRow())).toBe(true);
559+
expect(rows[0]).not.toContain('os start');
560+
}, E2E_TIMEOUT);
416561
});
417562
});

‎packages/cli/src/commands/doctor.ts‎

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,10 @@ export function doctorNodeEnv(env: NodeJS.ProcessEnv = process.env): string {
219219
*
220220
* What the posture does NOT change: {@link doctorNodeEnv} and the `.env*`
221221
* cascade doctor resolves (still `production`, which the `Environment files`
222-
* row keeps saying), the `/discovery` default, and how `os serve`, `os start`
223-
* and `os dev` resolve the mode. Only what this row says, and when.
222+
* row keeps saying — in this posture naming `os serve` / `os start` as the
223+
* loaders of that cascade, #22249), the `/discovery` default, and how
224+
* `os serve`, `os start` and `os dev` resolve the mode. Only what this row
225+
* says, and when.
224226
*
225227
* ── Deliberately NOT in `DOCTOR_ENV_INPUTS`, and not read through the overlay ─
226228
*
@@ -277,7 +279,8 @@ export function nodeEnvCheck(
277279
* [#22163] Is `cwd` the source checkout `os dev` serves — a cwd
278280
* `objectstack.config.ts` that boots as itself, with no artifact from anywhere
279281
* else? The posture {@link nodeEnvCheck} reads an unset `NODE_ENV` as
280-
* development in.
282+
* development in, and {@link environmentSourcesCheck} names the loaders of the
283+
* cascade it reports in (#22249). `run()` asks once and hands both the answer.
281284
*
282285
* Answered by THE precedence (`utils/artifact-precedence.ts`), never by a copy
283286
* of it: {@link resolveArtifactBootSource} for the artifact rungs, then
@@ -640,18 +643,41 @@ function revertDotenvOverlay(overlay: Record<string, string>, applied: string[])
640643
* `DOCTOR_ENV_INPUTS`, so they are not enumerated here — but the files that
641644
* supplied them are, which is what makes the wider overlay a reported fact
642645
* rather than the silent merge #5387 refused.
646+
*
647+
* ── In the source checkout `os dev` serves, the row says whose cascade (#22249) ─
648+
*
649+
* The cascade itself does not move with the posture: #5387's ruling stands,
650+
* doctor resolves it exactly as `os serve` does, so {@link doctorNodeEnv} and
651+
* every env-derived verdict are the same in every posture. What moves is the
652+
* reader. Beside a config `os dev` serves, the `NODE_ENV` row just below says
653+
* the project runs as development under `os dev` (#22163), and `os dev` loads
654+
* the `node_env=development` cascade — so an unattributed `node_env=production`
655+
* one row above it reads to a newcomer as "my dev project is production". There
656+
* the row names its loaders, `os serve` and `os start`, both of which resolve
657+
* the cascade doctor resolved (`start.ts` and `serve.ts` derive the same mode
658+
* from `NODE_ENV`, and `os start` forces `production` only when it is unset).
659+
* In every other posture the row is what it was, byte for byte.
660+
*
661+
* @param opts.sourcePosture {@link nodeEnvSourcePosture}'s answer for the
662+
* directory doctor runs in — the same answer {@link nodeEnvCheck} is given,
663+
* computed once by the caller. Defaults to `false`, the unchanged row.
643664
*/
644665
export function environmentSourcesCheck(
645666
reading: DotenvReading,
646667
env: NodeJS.ProcessEnv = process.env,
668+
opts: { sourcePosture?: boolean } = {},
647669
): HealthCheckResult {
648670
const loaded = reading.files.map((file) => displayEnvFile(reading, file));
649671
const provenances = DOCTOR_ENV_INPUTS.map((name) => provenanceOf(reading, name, env));
650672
const set = provenances.filter((p) => p.source !== 'unset');
651673

674+
const cascade = `node_env=${reading.nodeEnv}`;
675+
const loader = opts.sourcePosture ? '`os serve` / `os start` load' : '`os serve` loads';
652676
const where = loaded.length > 0
653-
? `${loaded.join(', ')} (node_env=${reading.nodeEnv}), the cascade \`os serve\` loads`
654-
: `No .env* files here (node_env=${reading.nodeEnv}) — environment read from this process only`;
677+
? `${loaded.join(', ')} (${cascade}), the cascade ${loader}`
678+
: opts.sourcePosture
679+
? `No .env* files here (${cascade}, the cascade ${loader}) — environment read from this process only`
680+
: `No .env* files here (${cascade}) — environment read from this process only`;
655681
const attribution = set.length > 0
656682
? ` — ${set.map((p) => describeProvenance(reading, p)).join(', ')}`
657683
: ' — no environment input set';
@@ -2181,7 +2207,12 @@ export default class Doctor extends Command {
21812207
// derived from it. Unconditional: an env-derived verdict whose inputs are
21822208
// not attributed is a verdict the operator cannot check, and after this
21832209
// change every such verdict has four possible sources.
2184-
results.push(environmentSourcesCheck(dotenvReading));
2210+
//
2211+
// #22163 / #22249 — the source checkout `os dev` serves, asked ONCE: the
2212+
// sources row and the NODE_ENV row below both read this one answer, so the
2213+
// two rows can never describe two different postures in one report.
2214+
const sourcePosture = nodeEnvSourcePosture(cwd);
2215+
results.push(environmentSourcesCheck(dotenvReading, process.env, { sourcePosture }));
21852216

21862217
// #5673 / #22163 — what an unset NODE_ENV means here. Placed immediately
21872218
// after the sources row because it answers the question that row raises:
@@ -2191,9 +2222,11 @@ export default class Doctor extends Command {
21912222
// says that project runs as development under `os dev` and as production
21922223
// under `os start` — and the sources row above still says
21932224
// `node_env=production` there, because that is the cascade doctor resolved
2194-
// and #22163 changes only this row. Only the unset case produces a row; a
2195-
// configured environment's report is unchanged.
2196-
const nodeEnvFinding = nodeEnvCheck(process.env, { sourcePosture: nodeEnvSourcePosture(cwd) });
2225+
// (#5387); since #22249 it also names `os serve` / `os start` as the
2226+
// loaders of that cascade there, so the two rows read as one account. Only
2227+
// the unset case produces a row; a configured environment's report is
2228+
// unchanged.
2229+
const nodeEnvFinding = nodeEnvCheck(process.env, { sourcePosture });
21972230
if (nodeEnvFinding) {
21982231
results.push(nodeEnvFinding);
21992232
}

0 commit comments

Comments
 (0)