From 0c93b6c1a278f67a658cc8b7b91d749d420fe3e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 12:53:08 +0000 Subject: [PATCH 1/2] fix(cli): anchor serve's optional-package resolution at the app, not the CWD (#11185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serve` takes its config as an argument, so the app being served need not be the process CWD — but every host-anchored optional load used `process.cwd()` as its resolution base. Booting an app by config path therefore read the wrong `package.json`: an app-declared optional service (`@objectstack/service-cluster` and its driver, the enterprise organizations runtime, anything a customer installs) came back `undeclared`, fell through to the framework-side fallback, and boot died while the app's own `node_modules` carried the package. `run()` now resolves the config path and the app root in one call (`anchorServedApp`), and every host-anchored load defaults to that root. The config's directory is adopted only when it holds a `package.json`, so no layout that resolves today resolves differently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- ...cli-serve-anchor-optional-import-at-app.md | 44 +++ packages/cli/src/commands/serve.ts | 121 ++++++- ...e-app-anchored-optional-import.e2e.test.ts | 307 ++++++++++++++++++ 3 files changed, 459 insertions(+), 13 deletions(-) create mode 100644 .changeset/cli-serve-anchor-optional-import-at-app.md create mode 100644 packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts diff --git a/.changeset/cli-serve-anchor-optional-import-at-app.md b/.changeset/cli-serve-anchor-optional-import-at-app.md new file mode 100644 index 0000000000..39a133bf79 --- /dev/null +++ b/.changeset/cli-serve-anchor-optional-import-at-app.md @@ -0,0 +1,44 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os serve` resolves app-declared optional service packages from the app, not the CWD (#11185) + +`serve` takes its config as an **argument**, so `objectstack serve /srv/app/objectstack.config.ts` +is a supported invocation and the app being served need not be the directory the operator +stood in. Every host-anchored optional load nevertheless used `process.cwd()` as its +resolution base, so with that invocation the CLI read the wrong `package.json`: a package the +app really does declare, and really does carry in its own `node_modules`, came back +`undeclared`, fell through to the framework-side fallback, and boot died — + +``` +Cannot find package '@objectstack/service-cluster': the host app does not declare it. + host app: /tmp/os-neutral-cwd-jXHXdF ← the CWD, not the app + (fallback resolution also failed: Cannot find package '@objectstack/service-cluster' + imported from …/packages/types/dist/node.mjs) +``` + +Measured on the released EE 4.1.0 image as `OS_CLUSTER_DRIVER=redis` ⇒ migrate exits 1 ⇒ the +whole stack cannot start. This is the same class as cloud#1013 and #10645 with the base wrong +for a different reason: those fixed the **importer** at these load sites (bare `import()` → +`importFromHost`); this fixes the **base** that importer is handed. + +`serve` now resolves the config path and the app root in one call (`anchorServedApp`), so the +anchor cannot be written too late or left out by a future author — the absolute config path +every later line needs is produced by the same call that sets it. Every host-anchored load in +the file defaults to that root, which is what generalises the repair to the next app-declared +optional service rather than fixing this one instance. The alternative route — declaring +`@objectstack/service-cluster*` in `packages/cli`'s own manifest — was rejected: it would make +the open-core CLI take a published dependency on packages it never imports, still leave every +third-party or future optional service broken, and change nothing for an app whose config is +addressed by path. + +The adopted root is the config's directory **only when that directory holds a `package.json`**, +and the CWD otherwise. `readHostDeclaration` reads a manifest — reachability is deliberately +not the contract (#4719) — so a directory with no manifest declares nothing and anchoring +there could only turn a working boot into an `undeclared` refusal. No layout that resolves +today resolves differently after this. + +The #4719 declaration gate is untouched: a package present in the app's `node_modules` but +absent from its `package.json` is still refused. The refusal's remedy (`host app: …`) now +names the app being served instead of an unrelated directory the operator happened to be in. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 45bf865575..fd95a98200 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -217,6 +217,88 @@ type CapabilitySpec = { const hostImporters = new Map(); +/** + * The root of the app this process is serving — resolved once by + * {@link anchorServedApp}, and the default resolution base for every + * host-anchored load in this file. + * + * `undefined` until `run()` has resolved the config path, and for any caller + * that reaches {@link importFromHost} / {@link Serve.importConfigPlugin} + * outside a boot (a unit test, an out-of-tree embedder). Both fall back to + * `process.cwd()` through {@link servedAppRootOrCwd}, which is the base this + * file used unconditionally before — so a load that somehow runs before the + * anchor is set behaves exactly as it did, never worse. + */ +let servedAppRoot: string | undefined; + +/** + * The served app's root, or the CWD when no boot has anchored one. + * + * Read as a FUNCTION at each use, never captured into a module-scope `const`: + * the value is not knowable at module-evaluation time (it comes from the + * command's own argument), and a captured copy would silently freeze the + * pre-boot answer into every load site. + */ +function servedAppRootOrCwd(): string { + return servedAppRoot ?? process.cwd(); +} + +/** + * Resolve the served app's config path **and anchor host resolution at it**. + * + * ── Why the app root is the CONFIG's directory, not the CWD (#11185) ──────── + * + * `serve` takes the config as an ARGUMENT, so `objectstack serve + * /srv/app/objectstack.config.ts` is a supported invocation and the app being + * served need not be the directory the operator happened to stand in. Every + * host-anchored load resolved from `process.cwd()` regardless, so with that + * invocation the CLI read the WRONG `package.json`: an app-declared optional + * service — `@objectstack/service-cluster` and its driver, the enterprise + * organizations runtime, anything a customer installs — was classified + * `undeclared`, fell through to the framework-side fallback, and boot died with + * + * Cannot find package '@objectstack/service-cluster': the host app does not + * declare it. + * host app: + * + * while the app's own `node_modules` carried the package the whole time. That + * is #10645's outage with the base wrong for a different reason: #10645 fixed + * the IMPORTER (bare `import()` → {@link importFromHost}), this fixes the BASE + * that importer is handed. + * + * This function is also the only place `run()` computes the config path, on + * purpose. The anchor cannot be "forgotten" or written too late by a future + * author, because the value every later line needs — the absolute config path — + * is produced by the same call that sets it. + * + * ── Why the fallback is conditional, and why it can only ADD working cases ─── + * + * A resolution base is only useful if a `package.json` is there to be read + * (`readHostDeclaration` reads a MANIFEST — reachability is deliberately not + * the contract, #4719). So the config's directory is adopted only when it + * actually holds one, and otherwise the CWD is kept: + * + * • config beside the app's manifest, CWD = that app → unchanged (same dir) + * • config beside the app's manifest, CWD = elsewhere → FIXED: the app wins + * • config in a manifest-less subdirectory of the app → unchanged (CWD) + * • no config at all (artifact boot, `/dist/…`) → unchanged (CWD) + * + * No layout that resolves today resolves differently after this, which is why + * it is a repair rather than a policy change. The app root is the directory + * that DECLARES; a directory with no manifest declares nothing, and anchoring + * there could only turn a working boot into an `undeclared` refusal. + */ +function anchorServedApp(configArg: string): { configPath: string; configExists: boolean } { + const configPath = path.resolve(process.cwd(), configArg); + const configExists = fs.existsSync(configPath); + const configDir = path.dirname(configPath); + servedAppRoot = + configExists && fs.existsSync(path.join(configDir, 'package.json')) + ? configDir + : process.cwd(); + return { configPath, configExists }; +} + /** * Host-anchored dynamic import: load a package **as the app being served * declares it**, falling back to the CLI's own resolution when the app does not @@ -269,10 +351,13 @@ const hostImporters = new Map(); * this file for every app-declarable optional load and fails on a bare one. * * @param hostRoot Directory holding the served app's `package.json`. Defaults to - * the process CWD — the same root `serve` reads `objectstack.config.ts` from, and - * the value its boot path computes as `hostRoot`. + * {@link servedAppRootOrCwd} — the directory `serve` read the app's + * `objectstack.config.ts` from, which is the app's own root and NOT necessarily + * the process CWD (#11185). The default is what makes this helper correct from + * every line of the file without an author having to know a root exists: the + * same reason #10769 made it a hoisted declaration rather than a binding. */ -function importFromHost(specifier: string, hostRoot: string = process.cwd()): Promise { +function importFromHost(specifier: string, hostRoot: string = servedAppRootOrCwd()): Promise { // Memoised per root so one boot shares a single host `require`, exactly as the // one mid-function `const` did before it was hoisted out here. let importer = hostImporters.get(hostRoot); @@ -537,11 +622,12 @@ export default class Serve extends Command { * and this refusal is where that request would come from. * * @param pluginSpecifier The string as the app wrote it in `plugins: [...]`. - * @param hostRoot Root of the served app; defaults to the process CWD, the - * same value `serve`'s boot path computes. + * @param hostRoot Root of the served app; defaults to + * {@link servedAppRootOrCwd}, the same value `serve`'s boot path computes and + * passes explicitly at its own call site (#11185). */ static async importConfigPlugin(pluginSpecifier: string, hostRoot?: string): Promise { - const root = hostRoot ?? process.cwd(); + const root = hostRoot ?? servedAppRootOrCwd(); // Refused BEFORE the try, and deliberately not wrapped in the // `Failed to import plugin '': …` text below: nothing was imported, // and calling a refusal an import failure sends the author looking for a @@ -1206,7 +1292,10 @@ export default class Serve extends Command { const isDev = flags.dev || process.env.NODE_ENV === 'development'; - const absolutePath = path.resolve(process.cwd(), args.config!); + // Resolves the config path AND anchors every host-anchored load in this + // file at the app that owns it (#11185) — one call, so the anchor cannot be + // written too late or left out. See `anchorServedApp`. + const { configPath: absolutePath, configExists } = anchorServedApp(args.config!); const relativeConfig = path.relative(process.cwd(), absolutePath); // ── Artifact-first fallback ────────────────────────────────────── @@ -1218,7 +1307,7 @@ export default class Serve extends Command { // `apps/objectos/objectstack.config.ts`, lifted into the framework // so any project can `objectstack start` against just a // `dist/objectstack.json`. - const configMissing = !fs.existsSync(absolutePath); + const configMissing = !configExists; let useArtifactFallback = false; let useEmptyBoot = false; @@ -1684,14 +1773,20 @@ export default class Serve extends Command { // The root of the app being served: where its `package.json` and // `objectstack.config.ts` live. `importFromHost` (module scope, top of this // file) anchors every app-declarable optional load here, and defaults to - // this same `process.cwd()`, so a load written ANYWHERE in this method — - // above this line included — resolves from the app rather than the CLI. - // That reachability is the point: see the helper's own note for the two - // shipped instances (cloud#1013, #10645) that a mid-function binding cost. + // this same value, so a load written ANYWHERE in this method — above this + // line included — resolves from the app rather than the CLI. That + // reachability is the point: see the helper's own note for the two shipped + // instances (cloud#1013, #10645) that a mid-function binding cost. + // + // NOT `process.cwd()`: the config is an ARGUMENT, so the app being served + // is the directory that config was read from, which need not be the + // directory the operator stood in (#11185). `anchorServedApp` resolved it + // at the top of this method; this reads the same answer rather than + // recomputing a second one, so the file has ONE notion of the app root. // // #4719: what the host root DECLARES is the contract; being merely // reachable through a hoisted workspace store is not, and is refused. - const hostRoot = process.cwd(); + const hostRoot = servedAppRootOrCwd(); // Cluster wiring: env-driven driver selection (mirrors OS_DATABASE_URL). // The remote driver self-registers on import; import it dynamically so it diff --git a/packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts b/packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts new file mode 100644 index 0000000000..2c8d67cccd --- /dev/null +++ b/packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #11185 — `os serve` resolves an app-declared OPTIONAL service package from the + * app it is serving, even when that app is not the process CWD. + * + * ── The defect ─────────────────────────────────────────────────────────── + * + * The config is an ARGUMENT (`objectstack serve /srv/app/objectstack.config.ts`), + * so the app being served is whatever directory that config was read from. Every + * host-anchored load nevertheless took `process.cwd()` as its resolution base, so + * with that invocation the CLI consulted the WRONG `package.json`: a package the + * app really does declare and really does carry in its own `node_modules` came + * back `undeclared`, fell through to the framework-side fallback, and boot died — + * + * Cannot find package '@objectstack/service-cluster': the host app does not + * declare it. + * host app: + * (fallback resolution also failed: Cannot find package + * '@objectstack/service-cluster' imported from …/packages/types/dist/node.mjs) + * + * Measured on the released EE 4.1.0 image as `OS_CLUSTER_DRIVER=redis` ⇒ migrate + * exits 1 ⇒ the whole stack cannot start. #10645/#10769 fixed the IMPORTER at + * these load sites (bare `import()` → `importFromHost`); this card fixes the BASE + * that importer is handed. + * + * ── Why this file spawns the real CLI ──────────────────────────────────── + * + * The base is computed by `run()` from its own argument. Nothing below `run()` + * can observe it, so an in-process test of `createHostImporter` — which is what + * `src/commands/serve-cluster-host-resolution.test.ts` pins — is green either + * way: it hands the importer a root the test itself chose, which is precisely + * the value that was wrong. Only a real `serve` process, given a real app + * directory and a real CWD that is NOT that directory, exercises it. + * + * The spawn is written out here rather than taken from `test/helpers/ + * serve-process.ts` on purpose: that helper always runs the child WITH `cwd` set + * to the app, which is the one shape this file must not use. + * + * ── The anti-vacuity floor ─────────────────────────────────────────────── + * + * The fixture packages exist ONLY in the app's `node_modules`. Nothing in this + * workspace can supply `@objectstack/service-cluster` to `packages/cli` — the + * CLI does not declare it (that is what makes it app-declarable at all), and the + * fixtures are written to a temp directory with a fake `index.js` no build + * produces. A pass therefore cannot come from the CLI's own resolution by + * accident: the marker line these fixtures print is reachable only across the + * boundary the card is about. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** `bin/run-dev.js` — this package's own entrypoint, run from TS source. */ +const CLI = resolve(HERE, '../bin/run-dev.js'); +/** The workspace `tsx` binary (an installed dependency, not a repo source input). */ +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +const CONFIG = ` +export default { + manifest: { + id: 'com.example.anchoredimport', + namespace: 'anchoredimport', + version: '1.0.0', + type: 'app', + name: 'App-Anchored Optional Import Fixture', + }, +}; +`; + +/** The gate package `serve` loads first when OS_CLUSTER_DRIVER is set. */ +const CLUSTER = '@objectstack/service-cluster'; +/** The driver it loads next, named from the env var. */ +const DRIVER = '@objectstack/service-cluster-redis'; + +const CLUSTER_MARK = '[fixture] app-local @objectstack/service-cluster loaded'; +const DRIVER_MARK = '[fixture] app-local @objectstack/service-cluster-redis loaded'; + +/** + * Stand-in for the distribution cluster gate. `checkMultiNodeAllowed` is the one + * export `serve` destructures; returning `allowed` keeps the boot walking on to + * the driver load, so one run exercises both host-anchored sites. + */ +const FAKE_CLUSTER = ` +console.error(${JSON.stringify(CLUSTER_MARK)}); +export function checkMultiNodeAllowed() { return { allowed: true }; } +`; + +const FAKE_DRIVER = ` +console.error(${JSON.stringify(DRIVER_MARK)}); +`; + +function writeAppLocalPackage(nodeModules: string, name: string, body: string): void { + const dir = join(nodeModules, ...name.split('/')); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name, version: '0.0.0-fixture', type: 'module', main: 'index.js' }), + 'utf8', + ); + writeFileSync(join(dir, 'index.js'), body, 'utf8'); +} + +/** + * An app whose optional service packages live in its OWN `node_modules`. + * + * `declare` splits the two halves of the contract: declared is the repair, + * undeclared must still be refused (#4719 — reachability is not a declaration, + * and moving the resolution base must not quietly widen what `serve` accepts). + */ +function writeApp(prefix: string, declare: boolean): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG, 'utf8'); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify( + { + name: 'anchored-import-fixture', + private: true, + type: 'module', + ...(declare ? { dependencies: { [CLUSTER]: '*', [DRIVER]: '*' } } : {}), + }, + null, + 2, + ), + 'utf8', + ); + const nodeModules = join(dir, 'node_modules'); + writeAppLocalPackage(nodeModules, CLUSTER, FAKE_CLUSTER); + writeAppLocalPackage(nodeModules, DRIVER, FAKE_DRIVER); + return dir; +} + +interface Run { stdout: string; stderr: string; both: string } + +/** + * Boot `serve` with an explicit `cwd` and an explicit config argument, collect + * output until `waitFor` matches or the child exits, then stop it. + * + * An early exit resolves rather than rejects: a boot that DIES still has to have + * said why, and the refusal case below reads exactly that. + */ +function runServeFrom( + cwd: string, + configArg: string, + waitFor: RegExp, + timeoutMs = 240_000, +): Promise { + return new Promise((resolveRun) => { + const port = String(40000 + Math.floor(Math.random() * 20000)); + const child = spawn(TSX, [CLI, 'serve', configArg, '--port', port], { + cwd, + env: { + ...process.env, + NO_COLOR: '1', + OS_DATABASE_URL: ':memory:', + OS_LOG_LEVEL: '', + OS_DISABLE_CONSOLE: '1', + // The trigger: without a non-memory driver the cluster block is skipped + // entirely and this file would measure nothing. + OS_CLUSTER_DRIVER: 'redis', + }, + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { child.kill('SIGTERM'); } catch { /* already gone */ } + resolveRun({ stdout, stderr, both: stdout + stderr }); + }; + const timer = setTimeout(finish, timeoutMs); + const onData = (chunk: unknown, stream: 'out' | 'err') => { + if (stream === 'out') stdout += String(chunk); else stderr += String(chunk); + if (waitFor.test(stdout + stderr)) finish(); + }; + child.stdout.on('data', (d) => onData(d, 'out')); + child.stderr.on('data', (d) => onData(d, 'err')); + child.on('exit', finish); + child.on('error', finish); + }); +} + +/** Matches either outcome, so a run never waits out its timeout. */ +const SETTLED = new RegExp( + `${DRIVER_MARK.replace(/[[\]]/g, '\\$&')}|does not declare it|Press Ctrl\\+C to stop`, +); + +let declaredApp: string; +let undeclaredApp: string; +/** A CWD that is not any app: no config, no manifest, nothing to resolve from. */ +let neutralCwd: string; + +beforeAll(() => { + declaredApp = writeApp('os-anchored-declared-', true); + undeclaredApp = writeApp('os-anchored-undeclared-', false); + neutralCwd = mkdtempSync(join(tmpdir(), 'os-anchored-neutral-cwd-')); +}); + +afterAll(() => { + for (const dir of [declaredApp, undeclaredApp, neutralCwd]) { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('os serve → optional service resolution is anchored at the app (#11185)', () => { + it( + 'loads an app-local-only optional package when the CWD is NOT the app', + async () => { + const run = await runServeFrom(neutralCwd, join(declaredApp, 'objectstack.config.ts'), SETTLED); + const seen = `\n--- stdout ---\n${run.stdout.slice(-4000)}\n--- stderr ---\n${run.stderr.slice(-4000)}`; + + // The load-bearing assertion. Before the fix the base was `process.cwd()` + // — the neutral directory — so the app's declaration was never read and + // the boot died before either marker was printed. + expect(run.both, `the cluster gate was not loaded from the app${seen}`).toContain(CLUSTER_MARK); + expect(run.both, `the cluster driver was not loaded from the app${seen}`).toContain(DRIVER_MARK); + expect(run.both, `serve refused a package the app DOES declare${seen}`).not.toContain( + 'does not declare it', + ); + }, + 300_000, + ); + + it( + 'still loads it when the CWD IS the app (the shape #10645 fixed stays fixed)', + async () => { + // Passes on both trees: it is the control that proves the fixture and the + // boot path are real, so a failure in the test above is the BASE and not a + // broken fixture. + const run = await runServeFrom(declaredApp, 'objectstack.config.ts', SETTLED); + const seen = `\n--- stdout ---\n${run.stdout.slice(-4000)}\n--- stderr ---\n${run.stderr.slice(-4000)}`; + expect(run.both, `the cluster gate was not loaded from the app${seen}`).toContain(CLUSTER_MARK); + expect(run.both, `the cluster driver was not loaded from the app${seen}`).toContain(DRIVER_MARK); + }, + 300_000, + ); + + it( + 'still refuses a package the app does not declare, and names the APP', + async () => { + // The other half: moving the base must not turn the #4719 declaration gate + // into "whatever is reachable". The package IS in this app's node_modules + // and is still refused — and the remedy now points at the app, which is + // where the declaration has to go. Before the fix it named the CWD, an + // unrelated directory the operator was merely standing in. + const run = await runServeFrom( + neutralCwd, + join(undeclaredApp, 'objectstack.config.ts'), + SETTLED, + ); + const seen = `\n--- stdout ---\n${run.stdout.slice(-4000)}\n--- stderr ---\n${run.stderr.slice(-4000)}`; + + expect(run.both, `the declaration gate did not fire${seen}`).toContain('does not declare it'); + expect(run.both, `the remedy does not name the app being served${seen}`).toContain( + `host app: ${undeclaredApp}`, + ); + expect(run.both, `the remedy still names the CWD${seen}`).not.toContain( + `host app: ${neutralCwd}`, + ); + expect(run.both, `reachability substituted for declaration${seen}`).not.toContain(CLUSTER_MARK); + }, + 300_000, + ); +}); + +describe('os serve → the anchor is wired where it cannot be forgotten', () => { + const SERVE_SOURCE = readFileSync(resolve(HERE, '../src/commands/serve.ts'), 'utf8'); + + it('resolves the config path and the app root in ONE call', () => { + // If `run()` ever computes the config path itself again, the anchor becomes + // a separate statement someone can write too late — or not at all — and the + // behavioural tests above would be the only thing standing between that and + // a silent return to CWD-based resolution. + expect(SERVE_SOURCE).toContain( + 'const { configPath: absolutePath, configExists } = anchorServedApp(args.config!);', + ); + expect(SERVE_SOURCE).not.toMatch( + /const absolutePath = path\.resolve\(process\.cwd\(\), args\.config!\)/, + ); + }); + + it('defaults every host-anchored load to the served app, not the CWD', () => { + expect(SERVE_SOURCE).toContain( + 'function importFromHost(specifier: string, hostRoot: string = servedAppRootOrCwd())', + ); + expect(SERVE_SOURCE).toContain('const hostRoot = servedAppRootOrCwd();'); + expect(SERVE_SOURCE).toContain('const root = hostRoot ?? servedAppRootOrCwd();'); + }); + + it('reads the app root through a function, never a module-scope copy', () => { + // A `const` captured at module-evaluation time would freeze the pre-boot + // answer (`process.cwd()`) into every call site, which is the defect wearing + // a different hat. + expect(SERVE_SOURCE).toMatch(/^function servedAppRootOrCwd\(\): string \{$/m); + expect(SERVE_SOURCE).not.toMatch(/\b(?:const|let|var)\s+servedAppRootOrCwd\b/); + }); +}); From 111f038ecf9f5295a01810722fd273ec5daf26a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 13:48:31 +0000 Subject: [PATCH 2/2] docs(cli): drop the false absolute from anchorServedApp's docblock (#11185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed "No layout that resolves today resolves differently after this". Measured, that is false: there is a fifth row. When the CWD's manifest DECLARES the optional package and the served app's does not, the load moves off the declared leg (hostRequire.resolve under the CWD) onto createHostImporter's fallback leg — which, because importFromHost passes no fallbackImport (#11157's residue), is a bare import() inside @objectstack/types. It still succeeds wherever Node's node_modules walk from there reaches the package (a hoisted monorepo whose root manifest declares it), and refuses where it cannot (a global/npx CLI serving an app elsewhere, package installed only beside the operator). Remedy named in place: declare the package in the SERVED app's own package.json, which is what #4719 asks for regardless. Comment text only — no behaviour change, no code touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- packages/cli/src/commands/serve.ts | 34 +++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index fd95a98200..50c5ee3cf7 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -271,7 +271,7 @@ function servedAppRootOrCwd(): string { * author, because the value every later line needs — the absolute config path — * is produced by the same call that sets it. * - * ── Why the fallback is conditional, and why it can only ADD working cases ─── + * ── Why the fallback is conditional, and the one row that MOVES ─────────── * * A resolution base is only useful if a `package.json` is there to be read * (`readHostDeclaration` reads a MANIFEST — reachability is deliberately not @@ -283,10 +283,34 @@ function servedAppRootOrCwd(): string { * • config in a manifest-less subdirectory of the app → unchanged (CWD) * • no config at all (artifact boot, `/dist/…`) → unchanged (CWD) * - * No layout that resolves today resolves differently after this, which is why - * it is a repair rather than a policy change. The app root is the directory - * that DECLARES; a directory with no manifest declares nothing, and anchoring - * there could only turn a working boot into an `undeclared` refusal. + * Those four rows are unchanged. A FIFTH one is NOT, and this comment will not + * claim otherwise — an absolute here ("no layout that resolves today resolves + * differently") reads to the next author as a licence to skip the check: + * + * • config beside the app's manifest, CWD = elsewhere, the CWD's manifest + * DECLARES the package and the served app's does NOT → the load moves off + * the declared leg onto the fallback leg: it still succeeds wherever that + * fallback can reach the package, and refuses where it cannot. + * + * MEASURED. Before: hostRoot was the CWD, so `declared` was true and + * `hostRequire.resolve` found the package under the CWD's `node_modules`. + * After: hostRoot is the served app, `declared` is false, and the load goes to + * `createHostImporter`'s fallback — which is a bare `import()` physically + * inside `@objectstack/types`, because `importFromHost` passes no + * `fallbackImport` (#11157's residue). Node ESM walks `node_modules` UPWARD + * from there, so the common shape survives: in a hoisted monorepo whose ROOT + * manifest declares the package while the served `apps/foo/package.json` does + * not, that walk reaches the same hoisted store and boot is what it always + * was — only the leg changed. It genuinely refuses only where the walk cannot + * reach the package: a global / `npx` CLI serving an app elsewhere, with the + * optional service installed only beside the operator. Remedy for anyone who + * lands there — declare the package in the SERVED app's own `package.json`, + * which is what #4719 asks for regardless. + * + * So this narrows one row toward the declaration #4719 already requires; it + * widens nothing. The app root is the directory that DECLARES — a directory + * with no manifest declares nothing, which is why anchoring at one is not + * attempted: it could only turn a working boot into an `undeclared` refusal. */ function anchorServedApp(configArg: string): { configPath: string; configExists: boolean } { const configPath = path.resolve(process.cwd(), configArg);