Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/26130-dev-compiles-at-boot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/cli': patch
---

fix(cli): `os dev` compiles the config before it boots, so the served connector `baseUrl` is the one dispatch calls

Clause-②: no
Metadata-protocol: none

- **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.
- **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.
- **`--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.
- `os serve` and `os start` are unchanged.
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32)
| `--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 |
| `--key <path>` | — | Path to the private key (PEM) for `--cert`. Required with `--cert` |
| `--ui` | — | Force Console UI on (already on by default in dev) |
| `--compile` | — | Force compiling `objectstack.config.ts` → `dist/objectstack.json` before starting (auto when the artifact is missing; ignored with `--artifact`) |
| `--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` |
| `--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 |
| `--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` |
| `-v, --verbose` | — | Verbose output |
Expand Down
5 changes: 4 additions & 1 deletion examples/app-showcase/src/system/self-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@
* **When this is read matters.** These are ordinary Node modules evaluated at
* config load, so the read happens in whichever process loads
* objectstack.config.ts. On the `os dev` / `os serve` path that is the serving
* process itself, so the value follows the live environment. On the
* process itself, so the value follows the live environment; `os dev` also
* recompiles `dist/objectstack.json` in that environment before it boots, so
* the served connector metadata carries the same value (#26130), except under
* `os dev --no-compile`, which serves the metadata as last built. On the
* artifact-only path (`os build` once, then `os start --artifact`) the
* connector metadata is serialized into `dist/objectstack.json`, so the value
* is frozen at BUILD time — set the env for the build, not just the boot.
Expand Down
64 changes: 57 additions & 7 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,48 @@ export function forwardSeedSettledToParent(msg: unknown): boolean {
return true;
}

/**
* Whether this `os dev` boot compiles the cwd config into the artifact it is
* about to serve, before the server starts (#26130).
*
* Yes by default whenever the boot serves an artifact this command builds and
* there is a config to build it from. The reason is the dev boot's two copies
* of one stack. A host config's `os dev` boot serves the stack from TWO places:
* the config MODULE, evaluated in the `serve` child (the AppPlugin wrap, whose
* registry copy is what a provider-bound connector is materialized from, so
* what dispatch calls), and the compiled artifact (the dev artifact door, whose
* copy is what `/api/v1/meta/<type>` serves). A config that reads the
* environment at module evaluation, as `examples/app-showcase`'s self URL reads
* `OS_PORT`, gives the module copy THIS boot's value and the artifact copy the
* value of whichever environment last compiled it. Measured on the showcase
* 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 materialized connector called 41302.
*
* Compiling here, in this process's environment (the `serve` child inherits
* the same one), makes both copies one evaluation's answer, so nothing has to
* restate which variables a config reads or in what order. The watch loop's
* recompiles already run in that environment, so the two stay one answer for
* the whole session.
*
* `--no-compile` keeps the old shortcut, booting the artifact as built (and
* the startup staleness warning says when it is older than the sources); a
* missing artifact is still compiled, since there is nothing else to serve. A
* pinned artifact (`--artifact`, `OS_ARTIFACT_URL`, a remote
* `OS_ARTIFACT_PATH`) is never rebuilt. With no config and a missing artifact
* the answer is yes, so the caller refuses there with the one remedy line.
*/
export function devCompilesAtBoot(opts: {
compile: boolean;
pinned: boolean;
configExists: boolean;
artifactExists: boolean;
}): boolean {
if (opts.pinned) return false;
if (!opts.artifactExists) return true;
return opts.compile && opts.configExists;
}

/**
* Whether this `os dev` boot runs the watch-recompile loop (#20681).
*
Expand Down Expand Up @@ -332,9 +374,11 @@ export default class Dev extends Command {
preset: Flags.string({
description: 'Plugin tier preset forwarded to `serve`: minimal | default | full',
}),
// Default ON (#26130): see `devCompilesAtBoot` for why a dev boot over an
// artifact it builds always rebuilds it first.
compile: Flags.boolean({
description: 'Compile objectstack.config.ts to dist/objectstack.json before starting (auto if artifact missing). Ignored when --artifact is set.',
default: false,
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.',
default: true,
allowNo: true,
}),
restart: Flags.boolean({
Expand Down Expand Up @@ -472,9 +516,15 @@ export default class Dev extends Command {
printKV('Config', configPath, '📂');
}

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

// ── Startup staleness warning (#5148 startup variant) ───────────
// Without --compile, `os dev` boots whatever dist/objectstack.json
// holds. When the sources are NEWER than the artifact (edited while
// With --no-compile, `os dev` boots whatever dist/objectstack.json
// holds (#26130 made compiling the default). When the sources are NEWER than the artifact (edited while
// the server was down, stale checkout), the boot silently serves the
// old build — measured in #5148: the config had gained a capability
// and dev still printed `Plugins: 38 loaded` until a manual build.
Expand Down
59 changes: 59 additions & 0 deletions packages/cli/test/dev-compile-at-boot.pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* PIN (#26130) — a bare `os dev` compiles the cwd config into the artifact it
* serves before the server starts, so the served metadata and the config
* module the `serve` child evaluates are one environment's answer.
*
* A PARSE-AND-DECIDE fact, the same instrument `dev-no-watch.pin.test.ts` uses:
* oclif's own parser over the command's real declarations, fed into the one
* decision the boot reads (`devCompilesAtBoot`). What the decision buys at the
* public door (`/api/v1/meta/connector/NAME` agreeing with the materialized
* connector on an `OS_PORT` boot) is measured on a real `os dev` boot in
* `dev-connector-env-parity.integration.test.ts`.
*/

import { describe, it, expect } from 'vitest';
import { Parser } from '@oclif/core';
import Dev, { devCompilesAtBoot } from '../src/commands/dev.js';

const parseDev = (argv: string[]) =>
Parser.parse(argv, { flags: Dev.flags, args: Dev.args, strict: true });

/** The decision for a cwd holding a config and an already-built artifact, from parsed argv. */
async function overBuiltArtifact(argv: string[]): Promise<boolean> {
const { flags } = await parseDev(argv);
return devCompilesAtBoot({
compile: flags.compile,
pinned: Boolean(flags.artifact),
configExists: true,
artifactExists: true,
});
}

describe('`os dev` compiles before it boots (#26130)', () => {
it('bare `os dev` over an already-built artifact compiles it again', async () => {
expect(await overBuiltArtifact([])).toBe(true);
});

it('`--no-compile` boots the built artifact as it is', async () => {
expect(await overBuiltArtifact(['--no-compile'])).toBe(false);
});

it('a pinned `--artifact` is never rebuilt, whatever `--compile` says', async () => {
expect(await overBuiltArtifact(['--artifact', 'elsewhere/objectstack.json'])).toBe(false);
expect(await overBuiltArtifact(['--artifact', 'elsewhere/objectstack.json', '--compile'])).toBe(false);
});

it('a missing artifact is compiled even under `--no-compile`', async () => {
const { flags } = await parseDev(['--no-compile']);
expect(devCompilesAtBoot({ compile: flags.compile, pinned: false, configExists: true, artifactExists: false })).toBe(true);
});

it('with no config, a built artifact boots as it is and a missing one reaches the refusal', async () => {
const { flags } = await parseDev([]);
expect(devCompilesAtBoot({ compile: flags.compile, pinned: false, configExists: false, artifactExists: true })).toBe(false);
// `true` here is what routes the boot to the "nothing to start" refusal.
expect(devCompilesAtBoot({ compile: flags.compile, pinned: false, configExists: false, artifactExists: false })).toBe(true);
});
});
Loading
Loading