From 3c0a400507987835400f6a1f87119b98993ccda4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:22:51 +0000 Subject: [PATCH 1/3] fix(cli): stop os dev putting its compile child under the tsx source loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `os dev` spawned `os compile` with a hard-coded `NODE_ENV: 'development'`. That activates oclif's tsx-based TypeScript source loader, tsx honours the *cwd* tsconfig's `paths`, and example apps map workspace packages to their TypeScript source there. The redirected packages are CJS, so Node's CJS resolver then walks their sibling relative imports and knows nothing about `.ts` — `Cannot find module './registry'`, and dev dies before the server starts, whenever `dist/objectstack.json` is absent. The same hazard was already documented and fixed on the sibling serve spawn 93 lines below; the compile child was missed. Drop the env override and make the note cover every child this command starts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --- packages/cli/src/commands/dev.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 7f783bcb56..c7806d3cfc 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -189,10 +189,31 @@ export default class Dev extends Command { } printStep('Compiling objectstack.config.ts → dist/objectstack.json...'); const binPath = process.argv[1]; + // NOTE: Do NOT set NODE_ENV='development' on this child — the same rule + // the serve spawn below carries, for a second, independently measured + // reason. Oclif's tsx-based TypeScript source loader is activated when + // NODE_ENV is 'test' or 'development', and tsx honours the *cwd* + // tsconfig's `paths`. Example apps map workspace packages to their + // TypeScript SOURCE there — `@objectstack/formula` → + // `../../packages/formula/src/index.ts` — so a loader-activating child + // resolves a CJS package to `.ts`, after which Node's CJS resolver + // walks that file's siblings and knows nothing about `.ts`: + // `Cannot find module './registry'`, and `os dev` dies before the + // server starts. Measured: the failures map 1:1 onto each app's `paths` + // entries — app-showcase (formula + plugin-email) fails on both, + // app-crm (formula only) on one, app-todo (no `paths` block) on none. + // The import SPELLING was never the variable: plugin-email already + // ships the explicit `./email-plugin.js` extension and fails + // identically. This is a TYPE-resolution directive leaking into RUNTIME + // resolution, so it is fixed on the side that leaks it. `compile` reads + // NODE_ENV nowhere, and the artifact is byte-identical either way apart + // from the `/runtimeModule` hash, which differs run-to-run regardless + // (the bundle embeds `builtAt`). Pinned by + // dev-child-source-loader.pin.test.ts. const compileResult = spawnSync( process.execPath, [binPath, 'compile', '--output', artifactPath], - { stdio: 'inherit', env: { ...process.env, NODE_ENV: 'development' } }, + { stdio: 'inherit' }, ); if (compileResult.status !== 0) { printError('Compile failed — fix errors above before starting dev server'); @@ -294,6 +315,15 @@ export default class Dev extends Command { // flag below already opts the serve command into dev semantics, // and serve.ts will set NODE_ENV='development' internally before // any runtime modules are imported. + // + // The rule is not local to this spawn: it holds for EVERY child this + // command starts, and the compile spawn above states the second reason + // (a `paths`-carrying cwd redirecting CJS packages to `.ts` source). + // That spawn carried NODE_ENV='development' for months while this one + // did not, which is exactly how `os dev` came to fail on any example app + // whose tsconfig had gained a `paths` block. Adding a third child? + // Neither reason is about serve or compile in particular — leave the + // loader off. // ── Dev admin seeding (in-process) ────────────────────────────── // Seeding is performed IN-PROCESS by the runtime // (@objectstack/plugin-auth → maybeSeedDevAdmin) on an empty DB — no From 4828f0223cdbab98773d7b16789df2ade2fbc269 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:28:00 +0000 Subject: [PATCH 2/3] test(cli): pin that no command hands a child a loader-activating NODE_ENV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard the fix needs: a source assertion over every command in packages/cli that no NODE_ENV write reaching a child process carries a value that activates oclif's tsx TypeScript source loader ('development' or 'test', read back out of oclif's own isProd()). Writing it found a second, identical instance the reproduction never reached: `os environments bind --build` spawned `os compile` with the same hard-coded env. Fixed in place — same defect class, same mechanical shape already settled by the sibling compile spawns in start.ts and dev.ts's watch loop, both of which pass process.env unmodified. An end-to-end `os compile` spawn was rejected for this pin: turbo's test task dependsOn ^build only, so packages/cli/dist is not guaranteed to exist when the suite runs, and the package's existing subprocess tests reach the CLI through tsx — which starts the child with the loader already active. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --- .../child-env-source-loader.pin.test.ts | 379 ++++++++++++++++++ packages/cli/src/commands/dev.ts | 2 +- .../cli/src/commands/environments/bind.ts | 10 +- 3 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/child-env-source-loader.pin.test.ts diff --git a/packages/cli/src/commands/child-env-source-loader.pin.test.ts b/packages/cli/src/commands/child-env-source-loader.pin.test.ts new file mode 100644 index 0000000000..9d37c4af6f --- /dev/null +++ b/packages/cli/src/commands/child-env-source-loader.pin.test.ts @@ -0,0 +1,379 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin: **no CLI command hands a child process an environment that activates + * oclif's TypeScript source loader.** + * + * ## The failure this exists to refuse + * + * `os dev` auto-compiles when `dist/objectstack.json` is absent, by spawning + * `os compile` as a child. That spawn carried a hard-coded + * `NODE_ENV: 'development'`. Oclif activates its tsx-based TypeScript source + * loader whenever `NODE_ENV` is `'development'` or `'test'`; tsx honours the + * **cwd** tsconfig's `paths`; and an example app's tsconfig maps workspace + * packages to their TypeScript **source** — `@objectstack/formula` → + * `../../packages/formula/src/index.ts`. Those packages are CommonJS, so once + * the redirect lands on a `.ts` file, Node's CJS resolver walks that file's + * sibling relative imports and knows nothing about `.ts`: + * + * Cannot find module './registry' + * Require stack: + * - packages/formula/src/index.ts + * ✗ Compile failed — fix errors above before starting dev server + * + * A **type-resolution directive leaking into runtime resolution**. Measured on + * the three example apps, the failures map 1:1 onto each app's `paths` entries: + * app-showcase (formula + plugin-email) fails on both specifiers, app-crm + * (formula only) on one, app-todo (no `paths` block) on none. + * + * ⛔ The import **spelling** was never the variable, and this is the part most + * likely to be re-litigated: `plugin-email` already ships the explicit + * `./email-plugin.js` extension and fails identically to `formula`'s + * extensionless `./registry`. Adding extensions to the redirected packages + * cannot fix this, and neither can removing the `paths` blocks — those are + * mandated by `pnpm check:type-source-resolution`, which was green throughout. + * So was `pnpm check:test-source-alias`. The two gates cover the **types** axis + * and the **vitest** axis; the axis that broke — a CLI child's **runtime** + * module resolution — is covered by neither, which is why the defect lived on + * `main` while every gate reported success. The general third-axis guard is + * separate scope and tracked separately; this pin is the narrow half that + * belongs to the fix. + * + * ## What is actually pinned, and why it is a source assertion + * + * The knowledge was already in the tree: `dev.ts`'s **serve** spawn carries a + * NOTE saying not to set `NODE_ENV='development'`, for an independent reason. + * `start.ts`'s compile spawn passes `process.env` unmodified, and `dev.ts`'s + * watch-mode recompile spawn does too. Three of the four sibling spawns were + * right; the fourth was missed, and a comment on one of two call sites is what + * a guard is for. + * + * ⚠️ **Why not spawn a real `os compile` from an example-app cwd** — the + * end-to-end form of this assertion, which was considered first and rejected on + * two measured grounds: + * + * 1. **It could not be made to fail.** Turbo's `test` task declares + * `dependsOn: ["^build"]` — *dependencies'* builds, not the package's own — + * so `packages/cli/dist`, which `bin/run.js` loads, is not guaranteed to + * exist when this suite runs. A spawn-based pin would have to skip on an + * unbuilt tree: green exactly when it cannot look. The package's other + * subprocess tests avoid that by spawning `bin/run-dev.js` **through tsx** + * — which starts the child with the source loader already active, so it + * cannot distinguish the state this pin exists to distinguish. + * 2. **It is the package's dominant cost.** `vitest.config.ts`'s header + * records the measurement: the 20 files that spawn the real CLI are 56.1% + * of this package's file wall (300.1s) for 177 of 1498 tests, at a ~6.5s + * floor per spawn. + * + * So the property is asserted where it is decidable and cheap — over the + * command sources themselves — and the behaviour it stands for was verified by + * hand against all three example apps. + * + * ## The four assertions, and why each is needed + * + * - **The property.** No command source writes a loader-activating `NODE_ENV` + * into a child environment. Reverting the fix re-adds exactly such a write. + * - **The scan reached real code.** A detector that silently stops finding + * anything passes forever; this asserts the walk found the supervisor + * commands, and that it reports the *real, safe* `NODE_ENV` write `start.ts` + * makes (`'production'`) — proof it is reading production source and + * grading it, not returning an empty list. + * - **Specimens.** The pre-fix spawn options are classified as a violation, a + * non-literal value is too (it cannot be proven safe), and a `'production'` + * child env is not — so the pin is not merely "any `NODE_ENV` is red". + * - **The vocabulary is oclif's, not ours.** `'development'` and `'test'` are + * read back out of `@oclif/core`'s own `isProd()`. If a future version + * changes which values activate the loader, this reds and the next author + * re-derives the set instead of trusting the two strings above. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import path from 'path'; +import ts from 'typescript'; + +/** + * The `NODE_ENV` values that make oclif register its TypeScript source loader. + * + * Not a choice — oclif's `isProd()` is + * `!['development', 'test'].includes(process.env.NODE_ENV ?? '')`, and the + * loader lookup is skipped only when that is true. Pinned against oclif's own + * shipped source below. + */ +const SOURCE_LOADER_ACTIVATING = new Set(['development', 'test']); + +const COMMANDS_DIR = fileURLToPath(new URL('.', import.meta.url)); + +/** How a `NODE_ENV` write was spelled — reported so a failure is actionable. */ +type WriteShape = + | 'object property' + | 'shorthand property' + | 'property assignment' + | 'indexed assignment'; + +interface NodeEnvWrite { + file: string; + line: number; + shape: WriteShape; + /** True when the assignment target is `process.env` itself. */ + inProcess: boolean; + /** The static string value, or `undefined` when it is not a literal. */ + value: string | undefined; +} + +interface Analysis { + file: string; + /** Whether the file starts a child process at all. */ + spawnsChild: boolean; + writes: NodeEnvWrite[]; +} + +const CHILD_PROCESS_STARTERS = new Set([ + 'spawn', 'spawnSync', 'fork', 'exec', 'execSync', 'execFile', 'execFileSync', +]); + +/** + * Read `NODE_ENV` writes and child-process starts off the TypeScript AST. + * + * Deliberately a parser and not a text scan, for the reason the sibling pin in + * `artifact-child-env.pin.test.ts` records: a comment-stripping regex over + * these very files reported one of them clean while it carried the write, because + * a `/*` inside a string literal opened a phantom comment that swallowed + * hundreds of lines of real code. Strings and comments cannot lie to a parser. + * + * Reads are never collected — `process.env.NODE_ENV === 'test'` is how several + * commands decide their own mode, and that must stay possible. Only writes. + */ +const analyze = (file: string, text: string): Analysis => { + const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const writes: NodeEnvWrite[] = []; + let spawnsChild = false; + + const lineOf = (node: ts.Node) => + sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; + + const staticName = (node: ts.Node): string | undefined => + ts.isIdentifier(node) || ts.isStringLiteral(node) ? node.text : undefined; + + const literalValue = (node: ts.Node): string | undefined => + ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : undefined; + + /** `process.env` — the current process's environment, not a child's. */ + const isProcessEnv = (node: ts.Node): boolean => + ts.isPropertyAccessExpression(node) + && node.name.text === 'env' + && ts.isIdentifier(node.expression) + && node.expression.text === 'process'; + + const calleeName = (expr: ts.Expression): string | undefined => { + if (ts.isIdentifier(expr)) return expr.text; + if (ts.isPropertyAccessExpression(expr)) return expr.name.text; + return undefined; + }; + + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const name = calleeName(node.expression); + if (name && CHILD_PROCESS_STARTERS.has(name)) spawnsChild = true; + } + + // `{ NODE_ENV: value }` + if (ts.isPropertyAssignment(node) && staticName(node.name) === 'NODE_ENV') { + writes.push({ + file, + line: lineOf(node), + shape: 'object property', + inProcess: false, + value: literalValue(node.initializer), + }); + } + + // `{ NODE_ENV }` — whatever the binding holds; never statically safe. + if (ts.isShorthandPropertyAssignment(node) && node.name.text === 'NODE_ENV') { + writes.push({ + file, line: lineOf(node), shape: 'shorthand property', inProcess: false, value: undefined, + }); + } + + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + const lhs = node.left; + // `env.NODE_ENV = value` + if (ts.isPropertyAccessExpression(lhs) && lhs.name.text === 'NODE_ENV') { + writes.push({ + file, + line: lineOf(node), + shape: 'property assignment', + inProcess: isProcessEnv(lhs.expression), + value: literalValue(node.right), + }); + } + // `env['NODE_ENV'] = value` + if ( + ts.isElementAccessExpression(lhs) + && staticName(lhs.argumentExpression) === 'NODE_ENV' + ) { + writes.push({ + file, + line: lineOf(node), + shape: 'indexed assignment', + inProcess: isProcessEnv(lhs.expression), + value: literalValue(node.right), + }); + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return { file, spawnsChild, writes }; +}; + +/** + * The writes that put a child under the source loader. + * + * A write to `process.env.NODE_ENV` is exempt **only in a file that starts no + * child process** — that is a command setting its own mode (`serve.ts` does it + * deliberately, after its imports have already been loaded). In a file that + * does spawn, the same write reaches the child through the inherited + * environment, so it is graded like any other. + * + * A value that is not a static string literal is a violation: it cannot be + * proven safe, and this pin refuses to guess. + */ +const dangerousWrites = (a: Analysis): NodeEnvWrite[] => + a.writes.filter((w) => { + if (w.inProcess && !a.spawnsChild) return false; + return w.value === undefined || SOURCE_LOADER_ACTIVATING.has(w.value); + }); + +const describeWrite = (w: NodeEnvWrite) => + `${w.file}:${w.line} (${w.shape}, value ${w.value === undefined ? 'NOT A LITERAL' : `'${w.value}'`})`; + +/** Every non-test command source, recursively. */ +const commandSources = (): Analysis[] => { + const out: Analysis[] = []; + const walk = (dir: string, prefix: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(path.join(dir, entry.name), rel); + continue; + } + if (!entry.name.endsWith('.ts')) continue; + if (/\.(test|pin\.test|contract\.test|integration\.test)\.ts$/.test(entry.name)) continue; + if (entry.name.endsWith('.d.ts')) continue; + out.push(analyze(rel, readFileSync(path.join(dir, entry.name), 'utf8'))); + } + }; + walk(COMMANDS_DIR, ''); + return out; +}; + +describe('no CLI command puts a child process under oclif\'s TypeScript source loader', () => { + const sources = commandSources(); + + it('writes no loader-activating NODE_ENV into any child environment', () => { + const offenders = sources.flatMap(dangerousWrites).map(describeWrite); + + expect( + offenders, + 'A child spawned with NODE_ENV=development (or =test) runs under oclif\'s tsx source ' + + 'loader. tsx honours the CWD tsconfig\'s `paths`, and example apps map workspace ' + + 'packages to their .ts source there — so the child resolves a CommonJS package to ' + + 'TypeScript and Node\'s CJS resolver then fails on that file\'s sibling imports ' + + '(`Cannot find module \'./registry\'`), killing `os dev` before the server starts. ' + + 'Neither check:type-source-resolution (types axis) nor check:test-source-alias ' + + '(vitest axis) sees this; they were both green while it was broken. Pass the child ' + + '`process.env` unmodified, or a NODE_ENV that is not ' + + `${[...SOURCE_LOADER_ACTIVATING].map((v) => `'${v}'`).join(' or ')}` + + ' — and if a child genuinely needs dev semantics, let the child command set them ' + + 'internally, after its own module loading is done (serve.ts does exactly that).', + ).toEqual([]); + }); + + it('scanned the supervisor commands, and graded the real write start.ts makes', () => { + const scanned = sources.map((s) => s.file); + // Without this the assertion above could pass by having found nothing. + for (const required of ['dev.ts', 'start.ts', 'serve.ts', 'compile.ts']) { + expect(scanned, `${required} must be among the scanned command sources`).toContain(required); + } + + // `start.ts` really does write NODE_ENV into the env it hands `serve` + // (`if (!localEnv.NODE_ENV) localEnv.NODE_ENV = 'production';`). The + // detector must SEE it and judge it safe — that is what distinguishes a + // working detector from one that reports nothing. + const startWrites = sources.find((s) => s.file === 'start.ts')?.writes ?? []; + expect( + startWrites.map((w) => w.value), + 'start.ts is expected to write a NODE_ENV into its child env; if that stopped being ' + + 'true, re-confirm this detector still finds writes at all before trusting its silence.', + ).toContain('production'); + expect(startWrites.some((w) => w.value === 'production' && !w.inProcess)).toBe(true); + }); + + it('classifies the pre-fix spawn — and only the unsafe shapes — as violations', () => { + // Verbatim shape of the defect, so a revert of the fix is provably red here. + const preFix = analyze('specimen-dev.ts', [ + 'const compileResult = spawnSync(', + ' process.execPath,', + " [binPath, 'compile', '--output', artifactPath],", + " { stdio: 'inherit', env: { ...process.env, NODE_ENV: 'development' } },", + ');', + ].join('\n')); + expect(preFix.spawnsChild).toBe(true); + expect(dangerousWrites(preFix).map((w) => `${w.shape}:${w.value}`)) + .toEqual(['object property:development']); + + // A value the pin cannot evaluate is not assumed innocent. + const dynamic = analyze('specimen-dynamic.ts', + 'spawn(bin, args, { env: { ...process.env, NODE_ENV: mode } });'); + expect(dangerousWrites(dynamic)).toHaveLength(1); + expect(dangerousWrites(dynamic)[0].value).toBeUndefined(); + + // ...but a non-activating value is fine: this pin is not "no NODE_ENV ever". + const safe = analyze('specimen-safe.ts', + "spawn(bin, args, { env: { ...process.env, NODE_ENV: 'production' } });"); + expect(dangerousWrites(safe)).toEqual([]); + + // A command that sets its OWN mode and spawns nothing is untouched. + const inProcess = analyze('specimen-serve.ts', + "if (flags.dev && !process.env.NODE_ENV) { process.env.NODE_ENV = 'development'; }"); + expect(inProcess.spawnsChild).toBe(false); + expect(dangerousWrites(inProcess)).toEqual([]); + + // ...but the same write in a file that DOES spawn reaches the child. + const leaks = analyze('specimen-leak.ts', + "process.env.NODE_ENV = 'development'; spawnSync(bin, args, { env: process.env });"); + expect(dangerousWrites(leaks)).toHaveLength(1); + }); + + it('takes its activating vocabulary from oclif, not from this file', () => { + // `@oclif/core`'s `exports` map does not publish `lib/util/util.js`, so the + // predicate is read off disk rather than imported. Resolution goes through + // `package.json`, which IS exported — no hard-coded node_modules layout. + const require_ = createRequire(import.meta.url); + const oclifRoot = path.dirname(require_.resolve('@oclif/core/package.json')); + const utilSource = readFileSync(path.join(oclifRoot, 'lib', 'util', 'util.js'), 'utf8'); + + const body = /function isProd\s*\([^)]*\)\s*\{([\s\S]*?)\n\}/.exec(utilSource)?.[1]; + expect( + body, + 'Could not read `isProd()` out of @oclif/core. That predicate is the whole reason this ' + + 'pin forbids the two values it forbids, so re-derive the activating set from the ' + + 'installed version rather than leaving this assertion unable to look.', + ).toBeTruthy(); + + const literals = new Set( + [...body!.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) => m[1] ?? m[2]).filter(Boolean), + ); + expect( + [...literals].sort(), + 'The NODE_ENV values that activate oclif\'s TypeScript source loader have changed. ' + + 'Widen (or narrow) SOURCE_LOADER_ACTIVATING in this file to match, and re-read the ' + + 'NOTEs on the spawns in dev.ts — they name these values explicitly.', + ).toEqual([...SOURCE_LOADER_ACTIVATING].sort()); + }); +}); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index c7806d3cfc..835041eb53 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -209,7 +209,7 @@ export default class Dev extends Command { // NODE_ENV nowhere, and the artifact is byte-identical either way apart // from the `/runtimeModule` hash, which differs run-to-run regardless // (the bundle embeds `builtAt`). Pinned by - // dev-child-source-loader.pin.test.ts. + // child-env-source-loader.pin.test.ts. const compileResult = spawnSync( process.execPath, [binPath, 'compile', '--output', artifactPath], diff --git a/packages/cli/src/commands/environments/bind.ts b/packages/cli/src/commands/environments/bind.ts index c0ac102ddb..8d2683a45b 100644 --- a/packages/cli/src/commands/environments/bind.ts +++ b/packages/cli/src/commands/environments/bind.ts @@ -70,10 +70,18 @@ export default class ProjectsBind extends Command { if (flags.build) { printStep('Compiling objectstack.config.ts → ' + artifactAbs); const binPath = process.argv[1]; + // NOTE: Do NOT set NODE_ENV='development' on this child. It activates + // oclif's tsx TypeScript source loader, which honours the CWD + // tsconfig's `paths` — and an app's `paths` redirect a CommonJS + // workspace package to its `.ts` source, after which Node's CJS + // resolver fails on that file's sibling imports. `os dev` carried the + // same spawn and died on exactly that; `os start`'s compile spawn + // never set it. `compile` reads NODE_ENV nowhere. See the NOTEs in + // commands/dev.ts and child-env-source-loader.pin.test.ts. const r = spawnSync( process.execPath, [binPath, 'compile', '--output', artifactAbs], - { stdio: 'inherit', env: { ...process.env, NODE_ENV: 'development' } }, + { stdio: 'inherit', env: process.env }, ); if (r.status !== 0) { printError('Compile failed — fix errors above before binding'); From d5b688ed627d054f47bb75dc5962c526c7d7e57d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:46:07 +0000 Subject: [PATCH 3/3] chore(changeset): patch @objectstack/cli for the dev compile-child env fix Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --- .changeset/dev-compile-child-source-loader.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .changeset/dev-compile-child-source-loader.md diff --git a/.changeset/dev-compile-child-source-loader.md b/.changeset/dev-compile-child-source-loader.md new file mode 100644 index 0000000000..10e11ef46c --- /dev/null +++ b/.changeset/dev-compile-child-source-loader.md @@ -0,0 +1,38 @@ +--- +"@objectstack/cli": patch +--- + +Stop `os dev` handing its auto-compile child an environment that activates +oclif's TypeScript source loader — `pnpm dev` could not boot an example app. + +`os dev` auto-compiles when `dist/objectstack.json` is absent, by spawning +`os compile`. That spawn set a hard-coded `NODE_ENV: 'development'`, which +activates oclif's tsx source loader. tsx honours the **cwd** tsconfig's +`paths`, and example apps map workspace packages to their TypeScript source +there (`@objectstack/formula` → `../../packages/formula/src/index.ts`). Those +packages are CommonJS, so the redirect lands on a `.ts` file and Node's CJS +resolver then walks its sibling imports, which it cannot resolve: + +``` +Cannot find module './registry' +Require stack: +- packages/formula/src/index.ts +✗ Compile failed — fix errors above before starting dev server +``` + +A **type-resolution directive leaking into runtime resolution**. Measured, the +failures map 1:1 onto each app's `paths` entries: app-showcase (two entries) +failed on both specifiers, app-crm (one) on one, app-todo (none) on none. The +import spelling was never the variable — `plugin-email` already ships the +explicit `./email-plugin.js` extension and failed identically. The `paths` +blocks are correct too; they are mandated by `check:type-source-resolution`. + +`os environments bind --build` carried the identical spawn and is fixed with +it. `os start`'s compile spawn and `os dev`'s watch-mode recompile already +passed `process.env` unmodified, as did the `os serve` spawn, whose NOTE had +documented this hazard for months on one of the call sites that needed it. + +Patch, not minor: no flag, command, contract or output changes. `compile` +reads `NODE_ENV` nowhere, and the emitted artifact is identical leaf-for-leaf +apart from the `/runtimeModule` bundle hash, which differs between two runs of +the *same* command anyway because the bundle embeds `builtAt`.