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..de5754e58c7 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -11,6 +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 { 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). @@ -2173,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) { @@ -3503,6 +3507,41 @@ 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, 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), + configExists, + artifactExists: true, + })) { + printDiagnostic(chalk.dim(` Compiling ${relativeConfig} → ${path.relative(process.cwd(), hmrArtifactPath) || hmrArtifactPath}...`)); + const compiled = compileForDevBoot({ configPath: absolutePath, outputPath: hmrArtifactPath, launchNodeEnv }); + 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.'), + ); + 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..df66a972427 --- /dev/null +++ b/packages/cli/src/utils/dev-compile-at-boot.ts @@ -0,0 +1,81 @@ +// 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 + * 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; +} + +/** + * 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-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/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..7b0d9308996 --- /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: 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 + * 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`