Skip to content

fix(cli): anchor serve's optional-package resolution at the app, not the CWD (#11185) - #11349

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-11185-app-anchored-optional-import
Aug 23, 2026
Merged

fix(cli): anchor serve's optional-package resolution at the app, not the CWD (#11185)#11349
os-zhuang merged 2 commits into
mainfrom
claude/issue-11185-app-anchored-optional-import

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#11185

os 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.

Measured on the released EE 4.1.0 image as OS_CLUSTER_DRIVER=redis ⇒ migrate exits 1 ⇒ the whole
stack cannot start.

What was still broken, measured at origin/main

The card's measured trace (Cannot find package '@objectstack/service-cluster' imported from …/packages/cli/dist/commands/serve.js) is the bare import() leg, and that leg is already
closed — #10645 routed these loads through importFromHost and #10769 hoisted the helper to
module scope. Re-measured here on a real serve process against a fixture app whose cluster
packages exist only in its own node_modules:

invocationorigin/main
CWD is the app, serve objectstack.config.tsapp-local packages load — #10645 holds
CWD is not the app, serve /srv/app/objectstack.config.tsboot dies

The second row is the residue, and it is exactly the route the card names as preferred
("createRequire anchored at the app config directory"). On origin/main:

✗ Cannot find package '@objectstack/service-cluster': the host app does not declare it.
host app: /tmp/os-anchored-neutral-cwd-PNv0hI ← the CWD, not the app
(fallback resolution also failed: Cannot find package '@objectstack/service-cluster'
imported from …/packages/types/dist/node.mjs)

The fix

run() now resolves the config path and the app root in one call, anchorServedApp(), and
every host-anchored load in the file defaults to that root:

  • importFromHost(specifier, hostRoot = servedAppRootOrCwd())
  • Serve.importConfigPlugin's hostRoot ?? servedAppRootOrCwd()
  • the boot body's const hostRoot = servedAppRootOrCwd()

One call, because the anchor must not be a statement a future author can write too late or leave
out: the absolute config path every later line needs is produced by the same call that sets the
root. servedAppRootOrCwd() is read as a function at each use — a module-scope const would
freeze the pre-boot answer (process.cwd()) into every call site, which is the defect wearing a
different hat.

What is unchanged, and the one row that DOES move

The config's directory is adopted only when it holds a package.json, and the CWD is kept
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.

layoutbeforeafter
config beside the app's manifest, CWD = that appapp rootunchanged (same directory)
config beside the app's manifest, CWD = elsewhereCWD — brokenthe app
config in a manifest-less subdirectory of the appCWDunchanged
no config at all (artifact boot, the CWD's own dist/…)CWDunchanged
config beside the app's manifest, CWD = elsewhere, and only the CWD's manifest declares the packageCWD — declared, found under the CWD's node_modulesthe app — undeclared, so the load moves to the fallback leg: it still succeeds wherever that fallback reaches the package, and refuses where it cannot

An earlier revision of this body — and of anchorServedApp's docblock — asserted "no layout that
resolves today resolves differently after this"
. That absolute is false, and the fifth row is
the counter-case. Both texts now carry the fifth row; the source comment was the half that mattered
most, because an absolute asserted in the code reads to the next author as a licence to skip the
check.

How far the fifth row actually bites, measured.importFromHost builds its importer as
createHostImporter(hostRoot) with no fallbackImport (#11157's residue), so the undeclared
leg is a bare import() physically inside @objectstack/types, and Node ESM walks node_modules
upward from there:

  • Survives — and this is the common shape. A hoisted monorepo whose ROOT manifest declares the
    package, serve apps/foo/objectstack.config.ts, apps/foo/package.json silent: the walk reaches
    the same hoisted store and the boot is what it always was. Only the leg changed.
  • Genuinely refuses. Only where that walk cannot reach the package — a global / npx CLI
    serving an app elsewhere, with the optional service installed only beside the operator.

Checked against packages/types/src/node.ts and with a two-case probe of Node's own bare-specifier
walk-up: an importer sitting inside a hoisted store resolves the package regardless of the CWD,
while an importer in an unrelated install with the package only beside the CWD raises
ERR_MODULE_NOT_FOUND — which createHostImporter re-throws as undeclared.

Remedy for anyone who lands in the refusing case: 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 the contract already requires, and widens nothing.
The #4719 declaration gate itself is untouched: a package present in the app's node_modules but
absent from its package.json is still refused. Its remedy now names the app being served instead
of an unrelated directory the operator happened to be in.

Route chosen, and the one rejected

Chosen: anchor the resolution base at the app's config directory. It generalises — the next
app-declared optional service, and any third-party cluster driver, is covered with nobody
remembering this file exists.

Rejected: declare @objectstack/service-cluster* in packages/cli's own manifest. It would
make the open-core CLI take a published dependency on packages it never imports (the coupling
the non-literal specifier in serve.ts exists to avoid), it fixes one instance and leaves the
class open — the same shape #10769 and #11157 both record — and, decisively, it does not fix
this defect at all
: with the wrong base the app's declaration is never read, so a CLI-side
declaration changes nothing for an app addressed by config path. It is also a published-manifest
change, which this card was dispatched not to make.

The pin, and both directions

packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts spawns the realserve
process against a fixture app whose @objectstack/service-cluster and
@objectstack/service-cluster-redis are written into that app's node_modules alone — nothing in
this workspace can supply them to packages/cli, which is what makes a pass unfakeable. It writes
its own spawn rather than using test/helpers/serve-process.ts, because that helper always runs
the child with cwd set to the app, which is the one shape this file must not use.

The in-process pin that already exists
(src/commands/serve-cluster-host-resolution.test.ts) is green on both trees by construction: it
hands createHostImporter a root the test itself chooses, and the root is precisely what was
wrong.

Reverse-verification — packages/cli/src/commands/serve.ts restored to origin/main (confirmed on
disk: anchorServedApp 0 occurrences, const hostRoot = process.cwd(); 1), the pin re-run, then
restored by an EXIT trap:

× loads an app-local-only optional package when the CWD is NOT the app 5452ms
✓ still loads it when the CWD IS the app (control)
× still refuses a package the app does not declare, and names the APP 5818ms
× resolves the config path and the app root in ONE call
× defaults every host-anchored load to the served app, not the CWD
× reads the app root through a function, never a module-scope copy
Tests 5 failed | 1 passed (6)

The single pass is the control, which is what isolates the failures to the base rather than to
a broken fixture. With the fix: Test Files 163 passed (163) · Tests 1839 passed (1839)
(@objectstack/cli, whole suite).

#11157 is not made redundant by this

Measured on this branch, undeclared app, CWD elsewhere:

✗ Cannot find package '@objectstack/service-cluster': the host app does not declare it.
host app: /tmp/os-undecl-Z5N7g3 ← now the app, correct
(fallback resolution also failed: … imported from …/packages/types/dist/node.mjs
(the caller did not pass `fallbackImport`, so that fallback resolved from
@objectstack/types, which can see only its own dependencies …

This PR corrects the base of the declared branch — which manifest is read and whose
node_modules is searched. #11157 corrects the fallback base of the undeclared branch, and
that leg is still live and unchanged here. Two different bases; #11157 still has work to do.

Verification

Everything in this first list is at 0c93b6c1, the last commit that changed code. 111f038e is
comment text only — no code, no test, no changeset — and is re-verified in its own list underneath.

  • pnpm --filter @objectstack/cli exec vitest run — 163 files, 1839 tests, all passing
  • pnpm --filter @objectstack/cli typechecktsc --noEmit, exit 0
  • pnpm lint (eslint . --no-inline-config, whole repo, not narrowed) — exit 0
  • the 17 path-derived gate families from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, plus the convention-triggered set for a new test file — all exit 0
  • pnpm check:type-check-debt --re-measure"33 ledger entr(ies) re-measured in 377.5s, 1897 raw tsc error(s) total, none above its recorded number" (it first refused on an unbuilt @objectstack/service-knowledge; the closure was built and it was re-run)

Patch round 111f038e — comment text only

The only diff in this round is anchorServedApp's docblock. Re-run at 111f038e, clean tree:

  • pnpm --filter @objectstack/cli typecheck> tsc --noEmit, VERDICT command-exit 0
  • pnpm --filter @objectstack/cli exec vitest run test/serve-app-anchored-optional-import.e2e.test.ts
    Test Files 1 passed (1) · Tests 6 passed (6), so the comment edit did not disturb the pin
  • pnpm exec eslint --no-inline-config --format json packages/cli/src/commands/serve.ts — exit 0,
    filesLinted=1 errors=0 warnings=0. Narrowed on purpose, and the narrowing is a measurement:
    the file count is read from eslint's own JSON output, and this repo's single eslint.config.mjs
    never enables type-aware linting for any file (no parserOptions.project, no typed
    @typescript-eslint rules — declared, and positively controlled, in that config's own header), so
    a comment-only edit inside one file cannot move any untouched file's verdict. CI runs the whole
    repo regardless.
  • ratchet and serve.ts-derived gate families, re-run at 111f038e, all exit 0: check:nul-bytes,
    check:slot-lookup, check:test-source-alias, check:type-source-resolution,
    check:published-files, check:route-envelope, check:cross-package-test-inputs
  • NOT re-run: check:type-check-debt --re-measure and the full 1839-test suite. A docblock cannot
    move a tsc error count or a test outcome, and both are green at 0c93b6c1 above; CI runs them on
    this head anyway.

Generated by Claude Code


Generated by Claude Code

…the CWD (#11185)
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@github-actions

github-actionsBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 7 documentable anchor(s).

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d39569239b46f6ef68e8af438a6e78ccb62d6c92.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d39569239b46f6ef68e8af438a6e78ccb62d6c92packageMentionDocs.

Which tree this was computed on

This run read content/docs from 60ffe301b42afd95fbdc6e1b43e62aef6909a0a8 — the merge of head 111f038ecf9f5295a01810722fd273ec5daf26a6 into base d39569239b46f6ef68e8af438a6e78ccb62d6c92, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 60ffe301b42afd95fbdc6e1b43e62aef6909a0a8 && git checkout 60ffe301b42afd95fbdc6e1b43e62aef6909a0a8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d39569239b46f6ef68e8af438a6e78ccb62d6c92 111f038ecf9f5295a01810722fd273ec5daf26a6 && git checkout -B drift-repro d39569239b46f6ef68e8af438a6e78ccb62d6c92 && git merge --no-ff 111f038ecf9f5295a01810722fd273ec5daf26a6
node scripts/docs-audit/affected-docs.mjs --json d39569239b46f6ef68e8af438a6e78ccb62d6c92

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d39569239b46f6ef68e8af438a6e78ccb62d6c92 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…11185)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude