Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/12271-published-entry-no-auto-transpile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/cli": patch
---

`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).

`@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:

```
[MODULE_NOT_FOUND] import() failed to load …/packages/cli/src/commands/doctor.ts:
Cannot find module './registry'
```

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.

- **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.
- ⛔ **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.
- **`NODE_ENV=development objectstack start` works again** — the debugging mode `os start` has advertised in a comment all along, and did not deliver.
- ⚠️ **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.
90 changes: 89 additions & 1 deletion packages/cli/bin/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,95 @@
//
// ⛔ Nothing here changes which arguments the CLI accepts. `os dev --no-ui` is
// still rejected — it is only rejected legibly.
import { flush, handle, run } from '@oclif/core';
import { flush, handle, run, settings } from '@oclif/core';

/**
* ⭐ THIS ENTRY POINT RUNS `dist/`. It says so here so that an ambient
* environment variable cannot decide otherwise.
*
* `bin/run.js` is the BUILT entry — `bin.objectstack` / `bin.os`, the file an
* `npm i -g @objectstack/cli` install executes — and `packages/cli/package.json`
* declares its command table over the emitted tree
* (`"target": "./dist/commands"`). `bin/run-dev.js` is the SOURCE entry, run
* under tsx, and it is the one that is SUPPOSED to reach `src/`. That division
* is not new prose: `scripts/check-cli-test-child-env.mjs` already enforces it
* on every test that spawns this file, and its rule 3 states the property in
* the same words — a child of the built entrypoint must be readably outside
* `development`/`test`, with no baseline and only two declared exceptions.
*
* What was missing is that **this file never asserted it about itself.**
* `@oclif/core@4.13.3`'s `lib/config/ts-path.js` skips its TypeScript path
* lookup only when `isProd()`, which `lib/util/util.js` defines as
* `['development', 'test'].includes(process.env.NODE_ENV ?? '')` negated. So an
* ambient `NODE_ENV` — exported by a developer, or inherited by any child this
* CLI spawns — rewrote the command target from `dist/commands` to
* `src/commands` and registered tsx on the way. Measured against `Config.load()`
* on this package with `dist` present (the table #11317 recorded, unchanged):
*
* child NODE_ENV resolved commandsDir
* -------------- -------------------------
* unset packages/cli/dist/commands
* production packages/cli/dist/commands
* development packages/cli/src/commands ⛔
* test packages/cli/src/commands ⛔
*
* ⚠️ The registration is the damaging half, not the redirect. `registerTsx()`
* runs BEFORE `determinePath()` decides anything, and tsx honours the tsconfig
* of the **current working directory**. An application whose tsconfig maps a
* workspace package to its TypeScript source for TYPE resolution —
* `"@objectstack/formula": ["../../packages/formula/src/index.ts"]`, which is
* what `examples/app-crm`, `app-showcase` and `app-multi-package` all do — then
* steers this CLI's own module graph into `.ts` files, after which Node's CJS
* resolver walks their extensionless siblings and knows nothing about `.ts`:
*
* [MODULE_NOT_FOUND] import() failed to load …/packages/cli/src/commands/doctor.ts:
* Cannot find module './registry'
* Require stack:
* - …/packages/formula/src/index.ts
*
* ⭐ Note WHICH file failed to load: `src/commands/doctor.ts`. The casualty is
* this CLI's own command table, not the user's config — so the failure is not
* specific to any one command, and no amount of scrubbing a CHILD's environment
* reaches it. Measured at `examples/app-crm` and `examples/app-showcase` with
* `NODE_ENV=development` exported, before this line existed: `os compile`,
* `os dev --compile --fresh`, `os serve --dev` and `os start` each exit 1 on
* that signature, against exit 0 / still-serving for every one of them with
* `NODE_ENV=production`. `examples/app-todo`, the one example app whose
* tsconfig carries no `paths` block, is the only one that survived — the
* failures map 1:1 onto that population, and #8249 is actively growing it.
*
* ⛔ This is deliberately NOT a `TSX_TSCONFIG_PATH` pin like the one
* `bin/run-dev.js` carries. That shim genuinely executes TypeScript, so all it
* can do is aim the transpiler at the right tsconfig; and it cannot even do
* that in-process (tsx parses its tsconfig in the loader's `initialize`, which
* has already run by then), so it pays a whole re-exec. This file executes no
* TypeScript at all, so the correct statement is not "transpile against a
* different config" but "do not transpile" — and the published install has no
* `packages/cli/tsconfig.json` to aim at in any case (`files` names `dist`
* only).
*
* ⚠️ What it costs, measured rather than assumed. The one thing oclif keeps the
* TypeScript lookup alive for even in production is a LINKED plugin
* (`plugin?.type !== 'link'` guards the `isProduction` early return), and this
* setting is checked ahead of that — so a `plugins link`ed TypeScript plugin
* would no longer be auto-transpiled through this 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 at all (measured on this entry —
* `os --help` lists 34 topics and none of them is `plugins`; the count is the
* control, so the zero is a reading). `content/docs/plugins/index.mdx` says the
* same in its own words and tells an extension author to build an `os`
* distribution listing the package in both places. ⛔ If that is ever fixed,
* this line is what has to be revisited — the remedy is `bin/run-dev.js`, or
* building the plugin.
*
* The other change in behaviour is a convergence, not a loss: on an UNBUILT
* tree this file now answers oclif's "command not found" under
* `development`/`test` exactly as it already did when `NODE_ENV` was unset —
* the signature `scripts/cli-build-prerequisite.mjs` classifies for every gate
* that shells out to this CLI, so the three legs stop disagreeing.
*/
settings.enableAutoTranspile = false;

/**
* Print the one-line invocation verdict, if this failure is one.
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,21 @@ export default class Dev extends Command {
// from the `/runtimeModule` hash, which differs run-to-run regardless
// (the bundle embeds `builtAt`). Pinned by
// child-env-source-loader.pin.test.ts.
//
// ⚠️ WHAT THIS RULE NO LONGER CARRIES, so the next reader does not
// re-derive it: the CONSEQUENCE above was never conditional on a child
// being handed the variable. Writing no `NODE_ENV` here leaves the
// child inheriting whatever the parent has, so an operator who merely
// EXPORTED `NODE_ENV=development` reproduced every word of it — and on
// a direct `os serve --dev` or `os start`, which has no parent to scrub
// at all. That class is closed one level down, where it is actually
// decided: `bin/run.js` declares `settings.enableAutoTranspile = false`,
// so the built entry resolves its commands from `dist/` whatever
// `NODE_ENV` says (#12271 — its docblock carries the measurement).
// ⛔ This rule stays anyway and is not redundant: it keeps the CLI's own
// sources from ASSERTING a loader-activating value, which is a
// different claim from the entry refusing to act on one, and it is the
// half `child-env-source-loader.pin.test.ts` can see.
const compileResult = spawnSync(
process.execPath,
[binPath, 'compile', '--output', artifactPath],
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,19 @@ export default class Start extends Command {
if (flags.artifact) delete localEnv.OS_ARTIFACT_URL;
// NODE_ENV is only forced to production when the user has not set it.
// Allows `NODE_ENV=development objectstack start` to work for debugging.
//
// ⚠️ That second sentence was a FALSE ADVERTISEMENT for as long as #12271
// was open, and it is worth recording which half was wrong. This line was
// always correct: the operator's value does reach the child. What did not
// work was the invocation the sentence names — measured at
// `examples/app-crm`, `NODE_ENV=development objectstack start` exited 1 on
// `Cannot find module './registry'`, because the ambient value made
// @oclif/core resolve the CLI's OWN commands from `src/` in this process,
// before `localEnv` was ever built. It is honoured again because
// `bin/run.js` now declares `settings.enableAutoTranspile = false`; the
// sentence is held true by
// `test/published-entry-node-env-source-reroute.test.ts`, ⛔ not by this
// comment.
if (!localEnv.NODE_ENV) localEnv.NODE_ENV = 'production';

// Single-node self-host quickstart: forcing production above would make
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The POSITIVE CONTROL for `published-entry-node-env-source-reroute.test.ts`
* (#12271): it defeats `bin/run.js`'s `settings.enableAutoTranspile = false`
* from inside the child, so the suite can watch the defect reproduce.
*
* ## Why a control is not optional here
*
* Every assertion in that file is an ABSENCE — the CLI did not reroute to
* `src/`, the card's signature did not appear, the exit code was 0. An absence
* passes just as well over a fixture that arms nothing: a `tsconfig.json` whose
* `paths` entry never matched, a trap module that resolves fine, a probe
* command that loads no command modules. Every one of those would be green, and
* green for the wrong reason, forever. So one leg has to make the SAME fixture,
* on the SAME entry, under the SAME environment, fail — and the only difference
* between the legs is this file.
*
* ## Why it is a preload and not an edit
*
* The alternative is mutating `bin/run.js` on disk and putting it back, which
* is a real edit to a shared worktree from inside a test run — the failure mode
* being a crashed or timed-out run that leaves the entry point neutralised for
* every later reader. Nothing here touches disk: the process exits and the
* neutralisation is gone with it.
*
* ## ⚠️ Why a getter/setter pair and not a plain write
*
* `--import` runs BEFORE the entry, and the entry then assigns `false` — so a
* preload that simply wrote `true` would be overwritten a moment later and the
* control would silently not fire. Making the property non-writable instead
* would make that assignment THROW, because `bin/run.js` is ESM and therefore
* strict mode. An accessor whose setter ignores writes is the one shape that
* absorbs the entry's assignment without either losing to it or crashing it,
* and leaves `@oclif/core` reading `undefined` — exactly the value it saw
* before the declaration existed, so the leg reproduces the ORIGINAL defect
* rather than some third state.
*/
const { settings } = await import('@oclif/core');

Object.defineProperty(settings, 'enableAutoTranspile', {
get: () => undefined,
set: () => {},
configurable: true,
});
Loading
Loading