Skip to content

Commit 86ae119

Browse files
fix(cli): with no built Console, /_console/ answers 503 naming the remedy instead of a bare 404 (#22419)
Fixes #22225 Clause-②: no ## What changes When the Console package `os serve` resolves has no built `dist/`, the server printed the "Console dist not found" boot warning and mounted nothing at the HTTP surface. `GET /_console/` then answered the router's `ENDPOINT_NOT_FOUND`, which looks exactly like a wrong URL (AGENTS.md, Route & surface ownership rule 3, "Absence must be loud"). On that same boot branch, `os serve` now also mounts `createConsoleNotBuiltPlugin(consolePath)` (`packages/cli/src/utils/console.ts`, internal, not on the `./console` subpath). It mounts the three routes the Console static plugin would mount: | route | built Console (unchanged) | no built Console (new) | | --- | --- | --- | | `GET /_console/*` | the SPA, from `dist/` | `503`, `text/plain`, `cache-control: no-store`: the Console is not built, then the remedy | | `GET /_console` | `302` to `/_console/` | `302` to `/_console/` | | `GET /` | `302` to `/_console/` | `302` to `/_console/` | **One remedy picker.** `formatConsoleDistMissingWarning` now renders a private helper, `describeConsoleDistMissing(consolePath, hostPaths)`. That helper holds the one `findConsoleBuildRoot` decision and the one copy of the remedy prose. The boot warning calls it with `hostPaths: true`, and its bytes are unchanged (the existing `console-resolve.test.ts` pins pass as before). The HTTP answer calls it with `hostPaths: false`. There is no second picker and no copied sentence. **`GET /`: redirect.** This is the card's design call. `/` redirects to `/_console/` whether or not the Console is built. Three reasons: - The route keeps one owner and one answer: the Console's. The not-built plugin claims `/` on exactly the boots where the static plugin would have claimed it. It registers at the same point in `serve.ts`, so it shadows nothing the built case did not already shadow. - Building the Console and restarting changes only what `/_console/*` returns. - A browser following `/` lands on the not-built page. **Status `503`, not `404`.** The address is right, and the server is missing a build artifact. The operator fixes that, not the caller. A caller that reads only the status code can still tell this apart from a wrong URL. **Plain text, not a JSON envelope.** These routes are the Console's `static-asset` family in `console-route-ledger.ts`, not API surface. The `503` body is read by a browser or by `curl`, and a JSON envelope would put an API-shaped body on a route the ledger keeps static-asset only. ## What an anonymous caller is told (the card's H4) Anyone who can reach the port can read the HTTP answer, with no credential. The terminal warning is read by the operator. So the HTTP form leaves out every absolute path on the host, and keeps the remedy: - **Framework checkout:** `Console dist not found at packages/console/dist — ... Build it with pnpm objectui:build in the root of the ObjectStack checkout this server runs from (...)`. The terminal names the absolute root; the HTTP form names the repository-relative `packages/console/dist`, which is the repo's public layout. - **Installed CLI:** `Console dist not found — the Console ships prebuilt in @objectstack/console, a dependency of @objectstack/cli; reinstall it at the same version as the CLI.` The terminal names the absolute `.../node_modules/.../dist` path, which can reveal the install layout and version. The HTTP form drops it. ## Help text The `--ui` help text in `serve.ts`, the `packages/cli/README.md` row and the mount comment in `serve.ts` now name `@objectstack/console`, the package `resolveConsolePath` reads, instead of `@object-ui/console`. ## Measured on the real composition Each boot ran `os serve` from source (`bin/run-dev.js` through tsx) on a bare `export default {};` config, with `OS_DATABASE_URL=:memory:`. The worktree's `packages/console` has no `dist/`, so it is the console package the CLI resolves. Responses were read with `curl -i`. | boot | `GET /_console/` | `GET /` | `GET /_console` | boot output | | --- | --- | --- | --- | --- | | before: this head with the two not-built mount lines in `serve.ts` ablated (BASE's composition; restore proven blob-equal to HEAD) | `404` `ENDPOINT_NOT_FOUND` | `404` `ENDPOINT_NOT_FOUND` | `404` `ENDPOINT_NOT_FOUND` | warning printed once | | after, no dist | `503`, the body below | `302` to `/_console/` | `302` to `/_console/` | warning printed once; `ConsoleNotBuilt` in the plugin list; no `Console:` banner row | | after, `--no-ui` | `404` `ENDPOINT_NOT_FOUND` | `404` `ENDPOINT_NOT_FOUND` | n/a | no warning, nothing mounted | | after, control: a fixture `@objectstack/console` with `dist/index.html` in the cwd | `200` `text/html`, the shell with the base tag injected | `302` to `/_console/` | `302` to `/_console/` | `ConsoleUI` loaded, `Console:` banner row | `GET /api/v1/health` answered `200` on the no-dist boot. The not-built body, as served: ```text The ObjectStack Console is not built, so this server has no Console to serve at /_console/. Console dist not found at packages/console/dist — `pnpm build` does not produce it. Build it with `pnpm objectui:build` in the root of the ObjectStack checkout this server runs from (uses a ../objectui checkout if present, else clones objectui at the pinned commit — needs network). Then restart the server. The API is served either way. ``` The card's measurement had `GET /` returning an empty `404` body. At BASE through this composition, it answered the same `ENDPOINT_NOT_FOUND` JSON as `/_console/`. Either way it was indistinguishable from a wrong URL. ## Tests `packages/cli/src/utils/console.not-built.test.ts` (unit tier) runs the real plugins on a real `HonoHttpServer`: - **Framework tree:** `GET /_console/`, a client-side route and a hashed-asset path each answer `503`, `text/plain` and `no-store`. Each body's first line is the not-built headline, and each names `pnpm objectui:build`. The body is the boot warning's sentence with only the absolute build root replaced, and it does not contain that root. - **Install tree:** the body is the warning's sentence with the dist path removed. It names `@objectstack/console`, not `pnpm objectui:build`, and contains no path of the project. - **Redirects:** `/` and `/_console` redirect to `/_console/` in both trees. - **Controls:** a built dist served by `createConsoleStaticPlugin` answers `200` with the shell and the same redirects. With no Console plugin mounted, `/` and `/_console/` are the bare `404`. `test/published-subpath-console.pin.test.ts` lists the new export in `INTERNAL_SINCE_RETIREMENT`, so the partition of `utils/console.ts` still accounts for every export and the public face stays exactly three names. Three ablations each made the test fail, as expected. Each was applied with `scripts/ablation-replace.mjs`: anchor hit 1 to 0, blob changed, and restore proven blob-equal to HEAD with `git diff HEAD` empty. The test imports `./console.js` relatively, so it reads `src/` and no `dist/` was involved. | ablation | result | | --- | --- | | delete the `/_console/*` 503 mount | 5 failed / 4 passed | | render the HTTP answer with `hostPaths: true` | 2 failed / 7 passed (both no-host-path assertions) | | drop the `/` redirect | 2 failed / 7 passed (both redirect tests) | ## Verification (all at `813ff4827`, this PR's head) - **Build.** The dependency closure was built with `pnpm turbo run build --filter='@objectstack/cli^...' --concurrency=2` (58/58 tasks), then `pnpm --filter @objectstack/cli build`. Lock verdict for both: `VERDICT command-exit 0`. - **Unit tests.** `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: `Test Files 272 passed (272)`, `Tests 4003 passed (4003)`, `VERDICT command-exit 0`. `vitest list --project unit` confirms that `console.not-built.test.ts`, `published-subpath-console.pin.test.ts`, `console-route-ledger.conformance.test.ts` and `console-resolve.test.ts` are in that run. - **Typecheck.** `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit`, then `check:test-typecheck` over `tsconfig.test.json`) exited 0. The test-layer debt held, with no new error. - **Integration tests: left to CI.** This diff changes no integration-tier file, no `bin/` entry and no `test/helpers/serve-process.ts`. No queue-tier integration test boots `serve` with the Console enabled: `runServe` pins `OS_DISABLE_CONSOLE=1`, and a grep of the 98 integration files for serve/dev/start spawns that set neither `OS_DISABLE_CONSOLE` nor `--no-ui` turned up only flow-node `'start'` ids and a `dev` refusal pin. - **Dispatch gates.** The gate lead's 66 lines, plus `pnpm check:cli-test-child-env` (added when the list was re-derived with no paths at this head), all exited 0. `dispatch-gates --ran` says: `67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED (a DERIVED zero — all 67 recorded an exit code and none of them is 3)`. - **Lint (`pnpm lint`), narrowed to the changed files.** `eslint --no-inline-config --format json` over the 6 changed files: eslint's own config linted 4 (`serve.ts`, `console.ts` and the two tests) with 0 errors and 0 warnings. It ignored the 2 `.md` files ("File ignored because no matching configuration was supplied"). The narrowing loses nothing: `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), and the only files it reads at load are baselines this diff does not touch. So no untouched file's result can change. The full `pnpm lint` run is CI's. - **Main has moved.** `origin/main` gained 4 commits since `b1f7a7a73`. None touches a file in this diff, and `git merge-tree` merges cleanly. ## Acceptance notes - **`console-route-ledger.ts` notes, not amended here.** `GET /`, `GET /_console` and `GET /_console/*` are now mounted by two factories in `utils/console.ts`, on mutually exclusive boots. The ledger rows' route, `mountedIn` and `static-asset` disposition stay true, and the conformance census passes. But each row's `family` and note describe only `createConsoleStaticPlugin`. The file is outside this card's claimed file surface, so it is left for a follow-up. Suggested note addition per row: "Also mounted by `createConsoleNotBuiltPlugin` when the console package resolves without a `dist/`: the same redirect, or for `/_console/*` a `503` plain-text answer naming the build or reinstall remedy." - **Same family, not fixed here: a drift-refused dev boot.** `os serve --dev` refuses to mount a Console whose `dist/.objectui-sha` differs from the repo pin. Measured on a fixture with pin `aaaa…` and stamp `bbbb…`: the terminal prints the refusal block, and `GET /_console/` and `GET /` answer `404` `ENDPOINT_NOT_FOUND`. That is the same bare 404, with a different remedy (`formatConsoleShaDriftRefusal`, which names an absolute pin-file path). Reported to the dispatching seat. - **Observations, not filed:** `GET /forms/:slug` (the public form short link) is mounted only with a built Console, so with no build it falls through to `404`. If the console package does not resolve at all (`consolePath` is `null` without `--no-console`), nothing is printed and nothing is mounted. That needs a broken install, and no public reach was measured. --- _Generated by [Claude Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 27a8b33 commit 86ae119

6 files changed

Lines changed: 305 additions & 14 deletions

File tree

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`.

‎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/serve.ts‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ import {
157157
formatConsoleShaDriftRefusal,
158158
formatConsoleDistMissingWarning,
159159
createConsoleStaticPlugin,
160+
createConsoleNotBuiltPlugin,
160161
createRuntimeAssetsPlugin,
161162
type ConsoleShaDrift,
162163
} from '../utils/console.js';
@@ -1197,7 +1198,7 @@ export default class Serve extends Command {
11971198
static override flags = {
11981199
port: Flags.string({ char: 'p', description: 'Server port', default: readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true }) ?? '3000' }),
11991200
dev: Flags.boolean({ description: 'Run in development mode (load devPlugins)' }),
1200-
ui: Flags.boolean({ description: 'Enable the bundled Console portal at /_console/ when @object-ui/console is installed (default: true)', default: true, allowNo: true }),
1201+
ui: Flags.boolean({ description: 'Enable the bundled Console portal at /_console/, served from @objectstack/console (default: true)', default: true, allowNo: true }),
12011202
console: Flags.boolean({
12021203
description: 'Mount the Console UI at /_console/ when the package is installed (default: true).',
12031204
default: true,
@@ -5088,12 +5089,16 @@ export default class Serve extends Command {
50885089
});
50895090

50905091
// ── Console portal ──────────────────────────────────────────
5091-
// The opinionated, fork-ready runtime console (`@object-ui/console`,
5092-
// published from the objectstack-ai/objectui monorepo) mounts under
5093-
// `/_console/`. When present, it owns the root `/` redirect
5094-
// (preferred default UI). It is optional — we only mount it when
5095-
// the package resolves and a pre-built `dist/` is present, and — in
5096-
// dev — only when that build matches the repo's objectui pin (#7752).
5092+
// The opinionated, fork-ready runtime console (`@objectstack/console`,
5093+
// the build of objectstack-ai/objectui's console app this framework
5094+
// vendors at its `.objectui-sha` pin) mounts under `/_console/`. When
5095+
// present, it owns the root `/` redirect (preferred default UI). It is
5096+
// optional — we only mount it when the package resolves and a
5097+
// pre-built `dist/` is present, and — in dev — only when that build
5098+
// matches the repo's objectui pin (#7752). A package that resolves
5099+
// with no `dist/` gets the not-built answer on those same routes, so
5100+
// `/_console/` names the remedy the boot warning prints instead of
5101+
// answering a bare 404.
50975102
if (consolePath) {
50985103
if (consoleWillMount) {
50995104
if (consoleDrift) {
@@ -5106,6 +5111,8 @@ export default class Serve extends Command {
51065111
console.error(chalk.red(formatConsoleShaDriftRefusal(consoleDrift)));
51075112
} else {
51085113
console.warn(chalk.yellow(formatConsoleDistMissingWarning(consolePath)));
5114+
await kernel.use(createConsoleNotBuiltPlugin(consolePath));
5115+
trackPlugin('ConsoleNotBuilt');
51095116
}
51105117
}
51115118
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* What a server with no built Console answers at `/_console/` and at `/`.
5+
*
6+
* Before the not-built answer existed, `os serve` mounted nothing when the
7+
* console package resolved without a `dist/`: `GET /_console/` got the
8+
* router's bare not-found and `GET /` an empty 404, the same answers a wrong
9+
* URL gets, and only the boot warning said why. `os serve` now mounts
10+
* `createConsoleNotBuiltPlugin` on that boot, and these tests pin what it
11+
* answers:
12+
*
13+
* - `GET /_console/` (and every path under it) answers `503` with the remedy
14+
* the boot warning names — the same sentence from the same picker, with
15+
* the host's absolute paths left out;
16+
* - `GET /` and `GET /_console` redirect to `/_console/`, exactly as they do
17+
* once the Console is built (the control below), so neither route changes
18+
* owner with the build.
19+
*
20+
* These tests run the REAL plugins on a REAL Hono app (the `HonoHttpServer`
21+
* that production resolves as `http.server`).
22+
*/
23+
24+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
25+
import fs from 'node:fs';
26+
import os from 'node:os';
27+
import path from 'node:path';
28+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
29+
30+
import {
31+
createConsoleNotBuiltPlugin,
32+
createConsoleStaticPlugin,
33+
formatConsoleDistMissingWarning,
34+
} from './console.js';
35+
36+
const ORIGIN = 'http://console.example.test';
37+
38+
/** The first line of the not-built answer, which says what is wrong before saying how to fix it. */
39+
const NOT_BUILT_HEADLINE = 'The ObjectStack Console is not built, so this server has no Console to serve at /_console/.';
40+
41+
/** The framework repo's remedy. */
42+
const BUILD_REMEDY = 'pnpm objectui:build';
43+
44+
/** The prefix the terminal warning carries and the HTTP answer does not. */
45+
const WARNING_PREFIX = ' ⚠ ';
46+
47+
/** Requests the not-built answer must cover: the shell, a client-side route, and a hashed asset. */
48+
const CONSOLE_REQUESTS = ['/_console/', '/_console/apps/crm/records/42', '/_console/assets/index-ocmkyCt6.js'];
49+
50+
type Request = (p: string, init?: RequestInit) => Promise<Response>;
51+
52+
let scratch: string;
53+
54+
beforeAll(() => {
55+
scratch = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'os-test-console-not-built-')));
56+
});
57+
58+
afterAll(() => {
59+
fs.rmSync(scratch, { recursive: true, force: true });
60+
});
61+
62+
function writeJson(file: string, value: unknown): void {
63+
fs.mkdirSync(path.dirname(file), { recursive: true });
64+
fs.writeFileSync(file, JSON.stringify(value));
65+
}
66+
67+
/** A console package with a manifest and no `dist/` — what the boot warning is printed for. */
68+
function writeUnbuiltConsole(dir: string): string {
69+
writeJson(path.join(dir, 'package.json'), { name: '@objectstack/console', version: '1.0.0' });
70+
return dir;
71+
}
72+
73+
/** The framework repo's shape: the root manifest declares the console build script. */
74+
function frameworkTree(): { root: string; consoleDir: string } {
75+
const root = path.join(scratch, 'framework');
76+
writeJson(path.join(root, 'package.json'), {
77+
name: 'framework-root',
78+
private: true,
79+
scripts: { build: 'turbo run build', 'objectui:build': 'bash scripts/build-console.sh' },
80+
});
81+
return { root, consoleDir: writeUnbuiltConsole(path.join(root, 'packages', 'console')) };
82+
}
83+
84+
/** A project that installed the CLI, whose `@objectstack/console` arrived without its `dist/`. */
85+
function installTree(): { project: string; consoleDir: string } {
86+
const project = path.join(scratch, 'app');
87+
writeJson(path.join(project, 'package.json'), { name: 'consumer-app', scripts: { dev: 'objectstack dev' } });
88+
return {
89+
project,
90+
consoleDir: writeUnbuiltConsole(path.join(project, 'node_modules', '@objectstack', 'console')),
91+
};
92+
}
93+
94+
async function serve(plugin: { start: (ctx: any) => Promise<void> }): Promise<Request> {
95+
const server = new HonoHttpServer(0);
96+
await plugin.start({ getServiceAsync: async () => server, logger: { warn: () => {} } });
97+
const app = server.getRawApp();
98+
return async (p, init) => app.request(`${ORIGIN}${p}`, init);
99+
}
100+
101+
/** `/` and `/_console` redirect to the Console's shell, built or not. */
102+
async function expectRedirectsToConsole(request: Request): Promise<void> {
103+
for (const p of ['/', '/_console']) {
104+
const res = await request(p);
105+
expect(res.status, `GET ${p}`).toBe(302);
106+
expect(res.headers.get('location'), `GET ${p}`).toBe('/_console/');
107+
}
108+
}
109+
110+
describe('a framework checkout with no built Console', () => {
111+
let request: Request;
112+
let root: string;
113+
let consoleDir: string;
114+
beforeAll(async () => {
115+
({ root, consoleDir } = frameworkTree());
116+
request = await serve(createConsoleNotBuiltPlugin(consoleDir));
117+
});
118+
119+
it.each(CONSOLE_REQUESTS)('GET %s answers 503 naming `pnpm objectui:build`', async (p) => {
120+
const res = await request(p);
121+
expect(res.status).toBe(503);
122+
expect(res.headers.get('content-type')).toMatch(/^text\/plain/);
123+
expect(res.headers.get('cache-control')).toBe('no-store');
124+
const body = await res.text();
125+
expect(body.split('\n')[0]).toBe(NOT_BUILT_HEADLINE);
126+
expect(body).toContain(BUILD_REMEDY);
127+
});
128+
129+
it('is the boot warning\'s sentence, with only the absolute build root left out', async () => {
130+
const warning = formatConsoleDistMissingWarning(consoleDir);
131+
expect(warning.startsWith(WARNING_PREFIX)).toBe(true);
132+
// The warning names the root to run the script in; the HTTP answer says
133+
// where in words. Everything either side of that path is the same text.
134+
const [before, after] = warning.slice(WARNING_PREFIX.length).split(` in ${root} `);
135+
expect(after, 'the warning no longer names the build root this test splits on').toBeDefined();
136+
137+
const body = await (await request('/_console/')).text();
138+
expect(body).toContain(before);
139+
expect(body).toContain(after);
140+
expect(body).toContain(path.join('packages', 'console', 'dist'));
141+
expect(body).not.toContain(root);
142+
});
143+
144+
it('redirects `/` and `/_console` to `/_console/`, as a built Console does', async () => {
145+
await expectRedirectsToConsole(request);
146+
});
147+
});
148+
149+
describe('an installed CLI whose `@objectstack/console` has no `dist/`', () => {
150+
let request: Request;
151+
let project: string;
152+
let consoleDir: string;
153+
beforeAll(async () => {
154+
({ project, consoleDir } = installTree());
155+
request = await serve(createConsoleNotBuiltPlugin(consoleDir));
156+
});
157+
158+
it('answers 503 with the reinstall remedy, the boot warning\'s sentence without the dist path', async () => {
159+
const warning = formatConsoleDistMissingWarning(consoleDir);
160+
const dist = path.join(consoleDir, 'dist');
161+
const [before, after] = warning.slice(WARNING_PREFIX.length).split(` at ${dist}`);
162+
expect(after, 'the warning no longer names the dist path this test splits on').toBeDefined();
163+
164+
const res = await request('/_console/');
165+
expect(res.status).toBe(503);
166+
const body = await res.text();
167+
expect(body.split('\n')[0]).toBe(NOT_BUILT_HEADLINE);
168+
expect(body).toContain(`${before}${after}`);
169+
expect(body).toContain('@objectstack/console');
170+
expect(body).not.toContain(BUILD_REMEDY);
171+
expect(body).not.toContain(project);
172+
});
173+
174+
it('redirects `/` and `/_console` to `/_console/`', async () => {
175+
await expectRedirectsToConsole(request);
176+
});
177+
});
178+
179+
describe('controls', () => {
180+
it('a BUILT Console serves its SPA shell at `/_console/` and the same redirects', async () => {
181+
const dist = path.join(scratch, 'built', 'dist');
182+
fs.mkdirSync(dist, { recursive: true });
183+
fs.writeFileSync(path.join(dist, 'index.html'), '<!doctype html><html><head></head><body>console shell</body></html>');
184+
const request = await serve(createConsoleStaticPlugin(dist));
185+
186+
const res = await request('/_console/');
187+
expect(res.status).toBe(200);
188+
expect(res.headers.get('content-type')).toMatch(/^text\/html/);
189+
expect(await res.text()).toContain('console shell');
190+
await expectRedirectsToConsole(request);
191+
});
192+
193+
it('with no Console plugin mounted, both paths are the bare 404 the not-built answer replaces', async () => {
194+
// What `--no-ui` composes, and what a not-built boot composed before: the
195+
// answers above come from the plugin, not from the server.
196+
const request = await serve({ start: async () => {} });
197+
for (const p of ['/', '/_console/']) {
198+
const res = await request(p);
199+
expect(res.status, `GET ${p}`).toBe(404);
200+
expect(await res.text(), `GET ${p}`).not.toContain(NOT_BUILT_HEADLINE);
201+
}
202+
});
203+
});

‎packages/cli/src/utils/console.ts‎

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,10 @@ function findConsoleBuildRoot(consolePath: string): string | null {
334334
}
335335

336336
/**
337-
* The boot warning for a resolved console package with no built `dist/`.
337+
* What is missing and what fixes it, for a resolved console package with no
338+
* built `dist/` — the ONE remedy picker, which both the boot warning and the
339+
* HTTP answer render ({@link formatConsoleDistMissingWarning},
340+
* {@link createConsoleNotBuiltPlugin}).
338341
*
339342
* Two places reach it, and each gets the remedy that works there:
340343
*
@@ -347,23 +350,35 @@ function findConsoleBuildRoot(consolePath: string): string | null {
347350
* remedy is that package install. (An installed `@object-ui/console`,
348351
* which this text used to name, is never consulted: that name is matched
349352
* only as the workspace package of a sibling `../objectui` checkout.)
353+
*
354+
* `hostPaths` decides whether the text names absolute paths on this host. The
355+
* terminal's reader is the operator, who is told exactly where to look. The
356+
* HTTP answer's reader is whoever requests `/_console/`, with no credential, so
357+
* that form names no absolute path: the remedy (the script or the package to
358+
* reinstall) is the same sentence either way, and the repository-relative
359+
* `packages/console/dist` stays because it is the repository's public layout.
350360
*/
351-
export function formatConsoleDistMissingWarning(consolePath: string): string {
361+
function describeConsoleDistMissing(consolePath: string, hostPaths: boolean): string {
352362
const dist = path.join(consolePath, 'dist');
353363
const buildRoot = findConsoleBuildRoot(consolePath);
354364
if (buildRoot) {
355365
return (
356-
` ⚠ Console dist not found at ${path.relative(buildRoot, dist)} — \`pnpm build\` does not produce it. ` +
357-
`Build it with \`pnpm ${CONSOLE_BUILD_SCRIPT}\` in ${buildRoot} ` +
366+
`Console dist not found at ${path.relative(buildRoot, dist)} — \`pnpm build\` does not produce it. ` +
367+
`Build it with \`pnpm ${CONSOLE_BUILD_SCRIPT}\` in ${hostPaths ? buildRoot : 'the root of the ObjectStack checkout this server runs from'} ` +
358368
`(uses a ../objectui checkout if present, else clones objectui at the pinned commit — needs network).`
359369
);
360370
}
361371
return (
362-
` ⚠ Console dist not found at ${dist} — the Console ships prebuilt in \`${CONSOLE_PACKAGE}\`, ` +
372+
`Console dist not found${hostPaths ? ` at ${dist}` : ''} — the Console ships prebuilt in \`${CONSOLE_PACKAGE}\`, ` +
363373
`a dependency of \`@objectstack/cli\`; reinstall it at the same version as the CLI.`
364374
);
365375
}
366376

377+
/** The boot warning for a resolved console package with no built `dist/`. */
378+
export function formatConsoleDistMissingWarning(consolePath: string): string {
379+
return ` ⚠ ${describeConsoleDistMissing(consolePath, true)}`;
380+
}
381+
367382
// ─── objectui-SHA Drift Guard (dev monorepo only) ───────────────────
368383

369384
/**
@@ -792,6 +807,56 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
792807
};
793808
}
794809

810+
/**
811+
* What `/_console/` answers when the console package resolved but has no built
812+
* `dist/` — mounted by `os serve` in place of {@link createConsoleStaticPlugin},
813+
* on the boot that prints {@link formatConsoleDistMissingWarning}.
814+
*
815+
* Without it nothing was mounted, so `GET /_console/` got the router's bare
816+
* `ENDPOINT_NOT_FOUND` and `GET /` an empty 404: a missing build read exactly
817+
* like a wrong URL, and only the terminal said why (AGENTS.md, Route & surface
818+
* ownership rule 3, "Absence must be loud"). It mounts the same three routes the
819+
* static plugin would, so who owns each path does not change with the build:
820+
*
821+
* - `GET /` and `GET /_console` redirect to `/_console/`, as they do once the
822+
* Console is built — so `/` keeps one owner and one answer, the Console's;
823+
* - `GET /_console/*` answers `503` with a short `text/plain` body saying the
824+
* Console is not built, then the remedy {@link describeConsoleDistMissing}
825+
* picked for the warning, rendered without host paths.
826+
*
827+
* `503`, not `404`: the address is right and the server is missing a build it
828+
* needs, which the operator fixes and a caller cannot — and a caller reading
829+
* only the status still tells it apart from a wrong URL. Plain text rather than
830+
* a JSON envelope: these are the Console's static-asset routes, not API
831+
* surface (`console-route-ledger.ts`), read by a browser or by `curl`.
832+
* `no-store`, so a browser never keeps the answer past the restart that serves
833+
* the real Console.
834+
*/
835+
export function createConsoleNotBuiltPlugin(consolePath: string) {
836+
const answer =
837+
`The ObjectStack Console is not built, so this server has no Console to serve at ${CONSOLE_PATH}/.\n\n` +
838+
`${describeConsoleDistMissing(consolePath, false)}\n\n` +
839+
'Then restart the server. The API is served either way.\n';
840+
return {
841+
name: 'com.objectstack.console-not-built',
842+
843+
init: async () => {},
844+
845+
start: async (ctx: any) => {
846+
const httpServer = await resolveHttpServer(ctx);
847+
if (!httpServer?.getRawApp) {
848+
ctx.logger?.warn?.('Console not-built answer: http.server service not found — skipping');
849+
return;
850+
}
851+
852+
const app = httpServer.getRawApp();
853+
app.get('/', (c: any) => c.redirect(`${CONSOLE_PATH}/`));
854+
app.get(CONSOLE_PATH, (c: any) => c.redirect(`${CONSOLE_PATH}/`));
855+
app.get(`${CONSOLE_PATH}/*`, (c: any) => c.text(answer, 503, { 'cache-control': 'no-store' }));
856+
},
857+
};
858+
}
859+
795860
// ─── Runtime Assets Plugin ──────────────────────────────────────────
796861

797862
/** The URL prefix the runtime assets route is mounted under. */

‎packages/cli/test/published-subpath-console.pin.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ const RETIRED_RUNTIME_NAMES = [
175175
* them off the published subpath. Publishing one is the same deliberate act as
176176
* re-admitting a retired name.
177177
*/
178-
const INTERNAL_SINCE_RETIREMENT = ['formatConsoleDistMissingWarning'];
178+
const INTERNAL_SINCE_RETIREMENT = ['createConsoleNotBuiltPlugin', 'formatConsoleDistMissingWarning'];
179179

180180
/**
181181
* Every subpath the published package resolves, in full. A subpath removed here

0 commit comments

Comments
 (0)