From f3b2f53b25d87d071a94986c34e54f1253364bf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:07:17 +0000 Subject: [PATCH 1/3] fix(cli): os dev compiles the config before it boots, so served connector metadata matches dispatch On a host config's os dev boot the served metadata (dev artifact door) and the materialized connectors (the config module) are two copies; an env-derived value differed between them whenever the artifact had been built in another environment. Compile by default (--no-compile keeps the old shortcut) so both come from one evaluation. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Cj99V2CpVvyWDjQu1ndoBS --- .changeset/26130-dev-compiles-at-boot.md | 13 + content/docs/deployment/cli.mdx | 2 +- examples/app-showcase/src/system/self-url.ts | 5 +- packages/cli/src/commands/dev.ts | 64 +++- .../cli/test/dev-compile-at-boot.pin.test.ts | 59 ++++ ...v-connector-env-parity.integration.test.ts | 299 ++++++++++++++++++ 6 files changed, 433 insertions(+), 9 deletions(-) create mode 100644 .changeset/26130-dev-compiles-at-boot.md create mode 100644 packages/cli/test/dev-compile-at-boot.pin.test.ts create mode 100644 packages/cli/test/dev-connector-env-parity.integration.test.ts diff --git a/.changeset/26130-dev-compiles-at-boot.md b/.changeset/26130-dev-compiles-at-boot.md new file mode 100644 index 00000000000..441017ea5c0 --- /dev/null +++ b/.changeset/26130-dev-compiles-at-boot.md @@ -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/` 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. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index e462c172f19..d1efceaf2f3 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -192,7 +192,7 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32) | `--cert ` | — | Path to a TLS certificate (PEM). With `--key`, terminate TLS in the dev process and serve `https://localhost:`. Bring your own certificate — none is generated | | `--key ` | — | 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 | diff --git a/examples/app-showcase/src/system/self-url.ts b/examples/app-showcase/src/system/self-url.ts index 7e17c368675..0e6b78185f1 100644 --- a/examples/app-showcase/src/system/self-url.ts +++ b/examples/app-showcase/src/system/self-url.ts @@ -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. diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 2a6d160ab41..7e10fb38230 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -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/` 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). * @@ -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({ @@ -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.'); @@ -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. diff --git a/packages/cli/test/dev-compile-at-boot.pin.test.ts b/packages/cli/test/dev-compile-at-boot.pin.test.ts new file mode 100644 index 00000000000..30364779534 --- /dev/null +++ b/packages/cli/test/dev-compile-at-boot.pin.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/cli/test/dev-connector-env-parity.integration.test.ts b/packages/cli/test/dev-connector-env-parity.integration.test.ts new file mode 100644 index 00000000000..4837e4d7532 --- /dev/null +++ b/packages/cli/test/dev-connector-env-parity.integration.test.ts @@ -0,0 +1,299 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #26130 — on an `os dev` boot, a connector value the config reads from the + * environment is the same in the served metadata and in what dispatch calls. + * + * ## The defect, measured at the public door before the fix + * + * A host config's `os dev` boot holds two copies of one stack: the config + * MODULE the `serve` child evaluates (the AppPlugin wrap; provider-bound + * connectors are materialized from its registry copy, so it is what dispatch + * calls) and the compiled artifact (the dev artifact door; 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 `dist/objectstack.json` already held. Measured on the showcase, + * `os dev` with `OS_PORT=41302` over an artifact built without it: + * + * | reader | before | after | + * |------------------------------------------------------|-----------------|-----------------| + * | `GET /api/v1/meta/connector/showcase_status_api` | `…:3000` | `…:41302` | + * | the materialized connector (`connector_action`) | `…:41302` | `…:41302` | + * + * The fix is in `os dev`, not in the config: it compiles the config into the + * artifact in its own environment before the server starts + * (`devCompilesAtBoot`), so both copies are one evaluation's answer. + * + * ## What this pins + * + * The same shape on a minimal host config: one provider-bound connector whose + * `providerConfig.baseUrl` is built from `OS_PORT` the way the showcase's + * `self-url.ts` builds it, a host plugin contributing that connector's + * provider (its factory records the `providerConfig` it was handed, which is + * what dispatch calls), and a `dist/objectstack.json` standing for a build + * whose environment had no `OS_PORT`. One `os dev` boot with `OS_PORT` set; + * the served `baseUrl` and the materialized one must both read that port. + * + * ## Spawn shape + * + * `bin/run.js` with `NODE_ENV` unset, the shipped entry resolving commands from + * `dist/` (`requireBuiltCli`), in its own process group (`dev` supervises a + * `serve` grandchild). The listen port is kernel-assigned (`-p 0`), which the + * flag gives the child; `OS_PORT` is only the value the config reads, exactly + * as an isolated-port boot hands it to a config. The boot runs in `beforeAll` + * and every `it` reads what it recorded. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + childEnv, + E2E_SECRET_KEY, + KERNEL_ASSIGNED_PORT, + probeThroughChild, + readKernelAssignedPort, + requireBuiltCli, + RUN_JS_RESOLVES_FROM_DIST, +} from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +/** `bin/run.js` — the SHIPPED entrypoint. */ +const CLI = resolve(HERE, '../bin/run.js'); + +/** The banner's tail — every row above it has printed. */ +const READY = /Press Ctrl\+C to stop/; +const BOOT_TIMEOUT_MS = 180_000; + +/** The port the boot's environment names, other than the 3000 default. */ +const LIVE_PORT = '41302'; +const LIVE_URL = `http://127.0.0.1:${LIVE_PORT}`; +const BUILD_URL = 'http://127.0.0.1:3000'; +const CONNECTOR = 'fxp_self'; +/** Where the fixture provider's factory records the `providerConfig` it was handed. */ +const MATERIALIZED_FILE = 'materialized-provider-config.json'; +/** The development dev-admin seed — the operator on this boot. */ +const EMAIL = 'admin@objectos.ai'; +const PASSWORD = 'admin123'; + +const MANIFEST = { id: 'com.example.fxp', namespace: 'fxp', version: '1.0.0', type: 'app', name: 'Fxp' }; + +/** The connector as the config declares it, given the base URL its environment yields. */ +const connector = (baseUrl: string) => ({ + name: CONNECTOR, + label: 'Self', + type: 'api', + provider: 'fxp', + providerConfig: { baseUrl }, + auth: { type: 'none' }, +}); + +/** + * The host config. Its `baseUrl` is read at module evaluation, the way + * `examples/app-showcase/src/system/self-url.ts` reads it; its one plugin + * contributes the `fxp` provider in `init()` (before the automation service + * materializes in `start()`), and the factory writes what it was handed to + * the project directory, the `serve` child's cwd. + */ +const CONFIG_SOURCE = `import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { defineStack } from '@objectstack/spec'; + +const selfUrl = \`http://127.0.0.1:\${process.env.OS_PORT?.trim() || '3000'}\`; + +export default defineStack({ + manifest: ${JSON.stringify(MANIFEST)}, + requires: ['automation'], + connectors: [${JSON.stringify(connector('__SELF_URL__')).replace('"__SELF_URL__"', 'selfUrl')}], + plugins: [{ + name: 'com.example.fxp.provider', + version: '1.0.0', + dependencies: ['com.objectstack.service-automation'], + init: async (ctx: any) => { + ctx.getService('automation').registerConnectorProvider('fxp', (c: any) => { + writeFileSync(join(process.cwd(), '${MATERIALIZED_FILE}'), JSON.stringify(c.providerConfig)); + return { def: { name: c.name, label: c.label, type: c.type, actions: [] }, handlers: {} }; + }); + }, + }], +}); +`; + +interface Reading { + served?: unknown; + materialized?: unknown; + error?: Error; +} + +const groups: ChildProcess[] = []; +let dir: string; +let reading: Reading = {}; + +interface Live { + child: ChildProcess; + base: string; + output: () => string; +} + +function bootDev(env: Record): Promise { + return new Promise((resolveBoot, rejectBoot) => { + const child = spawn( + process.execPath, + [CLI, 'dev', '--no-watch', '--no-restart', '-d', 'file:dev.db', '-p', KERNEL_ASSIGNED_PORT], + { + cwd: dir, + // `childEnv`, never a bare `...process.env` — see its header. `NODE_ENV` + // unset, so the built entry resolves its commands from `dist/`. + env: childEnv({ + NO_COLOR: '1', + OS_CLOUD_URL: 'off', + OS_DISABLE_CONSOLE: '1', + OS_SECRET_KEY: E2E_SECRET_KEY, + OS_LOG_LEVEL: 'warn', + OS_DATABASE_URL: undefined, + OS_ARTIFACT_URL: undefined, + OS_ARTIFACT_PATH: undefined, + OS_MODE: undefined, + NODE_ENV: undefined, + PORT: undefined, + ...env, + }), + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }, + ); + groups.push(child); + let out = ''; + let settled = false; + const settle = (verdict: Error | number) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out }); + }; + const timer = setTimeout( + () => settle(new Error(`os dev never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), + BOOT_TIMEOUT_MS, + ); + const onData = (d: unknown) => { + out += String(d); + // The child is the authority on the port it bound: a kernel-assigned one (#24342). + if (READY.test(out)) settle(readKernelAssignedPort(out, 'os dev')); + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.on('exit', (code) => + settle(new Error(`os dev exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + ); + }); +} + +async function stopGroup(child: ChildProcess): Promise { + if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return; + await new Promise((done) => { + const give = setTimeout(() => { + try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ } + done(); + }, 15_000); + child.once('exit', () => { clearTimeout(give); done(); }); + try { process.kill(-child.pid!, 'SIGTERM'); } catch { clearTimeout(give); done(); } + }); +} + +/** One exchange, attributed to the child if the transport fails. ⛔ No assertion inside it. */ +function http(live: Live, method: string, path: string, token = '', body?: unknown) { + return probeThroughChild( + { + child: live.child, + transcript: () => `\n--- child output ---\n${live.output().slice(-4000)}`, + label: 'dev-connector-env-parity', + what: `${method} ${path}`, + }, + async () => { + const r = await fetch(`${live.base}${path}`, { + method, + headers: { + origin: live.base, + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const text = await r.text(); + let parsed: any = text; + try { parsed = JSON.parse(text); } catch { /* keep the text */ } + return { status: r.status, body: parsed }; + }, + ); +} + +/** The `baseUrl` a `/meta/connector/NAME` answer carries, whichever envelope it comes in. */ +function servedBaseUrl(body: any): unknown { + const item = body?.data?.item ?? body?.item ?? body?.data ?? body; + return item?.providerConfig?.baseUrl; +} + +beforeAll(async () => { + requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); + dir = mkdtempSync(join(tmpdir(), 'os-26130-dev-')); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fxp-host', private: true }), 'utf8'); + writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG_SOURCE, 'utf8'); + linkSpec(dir); + // The artifact a build with no `OS_PORT` in its environment left behind. + mkdirSync(join(dir, 'dist'), { recursive: true }); + writeFileSync( + join(dir, 'dist', 'objectstack.json'), + JSON.stringify({ manifest: MANIFEST, requires: ['automation'], connectors: [connector(BUILD_URL)] }, null, 2), + 'utf8', + ); + + let live: Live | undefined; + try { + live = await bootDev({ OS_PORT: LIVE_PORT }); + const signIn = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email: EMAIL, password: PASSWORD }); + const token = signIn.body?.token; + if (signIn.status !== 200 || typeof token !== 'string') { + throw new Error(`sign-in answered ${signIn.status}: ${JSON.stringify(signIn.body)}\n--- output ---\n${live.output().slice(-3000)}`); + } + const meta = await http(live, 'GET', `/api/v1/meta/connector/${CONNECTOR}`, token); + if (meta.status !== 200) { + throw new Error(`GET /api/v1/meta/connector/${CONNECTOR} answered ${meta.status}: ${JSON.stringify(meta.body)}`); + } + const materializedPath = join(dir, MATERIALIZED_FILE); + reading = { + served: servedBaseUrl(meta.body), + materialized: existsSync(materializedPath) + ? (JSON.parse(readFileSync(materializedPath, 'utf8')) as { baseUrl?: unknown }).baseUrl + : `(the fxp provider never materialized ${CONNECTOR})\n--- output ---\n${live.output().slice(-3000)}`, + }; + } catch (error) { + reading = { error: error instanceof Error ? error : new Error(String(error)) }; + } finally { + if (live) await stopGroup(live.child); + } +}, BOOT_TIMEOUT_MS + 60_000); + +afterAll(async () => { + for (const child of groups) await stopGroup(child); + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +}, 60_000); + +function recorded(): Required> { + if (reading.error) throw reading.error; + return reading as Required>; +} + +describe('#26130 — `os dev` with OS_PORT serves the connector baseUrl dispatch uses', () => { + it('dispatch materializes the connector on the boot environment\'s port (unchanged)', () => { + expect(recorded().materialized).toBe(LIVE_URL); + }); + + it('/api/v1/meta/connector/NAME serves that same baseUrl, not the build-time one', () => { + expect(recorded().served).toBe(LIVE_URL); + }); +}); From 7f3d5beee8807f59ccebd0722115da3763ecdb54 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:15:31 +0000 Subject: [PATCH 2/3] test(cli): the env-parity fixture declares an object so the host boots a query engine Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Cj99V2CpVvyWDjQu1ndoBS --- .../test/dev-connector-env-parity.integration.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/dev-connector-env-parity.integration.test.ts b/packages/cli/test/dev-connector-env-parity.integration.test.ts index 4837e4d7532..7642d114196 100644 --- a/packages/cli/test/dev-connector-env-parity.integration.test.ts +++ b/packages/cli/test/dev-connector-env-parity.integration.test.ts @@ -81,6 +81,14 @@ const EMAIL = 'admin@objectos.ai'; const PASSWORD = 'admin123'; const MANIFEST = { id: 'com.example.fxp', namespace: 'fxp', version: '1.0.0', type: 'app', name: 'Fxp' }; +/** One object: a host config with none boots no query engine (`shouldAutoRegisterObjectQL`). */ +const OBJECTS = [{ + name: 'fxp_note', + label: 'Note', + pluralLabel: 'Notes', + sharingModel: 'public_read_write', + fields: { title: { type: 'text', label: 'Title' } }, +}]; /** The connector as the config declares it, given the base URL its environment yields. */ const connector = (baseUrl: string) => ({ @@ -107,6 +115,7 @@ const selfUrl = \`http://127.0.0.1:\${process.env.OS_PORT?.trim() || '3000'}\`; export default defineStack({ manifest: ${JSON.stringify(MANIFEST)}, + objects: ${JSON.stringify(OBJECTS)}, requires: ['automation'], connectors: [${JSON.stringify(connector('__SELF_URL__')).replace('"__SELF_URL__"', 'selfUrl')}], plugins: [{ @@ -248,7 +257,7 @@ beforeAll(async () => { mkdirSync(join(dir, 'dist'), { recursive: true }); writeFileSync( join(dir, 'dist', 'objectstack.json'), - JSON.stringify({ manifest: MANIFEST, requires: ['automation'], connectors: [connector(BUILD_URL)] }, null, 2), + JSON.stringify({ manifest: MANIFEST, objects: OBJECTS, requires: ['automation'], connectors: [connector(BUILD_URL)] }, null, 2), 'utf8', ); From ac8dadabc591623d4329935c82a3714e4e253ce5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:49:06 +0000 Subject: [PATCH 3/3] test(cli): the built-entrypoint census counts the os dev env-parity boot Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Cj99V2CpVvyWDjQu1ndoBS --- scripts/check-cli-test-child-env.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/check-cli-test-child-env.mjs b/scripts/check-cli-test-child-env.mjs index 48300d26ca0..c99039c9415 100644 --- a/scripts/check-cli-test-child-env.mjs +++ b/scripts/check-cli-test-child-env.mjs @@ -2635,8 +2635,13 @@ export function selfTest() { .filter((abs) => builtEntrypointSpawns(abs, readFileSync(abs, 'utf8')).spawns > 0) .map((abs) => relative(REPO_ROOT, abs).split(sep).join('/')) .sort(); - t('the built-entrypoint population is exactly the fifteen files that spawn bin/run.js', + t('the built-entrypoint population is exactly the sixteen files that spawn bin/run.js', JSON.stringify(builtFiles) === JSON.stringify([ + // [#26130] Joined the population deliberately: its subject is a plain + // `os dev` boot that compiles before it serves, measured on the BUILT + // packages the way `dev-standalone-self-heal` measures its `os dev` + // boots. Its spawn hands the child `NODE_ENV: undefined`. + 'packages/cli/test/dev-connector-env-parity.integration.test.ts', // [#21733] Joined the population deliberately: the card's acceptance is // a plain `os dev` restart on the BUILT packages, and its production leg // (`os serve` with `NODE_ENV` unset must NOT auto-migrate) is only