Skip to content

Commit 8b7253c

Browse files
fix(cli): os dev compiles the config before it boots, so served connector metadata matches dispatch (#26240)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X8JqqR7yTqyhz4Qfad5wYWK)_ Fixes #26130 Clause-②: no Metadata-protocol: none ## What was wrong A host config's `os dev` boot holds two copies of one stack. The config module, evaluated in the `serve` child, feeds the AppPlugin wrap; provider-bound connectors are materialized from that registry copy, so it is what dispatch calls. The compiled `dist/objectstack.json` feeds the dev artifact door, and its copy is what `/api/v1/meta/connector/NAME` serves. `examples/app-showcase` builds its connectors' `baseUrl` from `OS_PORT` at module evaluation, and `os dev` booted whatever artifact was already on disk, so the two copies disagreed whenever the artifact had been built in another environment. Measured on `examples/app-showcase` at this branch's base (`c7aa9cde`), `OS_PORT=41302`, the artifact compiled with no `OS_PORT` (`baseUrl: http://127.0.0.1:3000`), nothing listening on 3000. "dispatch" is `POST /api/v1/automation/showcase_declarative_connector_ping/trigger`, whose one node calls `showcase_status_api.request` for `GET /api/v1/health`. | boot | `GET /api/v1/meta/connector/showcase_status_api` → `providerConfig.baseUrl` | dispatch | |---|---|---| | `os dev --no-compile` (= a bare `os dev` before this PR) | `http://127.0.0.1:3000` | 200, `status: ok` (reached 41302) | | `os dev` (this PR) | `http://127.0.0.1:41302` | 200, `status: ok` | | `os start` (production, config joins the boot) | `http://127.0.0.1:41302` | 200, `status: ok` | | `os serve objectstack.config.ts` (production) | `http://127.0.0.1:41302` | 200, `status: ok` | | `os serve --dev objectstack.config.ts` | `http://127.0.0.1:3000` | 200, `status: ok` (reached 41302) | `os start` and production `os serve` compose no artifact door, so they serve the module's copy and already agree; they are unchanged. `os serve --dev` has the same two copies; see Acceptance notes. ## The fix Of the two routes the order named, this takes (b): the dev boot compiles instead of reading the artifact as found. `--compile` is now on by default, so `os dev` compiles `objectstack.config.ts` into the artifact it serves, in its own environment, before the `serve` child starts. Both copies are then one evaluation's answer, and nothing restates which variables a config reads or in what order (no second copy of the port/URL precedence). The watch loop's recompiles already ran in that environment, so the two stay one answer for the session. Route (a), re-resolving env-derived connector config at an artifact boot, has nothing to re-resolve from: the artifact holds the value, not the expression that produced it. Carrying the expression would need a new `packages/spec` form for env-derived values, which the rulings put out of reach, so it is not proposed. - `packages/cli/src/commands/dev.ts`: the decision is one exported function, `devCompilesAtBoot`. A pinned artifact (`--artifact`, `OS_ARTIFACT_URL`, a remote `OS_ARTIFACT_PATH`) is never rebuilt. `--no-compile` keeps the old shortcut and the startup staleness warning. A missing artifact is still compiled. With no config, an existing artifact boots as before. - Cost: one compile per dev start. Measured on the showcase: 6.5 s for `os compile` (737 KB artifact). - `content/docs/deployment/cli.mdx`: the `--compile` row now says default on and names `--no-compile`. This file is outside the claim's file surface, but the docs row would be false without it. - `scripts/check-cli-test-child-env.mjs`: one census entry for the new test, which spawns the built entry (see Gates). - `examples/app-showcase/src/system/self-url.ts`: header comment only. Its `os dev` claim ("follows the live environment") is now true for the served metadata too, and it names the `--no-compile` exception. ## Tests At head `ac8dadabc` (the three dev test files rerun there: `Test Files 3 passed (3)`, `Tests 15 passed (15)`; the unit tier and typecheck ran at `7f3d5bee`, whose only difference from the head is the census line in `scripts/check-cli-test-child-env.mjs`): - New `packages/cli/test/dev-connector-env-parity.integration.test.ts` (integration tier, built entry). It uses a minimal host config with one provider-bound connector whose `baseUrl` is built from `OS_PORT` the way `self-url.ts` builds it, and a host plugin whose provider factory records the `providerConfig` it was handed. A prebuilt artifact carries `:3000`. One `os dev` boot with `OS_PORT=41302` checks that the served `baseUrl` and the materialized one both read `http://127.0.0.1:41302`. Result: `Tests 2 passed (2)`. - New `packages/cli/test/dev-compile-at-boot.pin.test.ts` (unit tier) checks oclif's parse plus `devCompilesAtBoot` for each case: bare, `--no-compile`, pinned, missing artifact, no config. It passed with `dev-no-watch.pin.test.ts`. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: `Tests 1 failed | 4858 passed`. The one failure was `boot-preparation-parity.test.ts`, which timed out at 5934 ms against its 5000 ms limit while the gate sweep shared the box. Run alone it answered `Tests 37 passed (37)`. It reads `serve.ts`, which this PR does not touch. - `pnpm --filter @objectstack/cli typecheck`: exit 0. - Ablation, run once: `devCompilesAtBoot`'s default arm was mutated to `false` through `scripts/ablation-replace.mjs`, the CLI was rebuilt, and `ablation-dist-preflight` found the marker in `packages/cli/dist/commands/dev.js`. The integration test then failed with `expected 'http://127.0.0.1:3000' to be 'http://127.0.0.1:41302'` on the served leg, while the materialized leg stayed green, which is the card's defect exactly. The source was restored (blob equals HEAD, `git diff HEAD` empty), the CLI rebuilt, and the preflight `--absent` check passed. ## Gates Local subset, `node scripts/pm/dispatch-gates.mjs --commands` at `ac8dadabc`: 120 families derived. `--ran` reconciliation: 118 run with exit 0, 2 NOT MEASURED, 0 unrun. - NOT MEASURED, reason: prerequisite not met (exit 3), because both read a full-repo build that this container did not make: `pnpm --filter @objectstack/spec run check:skill-examples`, `pnpm check:dual-build-cjs-loads`. CI runs both. - `pnpm check:cli-test-child-env` went red on the first sweep because its census pins the exact set of test files that spawn the built `bin/run.js`, and the new integration test joins that set. The census in `scripts/check-cli-test-child-env.mjs` now names the file with a one-line reason, the same form as the other members. The gate is green, and its self-test passed all 152 cases. The script's one other `git grep` hit is a comment in `serve-node-env-production-default.e2e.test.ts`, which no vitest project selects. - `node scripts/check-plugin-teardown-shape.mjs --self-test` exited 3 on the shallow clone. After `git fetch origin 621a487…` (its positive control), it passed 48 cases. - `node scripts/check-nul-bytes.mjs`: exit 0. `pnpm exec eslint --no-inline-config` on the four touched source files: exit 0. Neither touched package has a `lint` script. - Every family CI runs is cited from this PR's check-runs in the report, not re-run here. ## Acceptance notes - **`os serve --dev objectstack.config.ts` has the same disagreement** (row 5 above): it composes the dev artifact door and does not compile. This PR does not widen to it, per the ruling. It is reported to the seat. - **`os dev -p 41302` with no `OS_PORT` in the environment**: `os dev` forwards the port to `serve` as `--port` and exports no `OS_PORT`, so the showcase's config resolves `:3000` in both copies. They agree, but dispatch fails with `connector_action(showcase_status_api.request) failed: fetch failed` (measured, `FLOW_FAILED` 400). `self-url.ts` says it follows "the same names, in the same order, that the CLI itself reads to choose the listen port", and the flag is not one of them. This is #7538's symptom on the flag path, not this card. It is reported to the seat. - `os dev` now rewrites `dist/objectstack.json` at every start. An `os start` that later boots that file alone serves the values of the last dev boot's environment. That was already true after any watch recompile, and `self-url.ts` already says to set the environment for the build. #26088 remains open; this PR does not address it. --- _Generated by [Claude Code](https://claude.ai/code/session_01Cj99V2CpVvyWDjQu1ndoBS)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Cj99V2CpVvyWDjQu1ndoBS --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1eebbc0 commit 8b7253c

7 files changed

Lines changed: 448 additions & 10 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os dev` compiles the config before it boots, so the served connector `baseUrl` is the one dispatch calls
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** `os dev` booted whatever `dist/objectstack.json` already held, and compiled only when it was missing or `--compile` was passed. A host config's dev boot serves the stack from two copies: the config module the server evaluates (what a provider-bound connector is materialized from, so what dispatch calls) and the artifact (what `/api/v1/meta/<type>` serves). A config value read from the environment, such as `examples/app-showcase`'s self URL built from `OS_PORT`, then differed between them. With `OS_PORT=41302` over an artifact built without it, `GET /api/v1/meta/connector/showcase_status_api` served `http://127.0.0.1:3000` while the connector called port 41302.
11+
- **Now.** `--compile` is on by default: `os dev` compiles `objectstack.config.ts` into the artifact it serves, in its own environment, before the server starts, so both copies come from one evaluation. The watch loop already recompiled in that environment.
12+
- **`--no-compile`** boots the existing artifact as built, as a bare `os dev` did before (it is still compiled when missing, and the startup warning still says when it is older than the sources). A pinned artifact (`--artifact`, `OS_ARTIFACT_URL`, a remote `OS_ARTIFACT_PATH`) is never rebuilt. With no `objectstack.config.ts`, an existing artifact boots as before.
13+
- `os serve` and `os start` are unchanged.

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32)
192192
| `--cert <path>` | — | Path to a TLS certificate (PEM). With `--key`, terminate TLS in the dev process and serve `https://localhost:<port>`. Bring your own certificate — none is generated |
193193
| `--key <path>` | — | Path to the private key (PEM) for `--cert`. Required with `--cert` |
194194
| `--ui` | — | Force Console UI on (already on by default in dev) |
195-
| `--compile` | — | Force compiling `objectstack.config.ts` → `dist/objectstack.json` before starting (auto when the artifact is missing; ignored with `--artifact`) |
195+
| `--compile` / `--no-compile` | — | Compile `objectstack.config.ts` → `dist/objectstack.json` before starting, under this boot's environment — default on, so a config value read from the environment (such as a self URL built from `OS_PORT`) is the same in the served metadata and in what runs. `--no-compile` boots the existing artifact as built (still compiled when it is missing). Ignored with `--artifact` |
196196
| `--fresh` | — | Ephemeral `OS_HOME` in the OS tempdir (clean DB, uploads root, and other `OS_HOME`-keyed state), auto-deleted on exit; implies `--seed-admin`. See the scope note below |
197197
| `--seed-admin` / `--no-seed-admin` | — | Seed a dev admin (`admin@objectos.ai` / `admin123`) on an empty DB — default on; override with `--admin-email` / `--admin-password` |
198198
| `-v, --verbose` | — | Verbose output |

‎examples/app-showcase/src/system/self-url.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@
3232
* **When this is read matters.** These are ordinary Node modules evaluated at
3333
* config load, so the read happens in whichever process loads
3434
* objectstack.config.ts. On the `os dev` / `os serve` path that is the serving
35-
* process itself, so the value follows the live environment. On the
35+
* process itself, so the value follows the live environment; `os dev` also
36+
* recompiles `dist/objectstack.json` in that environment before it boots, so
37+
* the served connector metadata carries the same value (#26130), except under
38+
* `os dev --no-compile`, which serves the metadata as last built. On the
3639
* artifact-only path (`os build` once, then `os start --artifact`) the
3740
* connector metadata is serialized into `dist/objectstack.json`, so the value
3841
* is frozen at BUILD time — set the env for the build, not just the boot.

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

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,48 @@ export function forwardSeedSettledToParent(msg: unknown): boolean {
246246
return true;
247247
}
248248

249+
/**
250+
* Whether this `os dev` boot compiles the cwd config into the artifact it is
251+
* about to serve, before the server starts (#26130).
252+
*
253+
* Yes by default whenever the boot serves an artifact this command builds and
254+
* there is a config to build it from. The reason is the dev boot's two copies
255+
* of one stack. A host config's `os dev` boot serves the stack from TWO places:
256+
* the config MODULE, evaluated in the `serve` child (the AppPlugin wrap, whose
257+
* registry copy is what a provider-bound connector is materialized from, so
258+
* what dispatch calls), and the compiled artifact (the dev artifact door, whose
259+
* copy is what `/api/v1/meta/<type>` serves). A config that reads the
260+
* environment at module evaluation, as `examples/app-showcase`'s self URL reads
261+
* `OS_PORT`, gives the module copy THIS boot's value and the artifact copy the
262+
* value of whichever environment last compiled it. Measured on the showcase
263+
* with `OS_PORT=41302` over an artifact built without it:
264+
* `GET /api/v1/meta/connector/showcase_status_api` served
265+
* `http://127.0.0.1:3000` while the materialized connector called 41302.
266+
*
267+
* Compiling here, in this process's environment (the `serve` child inherits
268+
* the same one), makes both copies one evaluation's answer, so nothing has to
269+
* restate which variables a config reads or in what order. The watch loop's
270+
* recompiles already run in that environment, so the two stay one answer for
271+
* the whole session.
272+
*
273+
* `--no-compile` keeps the old shortcut, booting the artifact as built (and
274+
* the startup staleness warning says when it is older than the sources); a
275+
* missing artifact is still compiled, since there is nothing else to serve. A
276+
* pinned artifact (`--artifact`, `OS_ARTIFACT_URL`, a remote
277+
* `OS_ARTIFACT_PATH`) is never rebuilt. With no config and a missing artifact
278+
* the answer is yes, so the caller refuses there with the one remedy line.
279+
*/
280+
export function devCompilesAtBoot(opts: {
281+
compile: boolean;
282+
pinned: boolean;
283+
configExists: boolean;
284+
artifactExists: boolean;
285+
}): boolean {
286+
if (opts.pinned) return false;
287+
if (!opts.artifactExists) return true;
288+
return opts.compile && opts.configExists;
289+
}
290+
249291
/**
250292
* Whether this `os dev` boot runs the watch-recompile loop (#20681).
251293
*
@@ -332,9 +374,11 @@ export default class Dev extends Command {
332374
preset: Flags.string({
333375
description: 'Plugin tier preset forwarded to `serve`: minimal | default | full',
334376
}),
377+
// Default ON (#26130): see `devCompilesAtBoot` for why a dev boot over an
378+
// artifact it builds always rebuilds it first.
335379
compile: Flags.boolean({
336-
description: 'Compile objectstack.config.ts to dist/objectstack.json before starting (auto if artifact missing). Ignored when --artifact is set.',
337-
default: false,
380+
description: 'Compile objectstack.config.ts to dist/objectstack.json before starting, under this boot\'s environment (default: on). --no-compile boots the existing artifact as built (still compiled when it is missing). Ignored when --artifact is set.',
381+
default: true,
338382
allowNo: true,
339383
}),
340384
restart: Flags.boolean({
@@ -472,9 +516,15 @@ export default class Dev extends Command {
472516
printKV('Config', configPath, '📂');
473517
}
474518

475-
// Auto-compile only when we have a config AND the boot's artifact is one
476-
// this command builds. A pinned artifact means "use this, don't rebuild".
477-
const needsCompile = !pinnedArtifact && (flags.compile || !fs.existsSync(artifactPath));
519+
// Compile only when the boot's artifact is one this command builds — a
520+
// pinned artifact means "use this, don't rebuild" — and then by default
521+
// (#26130, `devCompilesAtBoot`).
522+
const needsCompile = devCompilesAtBoot({
523+
compile: flags.compile,
524+
pinned: Boolean(pinnedArtifact),
525+
configExists,
526+
artifactExists: fs.existsSync(artifactPath),
527+
});
478528
if (needsCompile) {
479529
if (!configExists) {
480530
printError('No objectstack.config.ts and no --artifact given — nothing to start.');
@@ -531,8 +581,8 @@ export default class Dev extends Command {
531581
}
532582

533583
// ── Startup staleness warning (#5148 startup variant) ───────────
534-
// Without --compile, `os dev` boots whatever dist/objectstack.json
535-
// holds. When the sources are NEWER than the artifact (edited while
584+
// With --no-compile, `os dev` boots whatever dist/objectstack.json
585+
// holds (#26130 made compiling the default). When the sources are NEWER than the artifact (edited while
536586
// the server was down, stale checkout), the boot silently serves the
537587
// old build — measured in #5148: the config had gained a capability
538588
// and dev still printed `Plugins: 38 loaded` until a manual build.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN (#26130) — a bare `os dev` compiles the cwd config into the artifact it
5+
* serves before the server starts, so the served metadata and the config
6+
* module the `serve` child evaluates are one environment's answer.
7+
*
8+
* A PARSE-AND-DECIDE fact, the same instrument `dev-no-watch.pin.test.ts` uses:
9+
* oclif's own parser over the command's real declarations, fed into the one
10+
* decision the boot reads (`devCompilesAtBoot`). What the decision buys at the
11+
* public door (`/api/v1/meta/connector/NAME` agreeing with the materialized
12+
* connector on an `OS_PORT` boot) is measured on a real `os dev` boot in
13+
* `dev-connector-env-parity.integration.test.ts`.
14+
*/
15+
16+
import { describe, it, expect } from 'vitest';
17+
import { Parser } from '@oclif/core';
18+
import Dev, { devCompilesAtBoot } from '../src/commands/dev.js';
19+
20+
const parseDev = (argv: string[]) =>
21+
Parser.parse(argv, { flags: Dev.flags, args: Dev.args, strict: true });
22+
23+
/** The decision for a cwd holding a config and an already-built artifact, from parsed argv. */
24+
async function overBuiltArtifact(argv: string[]): Promise<boolean> {
25+
const { flags } = await parseDev(argv);
26+
return devCompilesAtBoot({
27+
compile: flags.compile,
28+
pinned: Boolean(flags.artifact),
29+
configExists: true,
30+
artifactExists: true,
31+
});
32+
}
33+
34+
describe('`os dev` compiles before it boots (#26130)', () => {
35+
it('bare `os dev` over an already-built artifact compiles it again', async () => {
36+
expect(await overBuiltArtifact([])).toBe(true);
37+
});
38+
39+
it('`--no-compile` boots the built artifact as it is', async () => {
40+
expect(await overBuiltArtifact(['--no-compile'])).toBe(false);
41+
});
42+
43+
it('a pinned `--artifact` is never rebuilt, whatever `--compile` says', async () => {
44+
expect(await overBuiltArtifact(['--artifact', 'elsewhere/objectstack.json'])).toBe(false);
45+
expect(await overBuiltArtifact(['--artifact', 'elsewhere/objectstack.json', '--compile'])).toBe(false);
46+
});
47+
48+
it('a missing artifact is compiled even under `--no-compile`', async () => {
49+
const { flags } = await parseDev(['--no-compile']);
50+
expect(devCompilesAtBoot({ compile: flags.compile, pinned: false, configExists: true, artifactExists: false })).toBe(true);
51+
});
52+
53+
it('with no config, a built artifact boots as it is and a missing one reaches the refusal', async () => {
54+
const { flags } = await parseDev([]);
55+
expect(devCompilesAtBoot({ compile: flags.compile, pinned: false, configExists: false, artifactExists: true })).toBe(false);
56+
// `true` here is what routes the boot to the "nothing to start" refusal.
57+
expect(devCompilesAtBoot({ compile: flags.compile, pinned: false, configExists: false, artifactExists: false })).toBe(true);
58+
});
59+
});

0 commit comments

Comments
 (0)