Skip to content

createHostRequire 认 NODE_PATH,于是 #4699 立下的「host app 必须自己声明」在 pnpm 工作区里根本没被强制 #4719

Closed

Description

@os-zhuang

发现于 cloud#1018 的实测。#4699 本身是对的,这条说的是它建立的契约没有被执行——一个没声明依赖的 app 照样能解析成功,于是 ADR-0093 D5 那道墙在最常见的部署形态里是假绿。

契约 vs 实际

packages/cli/src/utils/import-from-host.ts 的注释把契约写死了:

The fix is to resolve from the host app's root and import the resolved absolute path.

D5 的报错也是这么教 operator 的:

• add @objectstack/organizations (the enterprise multi-org runtime) to THIS APP
    — declare it in the app's package.json and install; the CLI resolves it from the
      app, not from the framework it is linked out of

但 createHostRequire 返回的是一个 CJS require:

export function createHostRequire(hostRoot: string = process.cwd()): NodeRequire {
  return createRequire(join(hostRoot, 'package.json'));
}

CJS 解析认 NODE_PATH(Module.globalPaths)。而 pnpm 生成的 bin shim 第一件事就是导出它——下面是 cloud 仓 apps/objectos-ee/node_modules/.bin/objectstack 的原文:

if [ -z "$NODE_PATH" ]; then
  export NODE_PATH="/…/cloud/node_modules/.pnpm/node_modules"
else
  export NODE_PATH="/…/cloud/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
...
exec node "$basedir/../@objectstack/cli/bin/run.js" "$@"

pnpm dev / pnpm start / objectstack dev spawn 出来的 serve 子进程,全都带着这个 NODE_PATH。任何被工作区里任意一个包传递依赖到的包,都躺在那个 hoisted 虚拟 store 里,于是 hostRequire.resolve() 成功——跟 host app 声明了什么毫无关系。

实测(cloud 仓 apps/objectos-ee,它当时没有声明 @objectstack/organizations)

$ node -e "…createRequire('apps/objectos-ee/package.json').resolve('@objectstack/organizations')"
host resolve -> FAILED MODULE_NOT_FOUND

$ NODE_PATH=<repo>/node_modules/.pnpm/node_modules node -e "…同一行…"
host resolve -> /…/cloud/packages/organizations/dist/index.cjs

对应到真实启动:

  • pnpm start(经 shim,带 NODE_PATH)→ boot 成功,插件表里有 Organizations,D5 一声不吭;
  • node node_modules/@objectstack/cli/bin/run.js serve …(不经 shim)→ ✖ FATAL: tenancy posture 'isolated' was requested but @objectstack/organizations could not be loaded → exit 1。

同一个 app、同一份 package.json、同一个 posture,只因为进程是怎么被拉起来的,走出两种结果。

为什么这是「声明未被执行」而不是小事

  1. 守卫的强度取决于调用形状。D5 存在的意义是「要求了隔离就不能假装有」,但它现在只在不经 pnpm shim 的调用里生效。经 shim 的那条路上,一个从没声明过依赖的 app 也能过关。
  2. 它靠的是别人的声明。cloud 那台 EE 能解析到,是因为 @objectstack/objectos-runtime(它的依赖)声明了 @objectstack/organizations。那是一条没人写下来的传递路径:删掉别的包里的一行,这台 app 就炸,而 diff 里看不出任何关系。
  3. 报错会撒谎。真出问题时 D5 说「declare it in the app's package.json」,但 CLI 从来没检查过那件事——照做当然能修好,可它并不是它检查的东西。
  4. 这正是「lenient 消费端把错误藏起来」的形状:宽松的不是 ?? 兜底,而是解析路径本身。

可能的方向(需要 maintainer 定)

  1. 解析后校验来源:hostRequire.resolve(pkg) 拿到路径后,确认它落在 host app 能看见的 node_modules 里(而不是 NODE_PATH 全局路径),否则按「未安装」处理。最贴近 fix(cli): resolve @objectstack/organizations from the host app (cloud#1013) #4699 的本意,代价是要判定「host app 能看见」的边界,pnpm / npm / yarn 布局各不相同。
  2. 先读 host 的 package.json:只有当包名出现在 host app 声明的 dependencies / devDependencies 里才尝试解析。最严格、最像「声明即执行」,而且实现只有几行;代价是对「靠 hoisting 跑着的现有部署」是破坏性变更——但那些部署本来就在未声明状态下运行。
  3. 改用 ESM 解析(import.meta.resolve 以 host package.json 为 parent)。ESM 不认 NODE_PATH,问题自然消失,且与 import() 实际使用的语义一致;代价是要处理 CJS-only 包的解析差异。

倾向 2:它把契约变成机器可检的东西——「app 声明了才算数」——错误在启动那一刻就红掉,而不是等到某个换了调用形状的部署上才炸。1 是同一意图的弱化版(仍在猜「可见性」),3 修得干净但把一个解析语义问题换成另一个。无论选哪个,都建议在 fail-fast 的文案里区分「你没声明」和「你声明了但装的东西坏了」,这两种今天都塌成同一条 MODULE_NOT_FOUND。

关联

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Aug 3, 2026
  2. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    ⚠️ 前提正在移动 —— 本单的目标文件即将被删除,认领前务必先读这条

    PR #4857(#4700,已推送、复核中)删除了 packages/cli/src/utils/import-from-host.ts,把 createHostRequire / createHostImporter 迁到共享位置 packages/types/src/node.ts(经 @objectstack/types/node 子路径导出),CLI 改为消费它。

    对本单的影响,逐条说清楚:

    1. 缺陷本身不变,仍然成立。 fix(verify,qa): 从 host app 解析企业版 organizations 包 —— 共享解析器落到 @objectstack/types/node (#4700) #4857 只做搬迁,没有改动 NODE_PATH 相关行为 —— 这是我在派发 @objectstack/verify 的 bootStack 与 qa dogfood 同样用裸 import 解析 cloud 私有包 —— 与 cloud#1013 同一缺陷类 #4700 时明确要求的(「若顺手看出 NODE_PATH 那条该怎么修,写进报告,不要在本 PR 里改」),正是为了避免两个 PR 在同一个文件上撞车。所以 CJS createRequire 认 Module.globalPaths 这个洞会原样搬到新位置。

    2. 修复落点变了:packages/cli/src/utils/import-from-host.ts → packages/types/src/node.ts。正文里引用该文件路径与注释的段落,在 fix(verify,qa): 从 host app 解析企业版 organizations 包 —— 共享解析器落到 @objectstack/types/node (#4700) #4857 合入后需要按新位置重读。

    3. 影响面变大了,这是最需要注意的一点。 原先这段解析逻辑只有 CLI 一个消费者;搬到 @objectstack/types/node 之后,packages/verify 与 packages/qa/dogfood 也在用同一个解析器。也就是说本单的三个候选方向(校验解析来源 / 先读 host package.json / 改用 ESM 解析)现在会同时改变 verify 与 dogfood 的行为,不再是 CLI 的局部决定。方案 2(最严格那条)对这些消费者是否也合适,需要在选型时一并考虑 —— 尤其 dogfood 的测试夹具可能正依赖当前的宽松解析。

    4. 车道归属可能要重锚。 本单现挂 domain:cli,但按「domain:* = 修复落地的那个包」的锚定规则,修复若落在 packages/types,归属就不再是 cli。等 fix(verify,qa): 从 host app 解析企业版 organizations 包 —— 共享解析器落到 @objectstack/types/node (#4700) #4857 合入后我会重新分诊并调整标签。

    因此:在 #4857 合并之前不要认领本单。 现在动手会对着一个即将消失的文件写代码。#4857 合入后我会回来更新正文的路径引用与车道标签,届时本单重新可派。

    (本会话不认领本单,仅作为 #4700 的派发方登记连带影响。)


    Generated by Claude Code

  3. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    ✅ #4857 已合并,本单重新可派 —— 路径与影响面更新如下

    前一条告示里说的迁移已落地(PR #4857 / #4700 已进 main)。按承诺回来更新,认领前请以本条为准,正文里的文件路径已过时。

    1. 修复落点

    旧:packages/cli/src/utils/import-from-host.ts        ← 已删除
    新:packages/types/src/node.ts                        ← 唯一的源
        经 @objectstack/types/node 子路径导出(非根导出)
    

    正文中引用 import-from-host.ts 及其注释的段落,请对照新文件重读。#4700 的 dev 已核实:createHostRequire / createHostImporter 的可执行代码与迁移前逐字相同,diff 只有注释措辞(CLI → importing package)。NODE_PATH 缺陷既未修也未放大,只是换了住址 —— 本单描述的缺陷完整成立。

    2. ⚠️ 影响面从 1 个消费者变成 3 个,这是本次更新最重要的一点

    迁移前,这段解析逻辑只有 CLI 用。现在:

    消费者 用途
    packages/cli serve 解析企业版 @objectstack/organizations
    packages/verify bootStack() —— #4700 新接入,并新增 BootOptions.hostRoot
    packages/qa/dogfood enterprise 门的探测 —— #4700 新接入

    正文里那三个候选方向现在会同时改变三个包的行为,不再是 CLI 的局部决定:

    建议:接手时先分别确认三个消费者在新规则下的行为,再动手;若发现某个方向会打挂 dogfood 的门,把它写进报告而不是自行放宽。

    3. 车道标签保持 domain:cli

    按「domain:* = 修复落地的那个包」的锚定规则,文件现在在 packages/types。但我保持 domain:cli 不变,理由:packages/types/src/node.ts 是 node-only 的工具模块(根导出零 node: 内建,由 import 图测试结构性隔离),消费者是 cli / verify / qa 三个工具面,且本单要执行的契约是 ADR-0093 D5「host app 必须自己声明」 —— 那是 CLI 侧的契约。它不落在 spec 或 devx 任何一个他方车道内,所以标签怎么叫都不会造成车道冲突;若 engine 车道认为该归他们,直接说,让行。

    4. 状态

    pm:queue 保留,未认领,现在可以正常派发了。


    Generated by Claude Code

  4. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Collaborator

    认领(PM 分派)—— 维护者已拍板:方向 2

    • session:session_018iARDqtrhQgz6fVHDeDkbQ
    • 分支:claude/issue-4719-host-declared-resolution
    • 车道:domain:cli(按前一条告示的裁定保持;落点 packages/types/src/node.ts,契约属 ADR-0093 D5 / CLI 面)

    维护者选定方向 2:只有包名出现在 host app 声明的 dependencies / devDependencies 里才尝试解析 —— 声明即执行,错误在启动那一刻就红,而不是等某个换了调用形状的部署上才炸。

    派发时设定的硬约束(取自上面第二条告示的影响面分析):

    1. 三个消费者(cli / verify / qa-dogfood)都要在新规则下逐一验证,不能只验 CLI。
    2. dogfood 那条是红线:@objectstack/verify 的 bootStack 与 qa dogfood 同样用裸 import 解析 cloud 私有包 —— 与 cloud#1013 同一缺陷类 #4700 刚把两道 enterprise 门从「恒假探测」修成「真能跑起来」。若新规则会把它们打回 skip,修法是补夹具的声明,不是放宽规则 —— 用一个 declared ≠ enforced 的修复制造另一个,不可接受。
    3. fail-fast 文案要区分「你没声明」和「你声明了但装的东西坏了」,这两种今天都塌成同一条 MODULE_NOT_FOUND。

    Generated by Claude Code

  5. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Collaborator

    审核通过 — PR #4999 已取消草稿并挂上 auto-merge(SQUASH);方向 2 按裁决落地

    红线守住了,而且是以最干净的方式:dogfood 的 rls-multitenant 门在改动前后都是 skip,原因实测确认是 @objectstack/organizations 在本仓工作区任何位置都不存在(连 NODE_PATH 指向 hoisted store 也 MODULE_NOT_FOUND)—— 不是新规则打回的。零夹具被放宽,零门从"真能跑"退回 skip;需要补声明的夹具全部按"补声明"处理,包括一个此前只被物理安装、manifest 从不提及的 @fixture/throws-on-load —— 那正是本单要消灭的形态,在测试夹具里也不豁免。

    三个消费者逐一验证(cli 589 / verify 12 / dogfood 475 全绿),含真实进程层的关键反向证明:改前,一个未声明任何依赖的 app 仅因环境里多了 NODE_PATH 就把 walled posture 起起来,输出里带着 ✓ Server is ready —— NODE_PATH got an undeclared app past the D5 wall。改后同一形态被拒,e2e 新增了 pnpm bin shim 的原样复现夹具。

    peerDependencies 收不收,答案不是拍的:serve.ts 里早就存在私有的 hostDeclaresDependency(#1597),读的正是这四个字段,注释理由与本单几乎逐字相同 —— 本仓早已在一个地方把这题做对了,缺陷其实是"两个消费者对同一问题给了两个答案"。本 PR 顺手把私有实现改为委托共享 owner(同文件、在范围内),declared 在全仓只剩一种方言(PD#12)。bundleDependencies 不收的理由也站得住(名字数组,成员必须同时在 dependencies 里,不可能是唯一声明)。

    修法成立的关键前提有核对:ESM 裸 import() 不认 NODE_PATH,且 tsup 的 cjs 产物里 import() 原样保留未被降级成 require —— 否则退路会把洞重新打开。createHostImporter 签名从 (NodeRequire) 改为 (hostRoot: string) 是公开导出变更,changeset 已写 FROM → TO。

    ⚠️ 转达 cloud 侧(本仓无法验证,按 dev 要求由 PM 转达)

    方向 2 落地后,OS_TEST_MULTI_ORG_ENABLED=1 的运行从此要求被测 app 自己声明 @objectstack/organizations —— 只作为别人的传递依赖被 hoist 到不再够。这正是方向 2 的预期效果,也正是 cloud#1018 观察到的那台 EE app 的状态;补救就是在那个 app 的 package.json 里补一行声明,探测失败文案已把这句话明写。cloud 车道的维护者/PM 请在升级到含本 PR 的版本前给 apps/objectos-ee 补上声明,否则 enterprise 门会从"真能跑"变成带精确文案的硬失败(这是门在工作,不是回归)。

    落点 10 文件 = types(owner)+ 三个消费者 + changeset,与 #4994(同 serve.ts,已先合)在不同区段,无冲突。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions