From 378de6209590ac54ec35f8f98b1f52aa370b5b4b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:37:53 +0000 Subject: [PATCH 1/4] fix(cli): a direct os serve --dev compiles the config before its dev artifact door serves it The dev artifact door served dist/objectstack.json as found while dispatch read the evaluated config module, so an env-derived connector baseUrl differed between /meta and what runs. The compile decision os dev reads (devCompilesAtBoot) moves to utils/ so serve reads it too; a supervised serve child keeps its parent's decision. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_019rDzaJmEJAYLUNJ4kwG2vN --- .../26242-serve-dev-compiles-at-boot.md | 13 ++ packages/cli/src/commands/dev.ts | 43 +--- packages/cli/src/commands/serve.ts | 41 ++++ packages/cli/src/utils/dev-compile-at-boot.ts | 47 ++++ .../cli/test/dev-compile-at-boot.pin.test.ts | 3 +- ...v-connector-env-parity.integration.test.ts | 204 +++++++++++++----- 6 files changed, 253 insertions(+), 98 deletions(-) create mode 100644 .changeset/26242-serve-dev-compiles-at-boot.md create mode 100644 packages/cli/src/utils/dev-compile-at-boot.ts diff --git a/.changeset/26242-serve-dev-compiles-at-boot.md b/.changeset/26242-serve-dev-compiles-at-boot.md new file mode 100644 index 00000000000..421680daeb8 --- /dev/null +++ b/.changeset/26242-serve-dev-compiles-at-boot.md @@ -0,0 +1,13 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): a direct `os serve --dev` compiles the config before its dev artifact door serves it, so the served connector `baseUrl` is the one dispatch calls + +Clause-②: no +Metadata-protocol: none + +- **Before.** `os serve --dev` over an existing `dist/objectstack.json` served that file through the dev artifact door as found, while dispatch read the config module it evaluated. 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. `os dev` had the same split until it began compiling at boot. +- **Now.** Before it composes that door, a direct dev boot compiles the config into the artifact in its own environment, using the same decision `os dev` uses (`devCompilesAtBoot`), so both copies come from one evaluation. A failed compile refuses the boot, as under `os dev`. This costs about 3 s per boot on the showcase. +- Under `os dev`, the `serve` child keeps its parent's decision and never compiles again, so `os dev --no-compile` still boots the artifact as built. A dev boot with no artifact on disk composes no door and compiles nothing, as before. A pinned artifact (`OS_ARTIFACT_URL`) is never rebuilt. +- Production `os serve` (no `--dev`, `NODE_ENV` not `development`) and `os start` are unchanged. diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 7e10fb38230..b39c318957e 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -22,6 +22,7 @@ import { type DevelopmentUpgradeResult, } from '../utils/dev-organization-ownership-upgrade.js'; import { childEnvWithResolvedArtifact } from '../utils/internal-artifact-channel.js'; +import { devCompilesAtBoot } from '../utils/dev-compile-at-boot.js'; import { makeDirectorySync } from '../utils/make-directory.js'; // THE artifact precedence, written once (#21501) — shared with `start`, and // with the `serve` child this command spawns. ⛔ No rung of it is restated here. @@ -246,48 +247,6 @@ 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). * diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 80458419a16..ea0b9d8c1a0 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -4,6 +4,7 @@ import { Args, Command, Flags } from '@oclif/core'; import path from 'path'; import fs from 'fs'; import net from 'net'; +import { spawnSync } from 'child_process'; import chalk, { chalkStderr } from 'chalk'; import { bundleRequire } from 'bundle-require'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; @@ -11,6 +12,7 @@ import { mergeBootConfig } from '../utils/merge-boot-config.js'; import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; import { readInternalArtifactPath, readInternalConfigOutputPath } from '../utils/internal-artifact-channel.js'; +import { devCompilesAtBoot } from '../utils/dev-compile-at-boot.js'; import { makeDirectorySync } from '../utils/make-directory.js'; // The precedence's last rung — whether the cwd config takes part — decided by // the SAME predicate the supervisors print their `Config:` row by (#21501). @@ -3503,6 +3505,45 @@ export default class Serve extends Command { + '\n boot without a door.', )); } else { + // #26242 — the door is about to serve THIS file's copy of the + // stack while the wrap below serves the config module's, so a + // config that reads the environment at module evaluation (the + // showcase's `OS_PORT` self URL) gives `/meta` the value of + // whichever environment last compiled the file. `os dev` closed + // that by compiling before it boots (#26130); a direct dev boot + // reads the same decision, `devCompilesAtBoot`. Under a + // supervising `os dev` (its channel answered) the parent already + // made it, `--no-compile` included, so the child never compiles + // again. Only an artifact on disk reaches this branch: with none, + // no door composes and the wrap is the one copy. + // + // The compile runs in THIS process's environment, the one the + // module above was evaluated in (`NODE_ENV` included, which + // `--dev` set; the built entry ignores it for loading, #12271), + // so both copies are one evaluation's answer. A failed compile + // refuses the boot, as it does under `os dev`. + if (!supervisorArtifact && devCompilesAtBoot({ + compile: true, + pinned: Boolean(pinnedArtifact), + configExists, + artifactExists: true, + })) { + printDiagnostic(chalk.dim(` Compiling ${relativeConfig} → ${path.relative(process.cwd(), hmrArtifactPath) || hmrArtifactPath}...`)); + const compiled = spawnSync( + process.execPath, + [process.argv[1], 'compile', absolutePath, '--output', hmrArtifactPath], + { env: process.env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ); + if (compiled.status !== 0) { + console.error( + `${compiled.stdout ?? ''}${compiled.stderr ?? ''}\n` + + chalk.red(` ✗ Compile failed — fix errors above before starting the dev server.\n`) + + chalk.dim(` A dev boot over ${hmrArtifactPath} compiles ${relativeConfig} into it first, so the`) + + chalk.dim('\n metadata it serves is the config this boot evaluates (#26242).'), + ); + process.exit(1); + } + } const { MetadataPlugin } = await import('@objectstack/metadata'); // Mirror the standalone stack's dev config exactly // (packages/runtime/src/standalone-stack.ts): declarative diff --git a/packages/cli/src/utils/dev-compile-at-boot.ts b/packages/cli/src/utils/dev-compile-at-boot.ts new file mode 100644 index 00000000000..214b143fb78 --- /dev/null +++ b/packages/cli/src/utils/dev-compile-at-boot.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Whether a development boot compiles the config into the artifact it is about + * to serve, before the server starts (#26130). Both dev doors read this one + * decision: `os dev` before it spawns its `serve` child, and a direct + * `os serve --dev` before it composes the dev artifact door (#26242). Lifted + * here from `commands/dev.ts` so the second door reads it instead of restating + * it (`dev.ts` imports `serve.ts`, so `serve.ts` cannot import `dev.ts`). + * + * Yes by default whenever the boot serves an artifact it 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 dev boot serves the stack from TWO places: the config + * MODULE, evaluated in the serving process (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, on `os dev` and on + * `os serve --dev` alike: `GET /api/v1/meta/connector/showcase_status_api` + * served `http://127.0.0.1:3000` while the materialized connector called 41302. + * + * Compiling in the boot's own environment (`os dev`'s `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. `os dev`'s watch + * loop recompiles in that environment too, so the two stay one answer for the + * whole session. + * + * `os dev --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; +} diff --git a/packages/cli/test/dev-compile-at-boot.pin.test.ts b/packages/cli/test/dev-compile-at-boot.pin.test.ts index 30364779534..f6e47c66fb8 100644 --- a/packages/cli/test/dev-compile-at-boot.pin.test.ts +++ b/packages/cli/test/dev-compile-at-boot.pin.test.ts @@ -15,7 +15,8 @@ import { describe, it, expect } from 'vitest'; import { Parser } from '@oclif/core'; -import Dev, { devCompilesAtBoot } from '../src/commands/dev.js'; +import Dev from '../src/commands/dev.js'; +import { devCompilesAtBoot } from '../src/utils/dev-compile-at-boot.js'; const parseDev = (argv: string[]) => Parser.parse(argv, { flags: Dev.flags, args: Dev.args, strict: 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 index 7642d114196..4645b5223e6 100644 --- a/packages/cli/test/dev-connector-env-parity.integration.test.ts +++ b/packages/cli/test/dev-connector-env-parity.integration.test.ts @@ -1,28 +1,33 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 30 — spawns the real producer once per single-cause fixture: four boots of the built CLI, one per dev-door compile decision (`os dev`, `os dev --no-compile`, `os serve --dev`, production `os serve`), each over a freshly written build-time artifact; 19.7 s locally on a shared container, x 1.25, no CI reading yet /** - * #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. + * #26130, #26242 — on a development boot, a connector value the config reads + * from the environment is the same in the served metadata and in what dispatch + * calls, on both dev doors (`os dev` and a direct `os serve --dev`). * * ## 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 + * A host config's dev boot holds two copies of one stack: the config MODULE + * the serving process 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: + * connectors' `baseUrl` from `OS_PORT` at module evaluation, and both dev doors + * booted whatever `dist/objectstack.json` already held. Measured on the + * showcase 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. + * The fix is in the boot, not in the config: it compiles the config into the + * artifact in its own environment before the door serves it + * (`devCompilesAtBoot`, one decision both doors read), so both copies are one + * evaluation's answer. `os dev` compiles before it spawns its `serve` child + * (#26130); a direct `os serve --dev` compiles before it composes the door + * (#26242). * * ## What this pins * @@ -31,17 +36,27 @@ * `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. + * whose environment had no `OS_PORT`, written afresh before each boot. One + * boot per leg with `OS_PORT` set: + * + * - `os dev` and `os serve --dev`: the served `baseUrl` and the materialized + * one both read that port. + * - `os dev --no-compile`: the served one still reads the build-time port. + * The `serve` child reads its supervisor's decision and never compiles on + * its own, so the old shortcut stays the operator's to choose. + * - `os serve` (production): it composes no artifact door and compiles + * nothing, so dispatch reads the module's copy as before and the artifact's + * bytes stay as built. (Its `/meta` answer, the module's copy, was measured + * on the showcase; this leg does not sign in, see `Leg.readsMeta`.) * * ## 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. + * `serve` grandchild). The listen port is kernel-assigned (`-p 0`); `OS_PORT` + * is only the value the config reads, exactly as an isolated-port boot hands it + * to a config. The boots run in `beforeAll`, one after another, and every `it` + * reads what its leg recorded. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -132,15 +147,59 @@ export default defineStack({ }); `; +/** The production leg's auth secret: a production boot refuses to compose auth without one. */ +const PRODUCTION_AUTH_SECRET = 'f'.repeat(64); + +interface Leg { + /** The command, as the `it` titles name it. */ + label: string; + argv: string[]; + env?: Record; + /** + * Whether the leg reads `/meta`. A production boot seeds no dev admin, and on + * a kernel-assigned port its auth trusts no origin the harness can send + * (the auth base URL names the requested port, `0`), so that leg reads only + * what dispatch materialized and the artifact's bytes. + */ + readsMeta: boolean; +} + +const LEGS = { + dev: { + label: 'os dev', + argv: ['dev', '--no-watch', '--no-restart', '-d', 'file:dev.db', '-p', KERNEL_ASSIGNED_PORT], + readsMeta: true, + }, + devNoCompile: { + label: 'os dev --no-compile', + argv: ['dev', '--no-compile', '--no-watch', '--no-restart', '-d', 'file:dev-no-compile.db', '-p', KERNEL_ASSIGNED_PORT], + readsMeta: true, + }, + serveDev: { + label: 'os serve --dev', + argv: ['serve', 'objectstack.config.ts', '--dev', '-p', KERNEL_ASSIGNED_PORT], + env: { OS_DATABASE_URL: 'file:serve-dev.db' }, + readsMeta: true, + }, + serve: { + label: 'os serve', + argv: ['serve', 'objectstack.config.ts', '-p', KERNEL_ASSIGNED_PORT], + env: { OS_DATABASE_URL: 'file:serve.db', OS_AUTH_SECRET: PRODUCTION_AUTH_SECRET }, + readsMeta: false, + }, +} satisfies Record; + interface Reading { served?: unknown; materialized?: unknown; + /** The artifact's bytes after the boot, beside the bytes written before it. */ + artifactUntouched?: boolean; error?: Error; } const groups: ChildProcess[] = []; let dir: string; -let reading: Reading = {}; +const readings: Partial> = {}; interface Live { child: ChildProcess; @@ -148,11 +207,11 @@ interface Live { output: () => string; } -function bootDev(env: Record): Promise { +function boot(leg: Leg): 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], + [CLI, ...leg.argv], { cwd: dir, // `childEnv`, never a bare `...process.env` — see its header. `NODE_ENV` @@ -169,7 +228,8 @@ function bootDev(env: Record): Promise { OS_MODE: undefined, NODE_ENV: undefined, PORT: undefined, - ...env, + OS_PORT: LIVE_PORT, + ...leg.env, }), stdio: ['ignore', 'pipe', 'pipe'], detached: true, @@ -186,18 +246,18 @@ function bootDev(env: Record): Promise { 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)}`)), + () => settle(new Error(`${leg.label} 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')); + if (READY.test(out)) settle(readKernelAssignedPort(out, leg.label)); }; 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)}`)), + settle(new Error(`${leg.label} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -247,62 +307,96 @@ function servedBaseUrl(body: any): unknown { 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, objects: OBJECTS, requires: ['automation'], connectors: [connector(BUILD_URL)] }, null, 2), - 'utf8', - ); +/** The artifact a build with no `OS_PORT` in its environment left behind. */ +const BUILT_ARTIFACT = JSON.stringify( + { manifest: MANIFEST, objects: OBJECTS, requires: ['automation'], connectors: [connector(BUILD_URL)] }, + null, + 2, +); +async function read(leg: Leg): Promise { + writeFileSync(join(dir, 'dist', 'objectstack.json'), BUILT_ARTIFACT, 'utf8'); + const materializedPath = join(dir, MATERIALIZED_FILE); + rmSync(materializedPath, { force: true }); 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)}`); + live = await boot(leg); + let served: unknown = '(this leg does not read /meta)'; + if (leg.readsMeta) { + 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(`${leg.label}: 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(`${leg.label}: GET /api/v1/meta/connector/${CONNECTOR} answered ${meta.status}: ${JSON.stringify(meta.body)}`); + } + served = servedBaseUrl(meta.body); } - 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), + return { + served, 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)}`, + artifactUntouched: readFileSync(join(dir, 'dist', 'objectstack.json'), 'utf8') === BUILT_ARTIFACT, }; } catch (error) { - reading = { error: error instanceof Error ? error : new Error(String(error)) }; + return { error: error instanceof Error ? error : new Error(String(error)) }; } finally { if (live) await stopGroup(live.child); } -}, BOOT_TIMEOUT_MS + 60_000); +} + +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); + mkdirSync(join(dir, 'dist'), { recursive: true }); + for (const key of Object.keys(LEGS) as (keyof typeof LEGS)[]) readings[key] = await read(LEGS[key]); +}, 4 * (BOOT_TIMEOUT_MS + 30_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> { +function recorded(key: keyof typeof LEGS): Required> { + const reading = readings[key]; + if (!reading) throw new Error(`${LEGS[key].label} never ran`); 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); + expect(recorded('dev').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); + expect(recorded('dev').served).toBe(LIVE_URL); + }); + + it('`--no-compile` keeps the old shortcut: the `serve` child does not compile on its own', () => { + expect(recorded('devNoCompile').served).toBe(BUILD_URL); + expect(recorded('devNoCompile').artifactUntouched).toBe(true); + expect(recorded('devNoCompile').materialized).toBe(LIVE_URL); + }); +}); + +describe('#26242 — a direct `os serve --dev` with OS_PORT serves the connector baseUrl dispatch uses', () => { + it('dispatch materializes the connector on the boot environment\'s port (unchanged)', () => { + expect(recorded('serveDev').materialized).toBe(LIVE_URL); + }); + + it('/api/v1/meta/connector/NAME serves that same baseUrl, not the build-time one', () => { + expect(recorded('serveDev').served).toBe(LIVE_URL); + }); + + it('production `os serve` is unchanged: dispatch on the module\'s baseUrl, and the artifact left as built', () => { + expect(recorded('serve').materialized).toBe(LIVE_URL); + expect(recorded('serve').artifactUntouched).toBe(true); }); }); From ca2e421679d7f59f66b48b208f8a57bceabe380b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 18:21:21 +0000 Subject: [PATCH 2/4] fix(cli): the dev-boot compile child gets the launch NODE_ENV; the serve --dev pin gets its own file serve sets NODE_ENV=development on itself, so a compile child spawned from serve.ts inherited it (child-env-source-loader pin). The spawn moves to utils/dev-compile-at-boot.ts as compileForDevBoot, which hands the child the serving environment with the launch NODE_ENV put back, as os dev's compile child sees it. The serve --dev legs leave dev-connector-env-parity (the os dev half, also being reworked for the port flag) for serve-dev-connector-env-parity, with its census entry; the verdicts compare the two copies rather than a port number. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_019rDzaJmEJAYLUNJ4kwG2vN --- packages/cli/src/commands/serve.ts | 20 +- packages/cli/src/utils/dev-compile-at-boot.ts | 34 ++ ...v-connector-env-parity.integration.test.ts | 204 +++------ ...v-connector-env-parity.integration.test.ts | 392 ++++++++++++++++++ scripts/check-cli-test-child-env.mjs | 9 +- 5 files changed, 498 insertions(+), 161 deletions(-) create mode 100644 packages/cli/test/serve-dev-connector-env-parity.integration.test.ts diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index ea0b9d8c1a0..1b0217de013 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -4,7 +4,6 @@ import { Args, Command, Flags } from '@oclif/core'; import path from 'path'; import fs from 'fs'; import net from 'net'; -import { spawnSync } from 'child_process'; import chalk, { chalkStderr } from 'chalk'; import { bundleRequire } from 'bundle-require'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; @@ -12,7 +11,7 @@ import { mergeBootConfig } from '../utils/merge-boot-config.js'; import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; import { readInternalArtifactPath, readInternalConfigOutputPath } from '../utils/internal-artifact-channel.js'; -import { devCompilesAtBoot } from '../utils/dev-compile-at-boot.js'; +import { compileForDevBoot, devCompilesAtBoot } from '../utils/dev-compile-at-boot.js'; import { makeDirectorySync } from '../utils/make-directory.js'; // The precedence's last rung — whether the cwd config takes part — decided by // the SAME predicate the supervisors print their `Config:` row by (#21501). @@ -2175,6 +2174,9 @@ export default class Serve extends Command { // An operator who never exported NODE_ENV is booting a real deployment, // not asking to be treated as development — this is `os serve` agreeing // with `os start` on that, one line, for the whole gate family at once. + // #26242 — the value before this command sets its own, for the one child a + // dev boot may start (`compileForDevBoot`), which must not inherit the write. + const launchNodeEnv = process.env.NODE_ENV; if (flags.dev) { if (!process.env.NODE_ENV) process.env.NODE_ENV = 'development'; } else if (!process.env.NODE_ENV) { @@ -3518,10 +3520,10 @@ export default class Serve extends Command { // no door composes and the wrap is the one copy. // // The compile runs in THIS process's environment, the one the - // module above was evaluated in (`NODE_ENV` included, which - // `--dev` set; the built entry ignores it for loading, #12271), - // so both copies are one evaluation's answer. A failed compile - // refuses the boot, as it does under `os dev`. + // module above was evaluated in, with the launch `NODE_ENV` put + // back (`compileForDevBoot` says why), so both copies are one + // evaluation's answer exactly as under `os dev`. A failed compile + // refuses the boot, as it does there. if (!supervisorArtifact && devCompilesAtBoot({ compile: true, pinned: Boolean(pinnedArtifact), @@ -3529,11 +3531,7 @@ export default class Serve extends Command { artifactExists: true, })) { printDiagnostic(chalk.dim(` Compiling ${relativeConfig} → ${path.relative(process.cwd(), hmrArtifactPath) || hmrArtifactPath}...`)); - const compiled = spawnSync( - process.execPath, - [process.argv[1], 'compile', absolutePath, '--output', hmrArtifactPath], - { env: process.env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, - ); + const compiled = compileForDevBoot({ configPath: absolutePath, outputPath: hmrArtifactPath, launchNodeEnv }); if (compiled.status !== 0) { console.error( `${compiled.stdout ?? ''}${compiled.stderr ?? ''}\n` diff --git a/packages/cli/src/utils/dev-compile-at-boot.ts b/packages/cli/src/utils/dev-compile-at-boot.ts index 214b143fb78..df66a972427 100644 --- a/packages/cli/src/utils/dev-compile-at-boot.ts +++ b/packages/cli/src/utils/dev-compile-at-boot.ts @@ -1,5 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { spawnSync, type SpawnSyncReturns } from 'child_process'; + /** * Whether a development boot compiles the config into the artifact it is about * to serve, before the server starts (#26130). Both dev doors read this one @@ -45,3 +47,35 @@ export function devCompilesAtBoot(opts: { if (!opts.artifactExists) return true; return opts.compile && opts.configExists; } + +/** + * Compile `configPath` into `outputPath` in a child `os compile`, for a direct + * dev boot about to serve that artifact (#26242). The output is captured and + * returned, so a boot whose banner is still being assembled stays quiet unless + * the compile fails. + * + * The child gets the serving process's environment (the project `.env` files + * it loaded included) with ONE value put back: `NODE_ENV` as the process was + * launched with it. `serve --dev` sets `NODE_ENV=development` on ITSELF after + * its own modules have loaded; inherited, that write would put the child under + * oclif's TypeScript source loader, the failure + * `commands/child-env-source-loader.pin.test.ts` holds every command to (and + * the reason `os dev`'s own compile spawn writes no `NODE_ENV`). So the child + * sees what `os dev`'s compile child sees: the operator's `NODE_ENV`, or none. + */ +export function compileForDevBoot(opts: { + configPath: string; + outputPath: string; + launchNodeEnv: string | undefined; +}): SpawnSyncReturns { + return spawnSync( + process.execPath, + [process.argv[1], 'compile', opts.configPath, '--output', opts.outputPath], + { + // `spawn` drops an `undefined` entry, so an unset launch value stays unset. + env: { ...process.env, NODE_ENV: opts.launchNodeEnv }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); +} 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 4645b5223e6..7642d114196 100644 --- a/packages/cli/test/dev-connector-env-parity.integration.test.ts +++ b/packages/cli/test/dev-connector-env-parity.integration.test.ts @@ -1,33 +1,28 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// test-budget: 30 — spawns the real producer once per single-cause fixture: four boots of the built CLI, one per dev-door compile decision (`os dev`, `os dev --no-compile`, `os serve --dev`, production `os serve`), each over a freshly written build-time artifact; 19.7 s locally on a shared container, x 1.25, no CI reading yet /** - * #26130, #26242 — on a development boot, a connector value the config reads - * from the environment is the same in the served metadata and in what dispatch - * calls, on both dev doors (`os dev` and a direct `os serve --dev`). + * #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 dev boot holds two copies of one stack: the config MODULE - * the serving process 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 + * 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 both dev doors - * booted whatever `dist/objectstack.json` already held. Measured on the - * showcase with `OS_PORT=41302` over an artifact built without it: + * 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 the boot, not in the config: it compiles the config into the - * artifact in its own environment before the door serves it - * (`devCompilesAtBoot`, one decision both doors read), so both copies are one - * evaluation's answer. `os dev` compiles before it spawns its `serve` child - * (#26130); a direct `os serve --dev` compiles before it composes the door - * (#26242). + * 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 * @@ -36,27 +31,17 @@ * `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`, written afresh before each boot. One - * boot per leg with `OS_PORT` set: - * - * - `os dev` and `os serve --dev`: the served `baseUrl` and the materialized - * one both read that port. - * - `os dev --no-compile`: the served one still reads the build-time port. - * The `serve` child reads its supervisor's decision and never compiles on - * its own, so the old shortcut stays the operator's to choose. - * - `os serve` (production): it composes no artifact door and compiles - * nothing, so dispatch reads the module's copy as before and the artifact's - * bytes stay as built. (Its `/meta` answer, the module's copy, was measured - * on the showcase; this leg does not sign in, see `Leg.readsMeta`.) + * 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`); `OS_PORT` - * is only the value the config reads, exactly as an isolated-port boot hands it - * to a config. The boots run in `beforeAll`, one after another, and every `it` - * reads what its leg recorded. + * `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'; @@ -147,59 +132,15 @@ export default defineStack({ }); `; -/** The production leg's auth secret: a production boot refuses to compose auth without one. */ -const PRODUCTION_AUTH_SECRET = 'f'.repeat(64); - -interface Leg { - /** The command, as the `it` titles name it. */ - label: string; - argv: string[]; - env?: Record; - /** - * Whether the leg reads `/meta`. A production boot seeds no dev admin, and on - * a kernel-assigned port its auth trusts no origin the harness can send - * (the auth base URL names the requested port, `0`), so that leg reads only - * what dispatch materialized and the artifact's bytes. - */ - readsMeta: boolean; -} - -const LEGS = { - dev: { - label: 'os dev', - argv: ['dev', '--no-watch', '--no-restart', '-d', 'file:dev.db', '-p', KERNEL_ASSIGNED_PORT], - readsMeta: true, - }, - devNoCompile: { - label: 'os dev --no-compile', - argv: ['dev', '--no-compile', '--no-watch', '--no-restart', '-d', 'file:dev-no-compile.db', '-p', KERNEL_ASSIGNED_PORT], - readsMeta: true, - }, - serveDev: { - label: 'os serve --dev', - argv: ['serve', 'objectstack.config.ts', '--dev', '-p', KERNEL_ASSIGNED_PORT], - env: { OS_DATABASE_URL: 'file:serve-dev.db' }, - readsMeta: true, - }, - serve: { - label: 'os serve', - argv: ['serve', 'objectstack.config.ts', '-p', KERNEL_ASSIGNED_PORT], - env: { OS_DATABASE_URL: 'file:serve.db', OS_AUTH_SECRET: PRODUCTION_AUTH_SECRET }, - readsMeta: false, - }, -} satisfies Record; - interface Reading { served?: unknown; materialized?: unknown; - /** The artifact's bytes after the boot, beside the bytes written before it. */ - artifactUntouched?: boolean; error?: Error; } const groups: ChildProcess[] = []; let dir: string; -const readings: Partial> = {}; +let reading: Reading = {}; interface Live { child: ChildProcess; @@ -207,11 +148,11 @@ interface Live { output: () => string; } -function boot(leg: Leg): Promise { +function bootDev(env: Record): Promise { return new Promise((resolveBoot, rejectBoot) => { const child = spawn( process.execPath, - [CLI, ...leg.argv], + [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` @@ -228,8 +169,7 @@ function boot(leg: Leg): Promise { OS_MODE: undefined, NODE_ENV: undefined, PORT: undefined, - OS_PORT: LIVE_PORT, - ...leg.env, + ...env, }), stdio: ['ignore', 'pipe', 'pipe'], detached: true, @@ -246,18 +186,18 @@ function boot(leg: Leg): Promise { else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out }); }; const timer = setTimeout( - () => settle(new Error(`${leg.label} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), + () => 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, leg.label)); + 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(`${leg.label} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), + settle(new Error(`os dev exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)), ); }); } @@ -307,96 +247,62 @@ function servedBaseUrl(body: any): unknown { return item?.providerConfig?.baseUrl; } -/** The artifact a build with no `OS_PORT` in its environment left behind. */ -const BUILT_ARTIFACT = JSON.stringify( - { manifest: MANIFEST, objects: OBJECTS, requires: ['automation'], connectors: [connector(BUILD_URL)] }, - null, - 2, -); +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, objects: OBJECTS, requires: ['automation'], connectors: [connector(BUILD_URL)] }, null, 2), + 'utf8', + ); -async function read(leg: Leg): Promise { - writeFileSync(join(dir, 'dist', 'objectstack.json'), BUILT_ARTIFACT, 'utf8'); - const materializedPath = join(dir, MATERIALIZED_FILE); - rmSync(materializedPath, { force: true }); let live: Live | undefined; try { - live = await boot(leg); - let served: unknown = '(this leg does not read /meta)'; - if (leg.readsMeta) { - 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(`${leg.label}: 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(`${leg.label}: GET /api/v1/meta/connector/${CONNECTOR} answered ${meta.status}: ${JSON.stringify(meta.body)}`); - } - served = servedBaseUrl(meta.body); + 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)}`); } - return { - served, + 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)}`, - artifactUntouched: readFileSync(join(dir, 'dist', 'objectstack.json'), 'utf8') === BUILT_ARTIFACT, }; } catch (error) { - return { error: error instanceof Error ? error : new Error(String(error)) }; + reading = { error: error instanceof Error ? error : new Error(String(error)) }; } finally { if (live) await stopGroup(live.child); } -} - -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); - mkdirSync(join(dir, 'dist'), { recursive: true }); - for (const key of Object.keys(LEGS) as (keyof typeof LEGS)[]) readings[key] = await read(LEGS[key]); -}, 4 * (BOOT_TIMEOUT_MS + 30_000)); +}, 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(key: keyof typeof LEGS): Required> { - const reading = readings[key]; - if (!reading) throw new Error(`${LEGS[key].label} never ran`); +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('dev').materialized).toBe(LIVE_URL); + expect(recorded().materialized).toBe(LIVE_URL); }); it('/api/v1/meta/connector/NAME serves that same baseUrl, not the build-time one', () => { - expect(recorded('dev').served).toBe(LIVE_URL); - }); - - it('`--no-compile` keeps the old shortcut: the `serve` child does not compile on its own', () => { - expect(recorded('devNoCompile').served).toBe(BUILD_URL); - expect(recorded('devNoCompile').artifactUntouched).toBe(true); - expect(recorded('devNoCompile').materialized).toBe(LIVE_URL); - }); -}); - -describe('#26242 — a direct `os serve --dev` with OS_PORT serves the connector baseUrl dispatch uses', () => { - it('dispatch materializes the connector on the boot environment\'s port (unchanged)', () => { - expect(recorded('serveDev').materialized).toBe(LIVE_URL); - }); - - it('/api/v1/meta/connector/NAME serves that same baseUrl, not the build-time one', () => { - expect(recorded('serveDev').served).toBe(LIVE_URL); - }); - - it('production `os serve` is unchanged: dispatch on the module\'s baseUrl, and the artifact left as built', () => { - expect(recorded('serve').materialized).toBe(LIVE_URL); - expect(recorded('serve').artifactUntouched).toBe(true); + expect(recorded().served).toBe(LIVE_URL); }); }); diff --git a/packages/cli/test/serve-dev-connector-env-parity.integration.test.ts b/packages/cli/test/serve-dev-connector-env-parity.integration.test.ts new file mode 100644 index 00000000000..a6d741d0a42 --- /dev/null +++ b/packages/cli/test/serve-dev-connector-env-parity.integration.test.ts @@ -0,0 +1,392 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 30 — spawns the real producer once per single-cause fixture: three boots of the built CLI, one per dev-door compile decision (`os serve --dev`, `os dev --no-compile`, production `os serve`), each over a freshly written build-time artifact; 15.0 s locally on a shared container, x 1.25, no CI reading yet + +/** + * #26242 — on a direct `os serve --dev` boot, a connector value the config + * reads from the environment is the same in the served metadata and in what + * dispatch calls, as #26130 made it on `os dev`. + * + * ## The defect, measured at the public door before the fix + * + * A host config's dev boot holds two copies of one stack: the config MODULE + * the serving process 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. `os dev` compiles + * before it boots since #26130, but a direct `os serve --dev` composed the door + * over whatever `dist/objectstack.json` already held. Measured on the showcase + * 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 the boot: before it composes the door, a direct dev boot + * compiles the config into the artifact in its own environment, reading the + * decision `os dev` reads (`devCompilesAtBoot`), so both copies are one + * evaluation's answer. + * + * ## What this pins + * + * The fixture of `dev-connector-env-parity.integration.test.ts` (#26130, the + * `os dev` half), copied rather than shared so neither file's legs move the + * other's: 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`, written afresh before each boot. One boot per leg: + * + * - `os serve --dev`: the served `baseUrl` is the materialized one, not the + * build-time one. + * - `os dev --no-compile`: the served one still reads the build-time port. + * The `serve` child reads its supervisor's decision and never compiles on + * its own, so the old shortcut stays the operator's to choose. + * - `os serve` (production): it composes no artifact door and compiles + * nothing, so dispatch reads the module's copy as before and the artifact's + * bytes stay as built. (Its `/meta` answer, the module's copy, was measured + * on the showcase; this leg does not sign in, see `Leg.readsMeta`.) + * + * The verdicts compare the two copies with each other and with the build-time + * value, never with a port number: what the config reads for its port is the + * port contract's business (`OS_PORT` here, the flag too since #26243), and + * the card's property is that the two copies agree. + * + * ## Spawn shape + * + * `bin/run.js` with `NODE_ENV` unset, the shipped entry resolving commands from + * `dist/` (`requireBuiltCli`), so the compile child sees the launch + * environment a real `os serve --dev` has; in its own process group (`dev` + * supervises a `serve` grandchild). The listen port is kernel-assigned + * (`-p 0`); `OS_PORT` is the value the config reads. The boots run in + * `beforeAll`, one after another, and every `it` reads what its leg 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 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' }; +/** 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) => ({ + 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)}, + objects: ${JSON.stringify(OBJECTS)}, + 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: {} }; + }); + }, + }], +}); +`; + +/** The production leg's auth secret: a production boot refuses to compose auth without one. */ +const PRODUCTION_AUTH_SECRET = 'f'.repeat(64); + +interface Leg { + /** The command, as the `it` titles name it. */ + label: string; + argv: string[]; + env?: Record; + /** + * Whether the leg reads `/meta`. A production boot seeds no dev admin, and on + * a kernel-assigned port its auth trusts no origin the harness can send + * (the auth base URL names the requested port, `0`), so that leg reads only + * what dispatch materialized and the artifact's bytes. + */ + readsMeta: boolean; +} + +const LEGS = { + serveDev: { + label: 'os serve --dev', + argv: ['serve', 'objectstack.config.ts', '--dev', '-p', KERNEL_ASSIGNED_PORT], + env: { OS_DATABASE_URL: 'file:serve-dev.db' }, + readsMeta: true, + }, + devNoCompile: { + label: 'os dev --no-compile', + argv: ['dev', '--no-compile', '--no-watch', '--no-restart', '-d', 'file:dev-no-compile.db', '-p', KERNEL_ASSIGNED_PORT], + readsMeta: true, + }, + serve: { + label: 'os serve', + argv: ['serve', 'objectstack.config.ts', '-p', KERNEL_ASSIGNED_PORT], + env: { OS_DATABASE_URL: 'file:serve.db', OS_AUTH_SECRET: PRODUCTION_AUTH_SECRET }, + readsMeta: false, + }, +} satisfies Record; + +interface Reading { + served?: unknown; + materialized?: unknown; + /** The artifact's bytes after the boot, beside the bytes written before it. */ + artifactUntouched?: boolean; + error?: Error; +} + +const groups: ChildProcess[] = []; +let dir: string; +const readings: Partial> = {}; + +interface Live { + child: ChildProcess; + base: string; + output: () => string; +} + +function boot(leg: Leg): Promise { + return new Promise((resolveBoot, rejectBoot) => { + const child = spawn( + process.execPath, + [CLI, ...leg.argv], + { + 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, + PORT: undefined, + OS_PORT: LIVE_PORT, + ...leg.env, + // Last, so no leg's own variables can put the child under the loader. + NODE_ENV: undefined, + }), + 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(`${leg.label} 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, leg.label)); + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.on('exit', (code) => + settle(new Error(`${leg.label} 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: 'serve-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; +} + +/** The artifact a build with no `OS_PORT` in its environment left behind. */ +const BUILT_ARTIFACT = JSON.stringify( + { manifest: MANIFEST, objects: OBJECTS, requires: ['automation'], connectors: [connector(BUILD_URL)] }, + null, + 2, +); + +async function read(leg: Leg): Promise { + writeFileSync(join(dir, 'dist', 'objectstack.json'), BUILT_ARTIFACT, 'utf8'); + const materializedPath = join(dir, MATERIALIZED_FILE); + rmSync(materializedPath, { force: true }); + let live: Live | undefined; + try { + live = await boot(leg); + let served: unknown = '(this leg does not read /meta)'; + if (leg.readsMeta) { + 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(`${leg.label}: 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(`${leg.label}: GET /api/v1/meta/connector/${CONNECTOR} answered ${meta.status}: ${JSON.stringify(meta.body)}`); + } + served = servedBaseUrl(meta.body); + } + return { + served, + 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)}`, + artifactUntouched: readFileSync(join(dir, 'dist', 'objectstack.json'), 'utf8') === BUILT_ARTIFACT, + }; + } catch (error) { + return { error: error instanceof Error ? error : new Error(String(error)) }; + } finally { + if (live) await stopGroup(live.child); + } +} + +beforeAll(async () => { + requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); + dir = mkdtempSync(join(tmpdir(), 'os-26242-serve-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); + mkdirSync(join(dir, 'dist'), { recursive: true }); + for (const key of Object.keys(LEGS) as (keyof typeof LEGS)[]) readings[key] = await read(LEGS[key]); +}, 3 * (BOOT_TIMEOUT_MS + 30_000)); + +afterAll(async () => { + for (const child of groups) await stopGroup(child); + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +}, 60_000); + +function recorded(key: keyof typeof LEGS): Required> { + const reading = readings[key]; + if (!reading) throw new Error(`${LEGS[key].label} never ran`); + if (reading.error) throw reading.error; + return reading as Required>; +} + +describe('#26242 — a direct `os serve --dev` serves the connector baseUrl dispatch uses', () => { + it('/api/v1/meta/connector/NAME serves the materialized baseUrl, not the build-time one', () => { + const { served, materialized } = recorded('serveDev'); + expect(materialized).not.toBe(BUILD_URL); + expect(served).toBe(materialized); + }); + + it('under `os dev --no-compile` the `serve` child does not compile on its own', () => { + const { served, materialized, artifactUntouched } = recorded('devNoCompile'); + expect(served).toBe(BUILD_URL); + expect(artifactUntouched).toBe(true); + expect(materialized).not.toBe(BUILD_URL); + }); + + it('production `os serve` is unchanged: dispatch on the module\'s baseUrl, and the artifact left as built', () => { + const { materialized, artifactUntouched } = recorded('serve'); + expect(materialized).not.toBe(BUILD_URL); + expect(artifactUntouched).toBe(true); + }); +}); diff --git a/scripts/check-cli-test-child-env.mjs b/scripts/check-cli-test-child-env.mjs index c99039c9415..e10633d0aee 100644 --- a/scripts/check-cli-test-child-env.mjs +++ b/scripts/check-cli-test-child-env.mjs @@ -2635,7 +2635,7 @@ 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 sixteen files that spawn bin/run.js', + t('the built-entrypoint population is exactly the seventeen 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 @@ -2662,6 +2662,13 @@ export function selfTest() { // `NODE_ENV=development` before argv is parsed. Every spawn hands its // child `NODE_ENV: undefined`. 'packages/cli/test/serve-datasource-mapping-before-sync.integration.test.ts', + // [#26242] Joined the population deliberately, beside + // `dev-connector-env-parity`: its subject is a direct `os serve --dev` + // that compiles before its artifact door serves, and the compile child + // must see the launch environment a real boot has, while + // `bin/run-dev.js` sets `NODE_ENV=development` before argv is parsed. + // Every spawn hands its child `NODE_ENV: undefined`. + 'packages/cli/test/serve-dev-connector-env-parity.integration.test.ts', 'packages/cli/test/serve-mcp-capability-collision.e2e.test.ts', 'packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts', // [#24191] Joined the population deliberately: its subject is `os serve` From 751f30aaaf3b9f09918cda489f4c0f08fa4caba6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 18:53:43 +0000 Subject: [PATCH 3/4] fix(cli): keep the tracker id out of the dev-boot compile refusal text Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_019rDzaJmEJAYLUNJ4kwG2vN --- packages/cli/src/commands/serve.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 1b0217de013..de5754e58c7 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -3537,7 +3537,7 @@ export default class Serve extends Command { `${compiled.stdout ?? ''}${compiled.stderr ?? ''}\n` + chalk.red(` ✗ Compile failed — fix errors above before starting the dev server.\n`) + chalk.dim(` A dev boot over ${hmrArtifactPath} compiles ${relativeConfig} into it first, so the`) - + chalk.dim('\n metadata it serves is the config this boot evaluates (#26242).'), + + chalk.dim('\n metadata it serves is the config this boot evaluates.'), ); process.exit(1); } From 6f7edd28c330b29e25946d13f28aa3d69435a00f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 18:56:19 +0000 Subject: [PATCH 4/4] test(cli): the serve --dev pin's budget from three measured runs Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_019rDzaJmEJAYLUNJ4kwG2vN --- .../cli/test/serve-dev-connector-env-parity.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/serve-dev-connector-env-parity.integration.test.ts b/packages/cli/test/serve-dev-connector-env-parity.integration.test.ts index a6d741d0a42..7b0d9308996 100644 --- a/packages/cli/test/serve-dev-connector-env-parity.integration.test.ts +++ b/packages/cli/test/serve-dev-connector-env-parity.integration.test.ts @@ -1,5 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// test-budget: 30 — spawns the real producer once per single-cause fixture: three boots of the built CLI, one per dev-door compile decision (`os serve --dev`, `os dev --no-compile`, production `os serve`), each over a freshly written build-time artifact; 15.0 s locally on a shared container, x 1.25, no CI reading yet +// test-budget: 20 — spawns the real producer once per single-cause fixture: three boots of the built CLI, one per dev-door compile decision (`os serve --dev`, `os dev --no-compile`, production `os serve`), each over a freshly written build-time artifact; 13.9 s, the slowest of three local runs on a shared container, x 1.25, no CI reading yet /** * #26242 — on a direct `os serve --dev` boot, a connector value the config