Skip to content

Commit bd25e89

Browse files
claude[bot]claude
andauthored
fix(cli): the published entry resolves its commands from dist/, whatever an ambient NODE_ENV says (#17927)
Fixes #12271 Clause-②: no Dispatched by the `domain:cli` execution seat (#6024) under triage's re-grade [`5650901283`](#12271 (comment)) (p3 → **p2**, `Task` → **Bug**, file face unlocked to `start.ts`). Branch base `5741ff10c`, the parked worktree merged forward and **fully rebuilt** before any reading was taken — freshness proven by content, not mtime: a symbol introduced by merged commit `eadcde6d8` (`KNOWN_UNSUPPORTED_JSON_SCHEMA_PATTERNS`) occurs twice in `packages/cli/dist/commands/generate.js` and twice in its source. --- ## 1. What was actually broken — and it is not what the card's title says The card is titled around the `os dev` **compile child** inheriting an ambient `NODE_ENV=development`. That inheritance is real, but it is a symptom. Reading the failure output rather than the card: ``` [MODULE_NOT_FOUND] Warning: ModuleLoadError task: findCommand (doctor) plugin: @objectstack/cli message: [MODULE_NOT_FOUND] import() failed to load …/packages/cli/src/commands/doctor.ts: Cannot find module './registry' Require stack: - …/packages/formula/src/index.ts ``` ⭐ The module that failed to load is **`packages/cli/src/commands/doctor.ts`** — one of the CLI's *own* command modules. The casualty is the command table, not the user's config. `@oclif/core@4.13.3`'s `lib/config/ts-path.js` skips its TypeScript path lookup only when `isProd()`, defined in `lib/util/util.js` as a negated `['development', 'test'].includes(process.env.NODE_ENV ?? '')`. Under either value it (a) rewrites the command target from the declared `./dist/commands` to `src/commands` and (b) calls `registerTsx()`. tsx honours the tsconfig of the **current working directory**, so an application that maps a CommonJS workspace package to its TypeScript source for *type* resolution steers this CLI's *runtime* module graph into `.ts` files — and Node's CJS resolver then walks their extensionless siblings and knows nothing about `.ts`. Three consequences the card did not have: - It is **not specific to `compile`**, or to any command. `os --version` reproduces it. - It reaches invocations with **no parent at all** — `os serve --dev`, `os start` — so ⛔ no child-environment scrub can fix it. This is the reason triage measured shape B as not fixing the card. - `NODE_ENV=test` is the **second** value oclif treats as non-production, and it had never been measured on this card. vitest exports it on its own worker. ## 2. The spawn census (acceptance item 3) — measured here, with controls ⛔ Not taken from the card, the previous dev's report, or triage's comment. Read through **TypeScript's own parser** (`ts.createSourceFile`), resolving `child_process` bindings through named / aliased / namespace / default imports, `require()` destructuring and `await import()` destructuring, then matching call expressions against those bindings. Script: `census.mjs`, run at `149135743`. **Controls, both required to pass before the census prints:** | control | expectation | result | |---|---|---| | positive fixture — 7 spawn calls across every import spelling, plus one look-alike identifier that must NOT match | 7 | **7 — PASS** (`spawn, spawnSync, execFileSync, exec, execFile, execSync, fork`) | | negative fixture — a local binding *named* `spawn`, and the word inside a string literal | 0 | **0 — PASS** | **Census — 6 child-process spawn sites, not 5:** | site | API | `env` handed to the child | |---|---|---| | `dev.ts:349` | `spawnSync` | ⛔ no `env` property — inherits `process.env` whole | | `dev.ts:586` | `spawn` | `env: localEnv` (a spread of `process.env`) | | **`dev.ts:724`** | **`execSync`** | ⛔ **no `env` property — inherits `process.env` whole** | | `dev.ts:821` | `spawnSync` | `env: process.env` | | `start.ts:238` | `spawnSync` | `env: process.env` | | `start.ts:435` | `spawn` | `env: localEnv` | Dark-instrument control: the API names occur as bare words 13 times in `dev.ts` and 6 times in `start.ts`; the site counts are 4 and 2, and the gap is imports and prose. ⚠️ **`dev.ts:724` is the disagreement, and it is a real site all four previous numbers missed.** The card, the previous dev's report, triage's own count and the seat's re-count all say **5**. They agree because they all counted `spawn` / `spawnSync`. `dev.ts:724` is an `execSync` reached through `const { execSync } = await import('child_process')` — a dynamic import inside a function body, invisible to a scan looking for the two static names. It is the workspace-root branch of `os dev`, which delegates to `pnpm [--filter X] dev`. ⛔ **It is deliberately not touched**, and that is a classification rather than an omission: it spawns a **user's own workspace script**, not the `os` CLI. `NODE_ENV=development` is meaningful and expected there, and scrubbing it would change the behaviour of arbitrary user dev scripts. It is in the census because the census was asked for; it is outside the defect class because the defect class is *this CLI resolving its own commands*. ## 3. Which sites were scrubbed, and why that set is **empty** **None.** The fix is one declaration in `packages/cli/bin/run.js`: ```js settings.enableAutoTranspile = false; ``` oclif checks `settings.enableAutoTranspile ?? settings.tsnodeEnabled` **ahead of** `isProd()`, so `false` skips both the source redirect and the tsx registration under every value of `NODE_ENV`. The argument for the entry over the six sites, point by point: - **A child-env scrub cannot satisfy acceptance item 1.** `os serve --dev` and `os start` are top-level processes. There is no parent. - **It is the contract this repo already wrote down.** `bin/run.js` is the BUILT entry (`bin.objectstack` / `bin.os`; `package.json` declares its command table over `./dist/commands`); `bin/run-dev.js` is the SOURCE entry. `scripts/check-cli-test-child-env.mjs` rule 3 enforces exactly that division on every *test* that spawns the CLI, with no baseline. The entry had simply never asserted it about itself. - **Every spawn site hands the child `process.argv[1]`**, i.e. this same entry — so fixing the entry fixes all of them, plus every future one, with no per-site convention to keep true. - **A per-site scrub changes what the child DOES.** `NODE_ENV` is read by product code (`start.ts`'s production default, the crypto posture, plugin-auth's origin gate). Rewriting it at six spawns to work around a module-resolution bug is the lenient-consumer shape Prime Directive #12 refuses. The entry-point declaration changes only *which of two already-declared code paths* oclif loads. - ⛔ **Not a `TSX_TSCONFIG_PATH` pin either** (what `bin/run-dev.js` carries). That shim genuinely executes TypeScript, so all it can do is aim the transpiler; it cannot even do that in-process and pays a full re-exec. This entry executes no TypeScript, and a published install has no `packages/cli/tsconfig.json` to aim at — `files` names `dist` only. `dev.ts` and `start.ts` carry comment-only changes, both required by Prime Directive #10 because **this change** moved what they claim. See §6. ## 4. Two-leg ablation with the `production` control (acceptance items 1 and 2) Run from the committed state. `exit 124` = compiled, booted, and **still serving** when the 60 s timeout killed it; `exit 1` = died. `NODE_ENV` is the only variable; `OS_SECRET_KEY` supplied so the production arm is not refused on crypto policy. Exit codes captured before any pipe. **End to end, `examples/app-crm` and `examples/app-showcase` (both carry sibling-src `paths`):** | invocation | leg | `NODE_ENV=development` | `NODE_ENV=production` (control) | |---|---|---|---| | `os dev --compile --fresh` | fix REVERTED | **exit 1** · 9 hits of `Cannot find module './registry'` | exit 124 · 0 hits | | `os dev --compile --fresh` | fix IN PLACE | **exit 124** · 0 hits | exit 124 · 0 hits | | `os serve --dev` | fix REVERTED | **exit 1** · 9 hits | exit 124 · 0 hits | | `os serve --dev` | fix IN PLACE | **exit 124** · 0 hits | exit 124 · 0 hits | Identical at both apps, 8 legs per side. **`os compile`, all four example apps, published entry `bin/run.js`:** | leg | showcase | crm | todo | multi-package | |---|---|---|---|---| | fix REVERTED, `NODE_ENV=development` | **1** | **1** | 0 | **2** | | fix REVERTED, `NODE_ENV=production` (control) | 0 | 0 | 0 | 0 | | fix IN PLACE, `development` / `test` / `production` | 0 | 0 | 0 | 0 | `examples/app-todo` — the one app whose tsconfig carries no `paths` block — is the only armed pass, so the failures map 1:1 onto the sibling-src population. ⚠️ **The entry the card's own probe used.** `bin/run-dev.js`, invoked correctly (under `tsx`, which its shebang requires), is **0/0/0/0 under both `development` and `production`** — its `TSX_TSCONFIG_PATH` re-exec mitigation works. That is why the card read "latent" for weeks: the probe went through the mitigated entry while users run the unmitigated one. ⛔ Re-measured here rather than carried over, and a note for the next reader: running `run-dev.js` under plain `node` answers 1/1/0/2 in **both** `NODE_ENV`s — that is the shim failing to load its own `.ts` import, not this defect, and it is an easy false reading to take. **The ablation legs are one-off and left nothing behind.** Every mutation was proved to have reached disk before its run (injected marker counted, removed line counted — ⛔ never a bare `git diff --stat`), every restore leg is `git checkout HEAD -- packages/cli/bin/run.js` (⛔ never a bare `git checkout --`, which restores from the polluted index), every restore was proved by `git hash-object` against the HEAD blob rather than by an exit code, and every mutating script carried `trap … EXIT INT TERM`. ## 5. The regression pin, and why it needs a control `packages/cli/test/published-entry-node-env-source-reroute.test.ts` — 5 cases, 5 passing. Every assertion in it is an **absence** (no reroute, no signature, exit 0), and an absence passes just as well over a fixture that arms nothing. So one leg defeats the declaration **inside the child** — `test/fixtures/published-entry-auto-transpile-neutraliser.mjs`, an `--import` preload installing an accessor whose setter swallows the entry's assignment (a plain write loses to it; a non-writable property makes ESM strict mode throw) — and asserts the card reproduces **verbatim**. A second control leg runs the neutralised child under `production` and is green, pinning that `NODE_ENV` is the variable. ⛔ Nothing touches `bin/run.js` on disk: a crashed or timed-out run must not leave the entry point neutralised for the next reader. ⚠️ The fixture is built in the test's own temp dir rather than pointed at `examples/app-crm`, so the suite reads nothing outside `packages/cli` — pointing at the example app would be a cross-package test input, needing a declaration in `scripts/cross-package-test-inputs.mjs` and a mirrored `turbo.json` entry that widens this package's test cache key over another package's whole source tree. Recorded because it cost a measurement: the **first** fixture attempted mapped the specifier onto an ESM `.ts` source inside `packages/cli` and stayed green in all six legs — the failure needs Node's **CJS** resolver walking a `.ts` file's siblings, so the trap has to be CommonJS with an extensionless relative `require`. A fixture that arms nothing is the exact vacuity the control exists to refuse. ## 6. `start.ts:419-421` (acceptance item 5) — the comment is true again, ⛔ no card needed ``` // NODE_ENV is only forced to production when the user has not set it. // Allows `NODE_ENV=development objectstack start` to work for debugging. if (!localEnv.NODE_ENV) localEnv.NODE_ENV = 'production'; ``` Measured at `examples/app-crm` on unmodified `5741ff10c`: `NODE_ENV=development objectstack start` → **exit 1**, 9 hits of the card's signature. `NODE_ENV=production` → exit 124. So the sentence advertised a debugging mode the runtime did not deliver (Prime Directive #10). ⭐ Which half was wrong matters: the **line** was always correct — the operator's value does reach the child. What failed was the invocation the sentence names, and it failed *before `localEnv` was ever built*, in this process. So the fix makes the sentence true rather than needing a behaviour change, and the comment now says so and points at the pin instead of asserting it on its own authority. ⛔ No separate card is filed, because there is no separable behaviour change left to file. `dev.ts:328-348`'s NOTE is corrected for the same reason: it stated the consequence ("`os dev` dies before the server starts") as a property of *writing* `NODE_ENV` on the child, and this change makes that consequence unreachable. The rule itself stays — a source that asserts a loader-activating value is a different claim from an entry that refuses to act on one, and it is the half `child-env-source-loader.pin.test.ts` can see. ## 7. Fence: ⛔ no general `paths`-resolution gate None is added. Nothing here parses a tsconfig, and the pin's fixture is a tsconfig the test **writes**, never one it reads. The #8020 / #8108 class is untouched. ## 8. The gate interaction this PR could not avoid, stated rather than buried The new pin spawns `bin/run.js` with a `development` / `test` child — exactly what `check:cli-test-child-env` rule 3 refuses. Three `DELIBERATE_REROUTE` entries are added, which is the mechanism that gate designs for a site whose `NODE_ENV` is its independent variable. ⚠️ They are the **inverse** of the two entries already there, and the registry now says so: the existing pair needs the reroute, this file asserts the reroute does **not** happen. The gate's header gains a paragraph recording that the published entry now refuses it, what rule 3 therefore still buys (it is what would notice the declaration being dropped), and ⛔ that these entries are not precedent for a spawner that wants `src/` — that is still `bin/run-dev.js`. The gate's own oclif table is untouched and still correct: it was measured against `Config.load()` with **default** settings, which is what every other built oclif entry still gets. Its self-test census pin moves from five files to six, with the reason beside the new member. ## 9. Docs — hand-read, because the drift tool declared it could not cover this file The docs-drift advisory reports `packages/cli/bin/run.js` as yielding **no anchor**, so the pages documenting the published entry's behaviour are outside its run. Hand-read instead: every hand-written page naming `NODE_ENV` (8, excluding the 6 release-owned pages, which are ⛔ read-only and were not edited), plus every page naming the CLI entry, `tsx`, or auto-transpilation. ⭐ **Nothing is falsified. Two pages document the exact invocation that was broken, and this change is what makes them deliverable:** - `content/docs/protocol/kernel/http-protocol.mdx:206-209` — *"Anything that boots the runtime without `os dev` — a bare `os serve`, an embedded host, a hand-written container entry point — must now set `NODE_ENV=development` explicitly to keep being advertised as such."* Following that instruction in a `paths`-carrying project exited 1 before this change. - `content/docs/deployment/environment-variables.mdx:33-36` — *"In dev (`os dev`, or `NODE_ENV=development`) a busy port auto-hops…"*. Same: the documented way to opt into dev behaviour was the thing that broke. The other six (`deployment/cli.mdx`, `deployment/self-hosting.mdx`, `permissions/authentication.mdx`, `plugins/packages.mdx`, `protocol/kernel/config-resolution.mdx`, `upgrading.mdx`) read `NODE_ENV` for auto-reconcile posture, the dev seed gate, sample app code, plugin-dev's production refusal, config-file selection and migration policy — none touches module resolution, and none moves. ⭐ **One page changed this PR's own claim.** `content/docs/plugins/index.mdx:399-408` documents that `os plugins …` is **not a registered command**: `@oclif/plugin-plugins` sits in `devDependencies` and oclif's core-plugin loader only matches names under `dependencies`. Verified here rather than taken on trust — `os --help` lists **34** topics and **zero** of them is `plugins`, the topic count being the control that makes the zero a reading. ⇒ the linked-TypeScript-plugin cost this PR originally stated is **unreachable today**; the docblock and the changeset were corrected to say so, with the condition under which it would have to be revisited. ⛔ No docs page needs an edit, and none was made. ## 10. Verification Everything below at HEAD `c6ea03075`, exit codes captured **before any pipe**, heavy runs serialised through `scripts/pm/os-verify-lock.sh` (slot `dev-12271`). | run | result | |---|---| | `pnpm build` (full, post-merge rebuild) | `VERDICT command-exit 0` — 73/73 tasks | | `pnpm --filter @objectstack/cli build && … typecheck` | `VERDICT command-exit 0` | | `vitest run --project unit` | `VERDICT command-exit 0` — **204 files / 2935 tests passed** | | `vitest run --project integration` | `VERDICT command-exit 0` — **47 files / 409 tests passed** | | `pnpm lint` (full repo union, ⛔ not narrowed) | **exit 0** | | the 81 families from `dispatch-gates --commands --repo objectstack-ai/objectstack` | **81 of 81 exit 0** | `node scripts/pm/dispatch-gates.mjs --ran … --repo objectstack-ai/objectstack` reconciles: *"81 derived famil(ies) accounted for — 81 run, 0 NOT-MEASURED (a DERIVED zero — all 81 recorded an exit code and none of them is 3)"*, with `pnpm lint` recorded as one run beyond the union. Re-derived after `git fetch origin main` advanced it to `dbea1756d`: the family set is **identical**, 81 before and after, zero added and zero removed. ⚠️ Two readings stated rather than smoothed over: - `pnpm check:pm-dispatch-gates` first returned **124** — that was *my* 420 s per-command timeout, ⛔ not a gate verdict. Re-run with a longer budget: **exit 0**, 1678 self-test cases pass. The record carries the 0, and this note carries the first reading. - The tree is **4 commits behind `origin/main`** (`dbea1756d`), and `dispatch-gates` flags one derived-from file as stale across that range: `scripts/engine-double-contract.pinned.json`. Its diff and mine share **zero files** — the four commits touch `spec`, `lint`, `plugin-auth`, `client`, `runtime`, `metadata-protocol` and `examples`, and no `packages/cli` path — so nothing is re-scoped; the merge queue rebuilds onto current `main` regardless. ## Acceptance notes - **noted, not filed:** `dev.ts:724`'s `execSync` hands `pnpm [--filter X] dev` the parent environment whole, with no `env` property. Not a defect — that child is a user workspace script, for which `NODE_ENV=development` is correct — but it is the sixth spawn site, and the four previous counts of "5" are all explained by it being reached through `await import('child_process')` rather than the static import. Carrier: the census table above; any future card on `os dev`'s workspace-root branch inherits it. - **noted, not filed:** `check:cli-test-child-env` rule 3's *specific* harm — a `bin/run.js` spawn silently executing `src/` — is now closed at the source for this repo's entry, so the rule can no longer fire for the reason its header gives. It is not dead: it keeps a built-entrypoint spawn readable about the `NODE_ENV` it means, and it is the instrument that would catch the declaration being dropped. Recorded in the gate's own header by this PR rather than left for the next reader to discover while deleting it. - **noted, not filed:** `examples/app-showcase`'s tsconfig now carries **three** sibling-src `paths` entries (`formula`, `plugin-email`, `lint`), against the two the card tabulates, and `examples/app-multi-package` — a fourth example app the card predates — redirects `@objectstack/spec` itself. Population growth only; #8249 owns it. - **noted, not filed:** `examples/app-multi-package` fails with a *different* surface under the armed environment (`The requested module '@objectstack/spec/api' does not provide an export named 'ErrorCode'`, then `command compile not found`, exit 2) rather than the `./registry` signature. Same cause, different first casualty; green after this change like the rest. Authored by the `os-dev` executor in session `session_01TSf4DV7ziu4V5j73e46b7c`, dispatched by the `domain:cli` seat (#6024). (Recorded here as prose: this PR body has been edited, and the edit channel appends its own attribution block regardless of what is sent — measured twice on this PR.) --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 84e6b05 commit bd25e89

7 files changed

Lines changed: 424 additions & 2 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`bin/run.js` — the entry `os` / `objectstack` names — resolves its commands from `dist/` whatever an ambient `NODE_ENV` says, so an exported `NODE_ENV=development` no longer kills the CLI in a project whose tsconfig maps a package to TypeScript source (#12271).
6+
7+
`@oclif/core` skips its TypeScript path lookup only when `isProd()` — a negated `['development', 'test'].includes(NODE_ENV)`. Under either value it resolved the CLI's **own** command modules from `src/` and registered tsx on the way, and tsx honours the tsconfig of the **current working directory**. An application that maps a CommonJS workspace package to its TypeScript source for *type* resolution — `"@objectstack/formula": ["../../packages/formula/src/index.ts"]` — therefore steered this CLI's *runtime* module graph into `.ts` files, after which Node's CommonJS resolver walked their extensionless siblings and found nothing:
8+
9+
```
10+
[MODULE_NOT_FOUND] import() failed to load …/packages/cli/src/commands/doctor.ts:
11+
Cannot find module './registry'
12+
```
13+
14+
Measured at two example apps with `NODE_ENV` as the only variable: `os compile`, `os dev --compile --fresh`, `os serve --dev` and `os start` each exited 1 on that signature under `development`, and each compiled or booted cleanly under `production`. The app with no `paths` block was the only one unaffected.
15+
16+
- **The fix is one declaration**: `settings.enableAutoTranspile = false`, checked by oclif ahead of `isProd()`. `bin/run.js` is the built entry and `bin/run-dev.js` is the source entry — a division `check:cli-test-child-env` already enforced on every test that spawns the CLI; the entry simply never asserted it about itself.
17+
-**Not a child-environment scrub.** `os serve --dev` and `os start` are top-level processes with no parent to scrub, and the casualty was the CLI's own command table rather than the user's config, so no per-spawn `NODE_ENV` handling could reach it.
18+
- **`NODE_ENV=development objectstack start` works again** — the debugging mode `os start` has advertised in a comment all along, and did not deliver.
19+
- ⚠️ **What it costs, measured**: the only thing oclif keeps its TypeScript lookup alive for in production is a **linked** plugin, so a `plugins link`ed TypeScript plugin would no longer be auto-transpiled through the published entry. That path is not reachable today — `@oclif/plugin-plugins` sits in `devDependencies` and oclif's core-plugin loader only matches names under `dependencies`, so `os plugins` is not a registered command (`os --help` lists 34 topics and none is `plugins`), which is what `content/docs/plugins/index.mdx` already documents. On an unbuilt checkout the entry now answers oclif's `command not found` under `development`/`test` exactly as it already did with `NODE_ENV` unset.

packages/cli/bin/run.js

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,95 @@
1414
//
1515
// ⛔ Nothing here changes which arguments the CLI accepts. `os dev --no-ui` is
1616
// still rejected — it is only rejected legibly.
17-
import { flush, handle, run } from '@oclif/core';
17+
import { flush, handle, run, settings } from '@oclif/core';
18+
19+
/**
20+
* ⭐ THIS ENTRY POINT RUNS `dist/`. It says so here so that an ambient
21+
* environment variable cannot decide otherwise.
22+
*
23+
* `bin/run.js` is the BUILT entry — `bin.objectstack` / `bin.os`, the file an
24+
* `npm i -g @objectstack/cli` install executes — and `packages/cli/package.json`
25+
* declares its command table over the emitted tree
26+
* (`"target": "./dist/commands"`). `bin/run-dev.js` is the SOURCE entry, run
27+
* under tsx, and it is the one that is SUPPOSED to reach `src/`. That division
28+
* is not new prose: `scripts/check-cli-test-child-env.mjs` already enforces it
29+
* on every test that spawns this file, and its rule 3 states the property in
30+
* the same words — a child of the built entrypoint must be readably outside
31+
* `development`/`test`, with no baseline and only two declared exceptions.
32+
*
33+
* What was missing is that **this file never asserted it about itself.**
34+
* `@oclif/core@4.13.3`'s `lib/config/ts-path.js` skips its TypeScript path
35+
* lookup only when `isProd()`, which `lib/util/util.js` defines as
36+
* `['development', 'test'].includes(process.env.NODE_ENV ?? '')` negated. So an
37+
* ambient `NODE_ENV` — exported by a developer, or inherited by any child this
38+
* CLI spawns — rewrote the command target from `dist/commands` to
39+
* `src/commands` and registered tsx on the way. Measured against `Config.load()`
40+
* on this package with `dist` present (the table #11317 recorded, unchanged):
41+
*
42+
* child NODE_ENV resolved commandsDir
43+
* -------------- -------------------------
44+
* unset packages/cli/dist/commands
45+
* production packages/cli/dist/commands
46+
* development packages/cli/src/commands ⛔
47+
* test packages/cli/src/commands ⛔
48+
*
49+
* ⚠️ The registration is the damaging half, not the redirect. `registerTsx()`
50+
* runs BEFORE `determinePath()` decides anything, and tsx honours the tsconfig
51+
* of the **current working directory**. An application whose tsconfig maps a
52+
* workspace package to its TypeScript source for TYPE resolution —
53+
* `"@objectstack/formula": ["../../packages/formula/src/index.ts"]`, which is
54+
* what `examples/app-crm`, `app-showcase` and `app-multi-package` all do — then
55+
* steers this CLI's own module graph into `.ts` files, after which Node's CJS
56+
* resolver walks their extensionless siblings and knows nothing about `.ts`:
57+
*
58+
* [MODULE_NOT_FOUND] import() failed to load …/packages/cli/src/commands/doctor.ts:
59+
* Cannot find module './registry'
60+
* Require stack:
61+
* - …/packages/formula/src/index.ts
62+
*
63+
* ⭐ Note WHICH file failed to load: `src/commands/doctor.ts`. The casualty is
64+
* this CLI's own command table, not the user's config — so the failure is not
65+
* specific to any one command, and no amount of scrubbing a CHILD's environment
66+
* reaches it. Measured at `examples/app-crm` and `examples/app-showcase` with
67+
* `NODE_ENV=development` exported, before this line existed: `os compile`,
68+
* `os dev --compile --fresh`, `os serve --dev` and `os start` each exit 1 on
69+
* that signature, against exit 0 / still-serving for every one of them with
70+
* `NODE_ENV=production`. `examples/app-todo`, the one example app whose
71+
* tsconfig carries no `paths` block, is the only one that survived — the
72+
* failures map 1:1 onto that population, and #8249 is actively growing it.
73+
*
74+
* ⛔ This is deliberately NOT a `TSX_TSCONFIG_PATH` pin like the one
75+
* `bin/run-dev.js` carries. That shim genuinely executes TypeScript, so all it
76+
* can do is aim the transpiler at the right tsconfig; and it cannot even do
77+
* that in-process (tsx parses its tsconfig in the loader's `initialize`, which
78+
* has already run by then), so it pays a whole re-exec. This file executes no
79+
* TypeScript at all, so the correct statement is not "transpile against a
80+
* different config" but "do not transpile" — and the published install has no
81+
* `packages/cli/tsconfig.json` to aim at in any case (`files` names `dist`
82+
* only).
83+
*
84+
* ⚠️ What it costs, measured rather than assumed. The one thing oclif keeps the
85+
* TypeScript lookup alive for even in production is a LINKED plugin
86+
* (`plugin?.type !== 'link'` guards the `isProduction` early return), and this
87+
* setting is checked ahead of that — so a `plugins link`ed TypeScript plugin
88+
* would no longer be auto-transpiled through this entry. ⭐ That path is not
89+
* reachable today: `@oclif/plugin-plugins` sits in `devDependencies`, and
90+
* oclif's core-plugin loader only matches names under `dependencies`, so
91+
* `os plugins` is not a registered command at all (measured on this entry —
92+
* `os --help` lists 34 topics and none of them is `plugins`; the count is the
93+
* control, so the zero is a reading). `content/docs/plugins/index.mdx` says the
94+
* same in its own words and tells an extension author to build an `os`
95+
* distribution listing the package in both places. ⛔ If that is ever fixed,
96+
* this line is what has to be revisited — the remedy is `bin/run-dev.js`, or
97+
* building the plugin.
98+
*
99+
* The other change in behaviour is a convergence, not a loss: on an UNBUILT
100+
* tree this file now answers oclif's "command not found" under
101+
* `development`/`test` exactly as it already did when `NODE_ENV` was unset —
102+
* the signature `scripts/cli-build-prerequisite.mjs` classifies for every gate
103+
* that shells out to this CLI, so the three legs stop disagreeing.
104+
*/
105+
settings.enableAutoTranspile = false;
18106

19107
/**
20108
* Print the one-line invocation verdict, if this failure is one.

packages/cli/src/commands/dev.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,21 @@ export default class Dev extends Command {
346346
// from the `/runtimeModule` hash, which differs run-to-run regardless
347347
// (the bundle embeds `builtAt`). Pinned by
348348
// child-env-source-loader.pin.test.ts.
349+
//
350+
// ⚠️ WHAT THIS RULE NO LONGER CARRIES, so the next reader does not
351+
// re-derive it: the CONSEQUENCE above was never conditional on a child
352+
// being handed the variable. Writing no `NODE_ENV` here leaves the
353+
// child inheriting whatever the parent has, so an operator who merely
354+
// EXPORTED `NODE_ENV=development` reproduced every word of it — and on
355+
// a direct `os serve --dev` or `os start`, which has no parent to scrub
356+
// at all. That class is closed one level down, where it is actually
357+
// decided: `bin/run.js` declares `settings.enableAutoTranspile = false`,
358+
// so the built entry resolves its commands from `dist/` whatever
359+
// `NODE_ENV` says (#12271 — its docblock carries the measurement).
360+
// ⛔ This rule stays anyway and is not redundant: it keeps the CLI's own
361+
// sources from ASSERTING a loader-activating value, which is a
362+
// different claim from the entry refusing to act on one, and it is the
363+
// half `child-env-source-loader.pin.test.ts` can see.
349364
const compileResult = spawnSync(
350365
process.execPath,
351366
[binPath, 'compile', '--output', artifactPath],

packages/cli/src/commands/start.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,19 @@ export default class Start extends Command {
418418
if (flags.artifact) delete localEnv.OS_ARTIFACT_URL;
419419
// NODE_ENV is only forced to production when the user has not set it.
420420
// Allows `NODE_ENV=development objectstack start` to work for debugging.
421+
//
422+
// ⚠️ That second sentence was a FALSE ADVERTISEMENT for as long as #12271
423+
// was open, and it is worth recording which half was wrong. This line was
424+
// always correct: the operator's value does reach the child. What did not
425+
// work was the invocation the sentence names — measured at
426+
// `examples/app-crm`, `NODE_ENV=development objectstack start` exited 1 on
427+
// `Cannot find module './registry'`, because the ambient value made
428+
// @oclif/core resolve the CLI's OWN commands from `src/` in this process,
429+
// before `localEnv` was ever built. It is honoured again because
430+
// `bin/run.js` now declares `settings.enableAutoTranspile = false`; the
431+
// sentence is held true by
432+
// `test/published-entry-node-env-source-reroute.test.ts`, ⛔ not by this
433+
// comment.
421434
if (!localEnv.NODE_ENV) localEnv.NODE_ENV = 'production';
422435

423436
// Single-node self-host quickstart: forcing production above would make
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The POSITIVE CONTROL for `published-entry-node-env-source-reroute.test.ts`
5+
* (#12271): it defeats `bin/run.js`'s `settings.enableAutoTranspile = false`
6+
* from inside the child, so the suite can watch the defect reproduce.
7+
*
8+
* ## Why a control is not optional here
9+
*
10+
* Every assertion in that file is an ABSENCE — the CLI did not reroute to
11+
* `src/`, the card's signature did not appear, the exit code was 0. An absence
12+
* passes just as well over a fixture that arms nothing: a `tsconfig.json` whose
13+
* `paths` entry never matched, a trap module that resolves fine, a probe
14+
* command that loads no command modules. Every one of those would be green, and
15+
* green for the wrong reason, forever. So one leg has to make the SAME fixture,
16+
* on the SAME entry, under the SAME environment, fail — and the only difference
17+
* between the legs is this file.
18+
*
19+
* ## Why it is a preload and not an edit
20+
*
21+
* The alternative is mutating `bin/run.js` on disk and putting it back, which
22+
* is a real edit to a shared worktree from inside a test run — the failure mode
23+
* being a crashed or timed-out run that leaves the entry point neutralised for
24+
* every later reader. Nothing here touches disk: the process exits and the
25+
* neutralisation is gone with it.
26+
*
27+
* ## ⚠️ Why a getter/setter pair and not a plain write
28+
*
29+
* `--import` runs BEFORE the entry, and the entry then assigns `false` — so a
30+
* preload that simply wrote `true` would be overwritten a moment later and the
31+
* control would silently not fire. Making the property non-writable instead
32+
* would make that assignment THROW, because `bin/run.js` is ESM and therefore
33+
* strict mode. An accessor whose setter ignores writes is the one shape that
34+
* absorbs the entry's assignment without either losing to it or crashing it,
35+
* and leaves `@oclif/core` reading `undefined` — exactly the value it saw
36+
* before the declaration existed, so the leg reproduces the ORIGINAL defect
37+
* rather than some third state.
38+
*/
39+
const { settings } = await import('@oclif/core');
40+
41+
Object.defineProperty(settings, 'enableAutoTranspile', {
42+
get: () => undefined,
43+
set: () => {},
44+
configurable: true,
45+
});

0 commit comments

Comments
 (0)