Uh oh!
There was an error while loading. Please reload this page.
fix(cli): os serve defaults NODE_ENV to production when unset - #11268
Conversation
os start already forces NODE_ENV='production' on the unset case, but on the child environment it assembles for its spawn. os serve runs in-process, so there was no equivalent write, and the whole family of NODE_ENV !== 'production' gates across the tree read the raw undefined and took the non-production branch on a boot that never declared itself anything else. Adds the same default os serve already applies for --dev, at the same early point in run() - before any dynamically-imported runtime module and before every gate downstream reads the variable. An explicitly-set NODE_ENV (development, test, anything else) is never overridden.
…xture Measured: os serve's own auto-injected AuthPlugin wiring always seeds trustedOrigins with the resolved baseUrl origin and carries its own isDev-gated localhost wildcard, so auth-manager.ts's NODE_ENV-gated substitution is unreachable through that path regardless of NODE_ENV. The pin now uses a host-authored AuthPlugin construction, which is the shape that actually reaches the gate, and reverse-verifies against the pre-fix tree. Also strips the vitest worker's own TEST env var from the spawned child: better-auth 1.7.1 reads TEST directly (independent of NODE_ENV) to decide whether to skip origin validation entirely, and leaving it inherited made the pin pass regardless of the fix.
…config
spawn(..., { stdio: ['ignore', 'pipe', 'pipe'] }) returns
ChildProcessByStdio<null, Readable, Readable> (no stdin), not
ChildProcessWithoutNullStreams. TS2352 on the `as` cast was the one raw tsc
error the TEST_DEBT re-measure ledger caught (146 -> 147) that the package's
own `typecheck` script cannot see, since packages/cli hides its test/ tree
from tsc.📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5d7c85a453bebdb927de0e57607fc1c784619468 && git checkout 5d7c85a453bebdb927de0e57607fc1c784619468
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e278a2970d2dbdb662db66dd61bc07264157fa51 98e8399f57052e71242e32fddfd974f03ed099b8 && git checkout -B drift-repro e278a2970d2dbdb662db66dd61bc07264157fa51 && git merge --no-ff 98e8399f57052e71242e32fddfd974f03ed099b8
node scripts/docs-audit/affected-docs.mjs --json e278a2970d2dbdb662db66dd61bc07264157fa51
|
…te + spawn-load flake) Lint & Repo Gates: check:test-source-alias flagged @objectstack/plugin-auth as a new unaliased artifact import — the gate's dependency-free text scanner cannot tell a fixture-config string (written to a file for a spawned child process) apart from a real import in this file. Added the gate's own prescribed remedy: an anchored resolve.alias entry in packages/cli/vitest.config.ts. Verified locally: `pnpm check:test-source-alias` green. Test Core (2/6): the pin's first sub-test failed once with oclif's own "Error: command serve not found" before reaching "Server is ready" — 1824 of 1825 other tests in the same shard passed, and the ordering fix in serve.ts runs strictly after command resolution, so it cannot be the cause. This package's own vitest.config.ts header documents ~20 files (this one now among them) that spawn the built CLI as a real child process under heavy concurrent load as the suite's dominant cost (56.1% of wall time) — a shape this repo's own scripts/cli-build-prerequisite.mjs names as the canonical signature of a `dist/commands` glob read that transiently looks incomplete under load (no oclif.manifest.json cache in this repo — every invocation re-globs). Added a bounded, signature-scoped retry (bootServeWithRetry) that retries EXACTLY ONCE, and only when the failure text matches that exact oclif sentence (looksLikeMissingCliCommand, replicating cli-build-prerequisite.mjs's line-flattening) — any other failure shape (real assertion, real crash, a timeout with a different tail) still fails unretried, so this cannot mask a genuine regression. Verified locally (this commit, workspace closure rebuilt first): - `pnpm --filter @objectstack/cli build` — clean - `pnpm --filter @objectstack/cli typecheck` — clean - `pnpm --filter @objectstack/cli exec vitest run test/serve-node-env-production-default.e2e.test.ts` — 3/3 passed - `pnpm --filter @objectstack/cli exec eslint test/serve-node-env-production-default.e2e.test.ts vitest.config.ts` — clean Not yet done at push time: a dedicated local reproduction of the CI spawn-load flake (an in-progress 24-file concurrent-spawn run was interrupted, inconclusive either way — this remains a documented-but-unreproduced-locally diagnosis, not a confirmed one) and check:type-check-debt --re-measure on this exact diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
os-elon
commented
Aug 23, 2026
PM seat — shift ending. Not enqueued. Recording a split verdict on the two fixes so the next seat does not inherit only the dev report's recommendation. Fix 1 — |
The CI red on #11268 was one test file failing with oclif's own "Error: command serve not found" before serve.ts ran a line. Re-derived rather than inherited, and the mechanism is this card's own subject matter turned back on the harness. turbo.json declared "@objectstack/cli#test": { dependsOn: ["^build"] } — dependencies only, never the package's own build. Measured with `turbo run test --filter=@objectstack/cli --dry=json`: 58 tasks, 57 of them #build, and @objectstack/cli#build absent from the test task's resolved dependencies. So packages/cli/dist does not exist when the Test Core shard runs, and it never has. Five other files in this package name bin/run.js. Two only assert the path as a string. The three that spawn it pass NODE_ENV: 'development' to the child for its --dev admin seed — and that is also the value that makes @oclif/core's tsPath() rewrite the command target from the declared ./dist/commands to ./src/commands and auto-transpile (lib/util/util.js: isProd = () => !['development','test'].includes(process.env.NODE_ENV ?? '')). Those three have never touched dist/, so the undeclared build dependency stayed invisible. This pin cannot dodge it: unset NODE_ENV is the input under test, and unset is exactly the value that leaves isProd() true and the reroute off. So it is the only file in packages/cli that genuinely consumes dist/, and the first to depend on a prerequisite the graph did not declare. Fixed at the seam, not the symptom: dependsOn ["build"] (turbo's own build task already dependsOn ^build, so this is a superset). Same shape as "@objectstack/metadata#test" 38 lines above. Marginal cost measured at 9s on a tree whose dependency closure is already built — the shard's exact state — and ~0 on a turbo cache hit. Also removes bootServeWithRetry / looksLikeMissingCliCommand, added one commit earlier on the theory that the failure was a transient dist/commands read under concurrent spawn load. Removed because the theory is falsified by measurement, not because a rule forbids retry-wrapping: that retry was already live in the failing job 97166275854 and did not change the outcome. The failure is deterministic — with dist absent, unset and production both answer "command serve not found", test and development both resolve from src/. The file returns to its reviewed pre-retry shape. Verified in a dedicated worktree: - turbo graph after the change: 59 tasks, @objectstack/cli#test depends on @objectstack/cli#build. - End-to-end through the real mechanism: `rm -rf packages/cli/dist` then `turbo run test --filter=@objectstack/cli -- test/serve-node-env-production-default.e2e.test.ts` -> 57 tasks, 0 cached, `Test Files 1 passed (1)` / `Tests 3 passed (3)`. - Reverse-verified both legs on this exact tree, each rebuilt, the mutation confirmed on disk by anchored grep counts and in dist/ by ablation-dist-preflight.mjs: fix ablated -> `AssertionError: expected 401 to be 403`, `Tests 1 failed | 2 passed (3)`; restored -> `Tests 3 passed (3)`. - pnpm --filter @objectstack/cli typecheck / eslint — clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
Uh oh!
There was an error while loading. Please reload this page.
…ker's TEST
Any `packages/cli` e2e test that spawns a real `os serve` and builds the
child env with `{ ...process.env, … }` carries the vitest WORKER's
environment into the child. Vitest sets `TEST=true` on that worker
unconditionally, independent of `NODE_ENV`, and better-auth 1.7.1 reads
`TEST` directly:
@better-auth/core/dist/env/env-impl.mjs:36
const isTest = () => nodeENV === "test" || toBoolean(env.TEST);
better-auth/dist/context/create-context.mjs:210
skipOriginCheck: … isTest() ? true : false,
so the child had better-auth's own origin/CSRF validation switched OFF,
one layer below anything `serve.ts` or `plugin-auth` decide.
`helpers/serve-process.ts` now exports `childEnv()`, which drops `TEST`
and the whole `VITEST*` namespace before the caller's overrides go on,
and `runServe()` builds through it. The four `os serve` spawners in this
directory that roll their own child env are swept onto it too, including
#11268's pin, which had to unset `TEST` by hand.
Measured, same fixture and same probe, the env family the only
difference — POST /api/v1/auth/sign-in/email with
`Origin: https://evil.example.com`:
{ ...process.env, … } 401 INVALID_EMAIL_OR_PASSWORD (origin ACCEPTED)
childEnv({ … }) 403 INVALID_ORIGIN
only TEST stripped 403 INVALID_ORIGIN
`TEST` alone is load-bearing; the `VITEST*` entries are hygiene. Both
rows are pinned as real boots in `serve-process-child-env.e2e.test.ts`,
so the repair stays distinguishable from a no-op.
Part of #11267
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
Fixes#11113
The fix
packages/cli/src/commands/serve.ts(~1093-1116): the--devbranch that alreadydefaults
process.env.NODE_ENVto'development'when unset now has a siblingelsebranch that defaults it to
'production'when unset and--devwas not passed. Bothbranches sit at the same point in
run()— before anyawait import(...)in the fileand before every environment gate the boot reaches:
os startalready forcesNODE_ENV='production'on the unset case(
start.ts:347), but onlocalEnv— a child environment assembled for a spawn.os serveruns in-process, so there is no child env to default; the equivalent hasto mutate
process.env.NODE_ENVitself, at the same point that already has to be earlyenough for the
--devcase. An explicitly-setNODE_ENV(development,test,anything else) is never touched — both branches are
if (!process.env.NODE_ENV).Ordering proof
The trap this card calls out by name: setting the default anywhere below the first
await import(...)(line ~1250) or below the first gate read (portAutoShiftAllowedat~1114,
modeat ~1140) would look identical in a source diff and still ship with thedoor open, because those reads would already have run against
undefined.Proof, not assumption: the regression pin (below) spawns the built, shipped
entrypoint (
bin/run.js), asserts the observable behaviour of a gate several layersbelow
serve.ts(plugin-auth's CSRF/origin middleware, reached through better-auth'sown request pipeline) on a real HTTP response, and is reverse-verified against the
pre-fix tree. If the default were set too late, this pin would still fail post-fix —
it does not.
Behavior-flip table
Every
NODE_ENV-reading predicate in the tree (excluding*.test.ts), surveyed atorigin/main. Two populations, kept distinct — conflating them over-reports:Population A — reads the raw variable with
!== 'production'(or an equivalent"default when absent" shape). These flip, because unset used to read as "not
production" and now reads as
'production':'production')plugin-auth/auth-manager.ts:1927trustedOriginswildcard substitutionplugin-auth/auth-manager.ts:3672plugin-auth/auth-manager.ts:3161(generateSecret)dev-secret-<timestamp>whenOS_AUTH_SECRETunsetplugin-auth/auth-manager.ts:2625plugin-auth/auth-manager.ts:2788plugin-auth/auth-manager.ts:5149(isPhoneOtpDeliverable)plugin-dev/dev-plugin.ts:126(assertNotProduction, ADR-0115 D6)OS_ALLOW_DEV_PLUGIN)driver-sql/sql-driver.ts:9188(isProductionEnv)safeschema drift whenautoMigrate: 'safe'os migrateservice-sms/sms-service.ts:53(LogSmsTransport)service-settings/local-crypto-provider.ts:134(detectMode)runtime/standalone-stack.ts:710metadata-protocol/seed-loader.ts(resolveEnvConfig)Seed.envdatasets'prod'→ scoped correctlyRows 3 and 10 are the two most consequential: a deployment that boots today purely
because it never set
OS_AUTH_SECRET/ never configured a stable crypto key will nowrefuse to boot instead of running with a forgeable session secret / an ephemeral
data-encryption key. That is
os servegaining the exact guaranteeos starthas hadsince #5673 — not a new hazard, and not something this PR carves an exception for.
Not flipped, deliberately checked and excluded — already correct before this
change:
serve.ts:1140-1141(mode)NODE_ENV === 'test' ? 'test' : (NODE_ENV || 'production')'production'— this computes the.env*cascade selector, never the raw variableserve.ts:1114(portAutoShiftAllowed),serve.ts:1188(isDev)flags.dev || NODE_ENV === 'development''development'; unset and'production'both fail it identicallystart.ts:156-157(mode)modedoctor.ts:159(doctorNodeEnv),doctor.ts:205(nodeEnvCheck)NODE_ENV || 'production',if (NODE_ENV) …os doctoris a separate process — unaffected by this fix; its own fallback already reads unset as productionspec/discovery.zod.tsresolveDiscoveryEnvironment(http-dispatcher.ts,metadata-protocol/protocol.ts)if (spelling === '') return 'production'/discoveryenvironmentfield was already'production'on an unset bootplugin-auth/auth-plugin.ts:1485(maybeSeedDevAdmin),runtime/app-plugin.ts:1379(hot-reload seeder),plugin-auth/auth-manager.ts:4300(devSeedAdmin)NODE_ENV !== 'development'/=== 'development''development'; unset was already excluded,'production'still isservice-analytics/analytics-service.ts:701(debugSql)NODE_ENV === 'development'service-datasource/sqlite-driver-fallback.ts:113,default-datasource-driver-factory.ts(doc),standalone-stack.ts:566(factoryDev)NODE_ENV === 'development'Out of scope for the survey:
verify/harness.ts(test harness that force-setsNODE_ENV='development'for its own in-memory posture, unrelated to any real boot) andbin/run-dev.js(the tsx dev-shim entrypoint, which unconditionally setsNODE_ENV='development'and is never used to serve a real deployment).The ruling's condition names the pin as "
os servewith unsetNODE_ENV⇒ the localhosttrusted-origin convenience gate is CLOSED." Measured against
os serve's own defaultwiring (letting the CLI auto-inject
AuthPlugin, the shape the wording most naturallyreads as), that statement is already true today, unconditionally, unrelated to this
fix — and stays true with the fix reverted. Two things in
serve.tsmask row 1 of thetable above from that path entirely:
serve.tsalways pushes the resolvedbaseUrl's origin intotrustedOriginsbefore handing it to
AuthPlugin, soauth-manager.ts's own!origins.lengthgatenever sees an empty array through that path.
serve.tshas its own, separate localhost-wildcard convenience(
if (isDev && …) trustedOrigins.push('http://localhost:*')), gated onisDev = flags.dev || NODE_ENV === 'development'— an equality test against'development'that was never open on unsetNODE_ENVto begin with.So a pin built against the auto-injected path would be green with the fix reverted —
vacuous, one layer further down than the ordering trap the card names explicitly. The
gate row 1 is actually about —
auth-manager.ts's own substitution — is reached by ahost app that constructs
AuthPluginitself without pre-populatingtrustedOrigins(asupported, real shape:
serve.ts'shasAuthPlugincheck exists precisely to detect anddefer to it). The committed pin uses that shape. Full reasoning and the measured evidence
are in the pin file's own header comment
(
packages/cli/test/serve-node-env-production-default.e2e.test.ts).I did not widen the fix to change
serve.ts's own auto-injection wiring (rows outsidethis card's scope) — flagging this as the pin's target, not as a defect to fix here.
Regression pin (anti-vacuity)
New file:
packages/cli/test/serve-node-env-production-default.e2e.test.ts. Boots thereal, built
bin/run.js(not the tsx dev shim — that unconditionally setsNODE_ENV=developmentbefore argv is even parsed, which would make the unset-NODE_ENVinput this card is about unreachable) three times, each with an explicit
OS_AUTH_SECRETthreaded into the fixture's own
new AuthPlugin({ secret })(so the pin measures only thetrusted-origin gate, never the orthogonal "AuthPlugin.init() throws: secret is required"
path), and POSTs a bogus sign-in with
Origin: http://localhost:<a port different from the one the server bound to>and no cookie — a shape better-auth's CSRF middleware forces anorigin check on before it looks at the credentials, so the outcome is observable
independent of whether the sign-in itself would have succeeded:
NODE_ENVunset → asserts403withbody.code === 'INVALID_ORIGIN'(gate CLOSED).This is the card's regression pin, on the fixture shape that actually reaches the
gate (see the correction above).
NODE_ENV=development(explicit) → asserts the gate stays OPEN (not403/INVALID_ORIGIN) — proves the default never overrides an explicitly-set value.NODE_ENV=test(explicit) → same assertion, same reason.Reverse-verified, real measurements (
packages/clibuilt both ways,pnpm --filter @objectstack/cli exec vitest run test/serve-node-env-production-default.e2e.test.ts):× NODE_ENV unset: … (regression pin)—AssertionError: expected 401 to be 403, naming the exact gate (the trusted-origin substitution left the probe'smismatched-port origin accepted; better-auth answered
401 INVALID_EMAIL_OR_PASSWORDinstead of403 INVALID_ORIGIN). The other two legs passed.Tests 1 failed | 2 passed (3).Tests 3 passed (3).A second manual measurement (
curl, outside any test harness) on the same pre/postbuilds confirms the identical flip at the HTTP layer:
{"message":"Invalid origin","code":"INVALID_ORIGIN"}(403) post-fix vs.{"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"}(401)pre-fix, for the same probe.
A second, unrelated trap the pin's own header documents
Early attempts at this pin passed regardless of the fix, both directions — a silent
vacuity one layer below the one the ordering trap warns about. Cause:
env: { ...process.env, … }when constructing the spawned child's environment carries thevitest worker's own env, and vitest's worker sets
TEST=trueunconditionally.better-auth 1.7.1 reads
TESTdirectly (@better-auth/core/env'sisTest = () => nodeENV === 'test' || toBoolean(env.TEST)), andcreate-context.mjsdefaults
skipOriginCheck: … isTest() ? true : false— independent ofNODE_ENV. Achild that inherits
TEST=truehas origin validation disabled by better-auth itself, nomatter what this fix does. The pin now explicitly unsets
TESTfor the spawned child,the same way it unsets
NODE_ENV, with the mechanism documented inline. This is a latentfootgun for any other e2e test in this repo that spawns a real
os serveunder vitestand cares about auth/CSRF behaviour while spreading
...process.env(the sharedrunServe()helper included) — filed as #11267 rather than fixed repo-wide here.Explicit-
NODE_ENVlegsCovered by the pin's second and third
it()blocks above (development,test) —both pass on the fixed tree, proving the production default never overrides an
explicitly-set value.
The CI red, and why closing it took a build-graph fix (handover, R31)
Test Core (2/6)was red atadb1b9ea8cwith only this PR's own new pin failing(1 failed / 1824 passed;
check-test-completenessaccounted for all 1825, so nothingwas silently skipped):
⛔ Not the fork-back condition, re-derived rather than inherited: the CLI never started,
so no
NODE_ENV-reading predicate in the table above was ever evaluated. No gate outsidethe table flipped, and no legitimate workflow is implicated. ⛔ Not the Corepack/undici
infrastructure signature of #11300 either — that job set
STORE_PATHand ran 1825 tests.The mechanism — this card's own subject matter, turned back on the harness
turbo.jsondeclared"@objectstack/cli#test": { "dependsOn": ["^build"] }—dependencies only, never the package's own build. Measured with
pnpm exec turbo run test --filter=@objectstack/cli --dry=json: 58 tasks, 57 of them#build, and@objectstack/cli#buildabsent from the test task's resolveddependency list. So
packages/cli/distdoes not exist when the shard runs, and never has.invocation-loudness.e2e.test.ts:34-35already states that fact in prose.The question that fact alone cannot answer is why the four other files in the same
161-file suite that spawn
bin/run.jsall passed in the same job. The answer is@oclif/core'stsPath(). It skips the TypeScript path lookup only whenisProd(), andlib/util/util.js:66defines that as:Under
'development'or'test', oclif silently rewrites the command target from thedeclared
./dist/commandsto./src/commandsand auto-transpiles. Two of those filesonly assert the path as a string; the three that actually spawn it
(
serve-mcp-stdio-answers:115,serve-mcp-capability-collision:119,serve-stdio-stdout-purity:109) each passNODE_ENV: 'development'to the child for its--devadmin seed — so they have never toucheddist/at all, and the undeclaredbuild dependency stayed invisible for as long as it did.
This pin cannot dodge it. Unset
NODE_ENVis the input under test, and unset is exactlythe value that leaves
isProd()true and the reroute off. Measured directly,distabsent:NODE_ENV› Error: command serve not found(byte-identical to CI)productiontestsrc/(task: findCommand (compile))development⇒ this PR's pin is the only file in
packages/clithat genuinely consumespackages/cli/dist, and the first to depend on a prerequisite the graph did not declare.Corroborated by running the pre-existing
serve-mcp-stdio-answers.e2e.test.tson the samedist-absent tree: it fails identically (
Test Files 1 failed (1),Tests 1 skipped (1)).The fix: the seam, not the symptom
turbo.json, one key —"@objectstack/cli#test"→"dependsOn": ["build"](turbo's ownbuildtask alreadydependsOn: ["^build"], so this is a superset, not a replacement).Same shape as
"@objectstack/metadata#test"38 lines above in the same file.Rejected alternative: having the pin build or verify
distitself, which would havestayed inside this PR's original four-file surface. It is consumer-side compensation for a
producer-side omission: it makes one test file responsible for running
tsc, nests aturbo invocation inside the turbo task already running
test, and — hoisted to aglobalSetup— would tax all 161 files and force atestblock into a config whose headerdocuments why it deliberately has none. The deciding reason is the fourth: it leaves the
task graph still asserting a false dependency, so the next author who writes a built-CLI
test here hits the identical wall with no signal. Widening the surface by this one key was
ruled on explicitly before it was edited.
Measured cost: 9s.
pnpm --filter @objectstack/cli run buildon a tree whosedependency closure is already built — the shard's exact state after
^build— ran in 9s,and ~0 on a turbo cache hit.
Verified end-to-end through the real mechanism, not by inspection:
rm -rf packages/cli/dist, thenpnpm exec turbo run test --filter=@objectstack/cli -- test/serve-node-env-production-default.e2e.test.ts→
Tasks: 57 successful, 57 total/Cached: 0 cached/Test Files 1 passed (1)/Tests 3 passed (3), withpackages/cli/dist/commands/serve.jspresent afterwards.The bounded retry is removed — because the theory was falsified, not because a rule forbids it
adb1b9ea8caddedbootServeWithRetry/looksLikeMissingCliCommand, retrying once onoclif's exact "command not found" sentence, on the theory that the failure was a transient
dist/commandsglob read under this suite's concurrent-spawn load. That retry wasalready live in the failing job 97166275854 and did not change the outcome. The failure
is deterministic — the
NODE_ENVtable above reproduces it on demand — so there was nevera transient to absorb. The measurement is what makes removing it safe rather than dogmatic;
the file returns to its reviewed pre-retry shape, and the prerequisite is now declared in
the build graph where it is true for whoever writes the next built-CLI test here.
The
packages/cli/vitest.config.tsalias from that same commit stays: it is a separate,correct fix for
check:test-source-alias, and that gate is green.Reverse-verified again on the final tree
Both legs rebuilt, the mutation confirmed on disk by anchored grep counts before each
rebuild and inside
dist/byscripts/ablation-dist-preflight.mjsafter it (markerelse if (!process.env.NODE_ENV), verified unique todist/commands/serve.js); exit codescaptured by redirect-then-capture, never through a pipe; a
trap … EXIT INT TERMrestoredand rebuilt on every leg.
✓ marker absent from all 420 built files→× NODE_ENV unset: the localhost trusted-origin convenience gate is CLOSED (regression pin),AssertionError: expected 401 to be 403 // Object.is equality,Test Files 1 failed (1)/Tests 1 failed | 2 passed (3).✓ marker present in 1 built file→Test Files 1 passed (1)/Tests 3 passed (3).So the pin still catches the original defect on exactly the tree this change produces, and
the only difference between the CI red and 3/3 green is whether
packages/cli/distexists.Out-of-scope finding, filed not fixed
#11317 (unassigned): the three e2e files above each carry the comment
"
bin/run.js— the SHIPPED entrypoint" while never reachingdist/, for thetsPathreason measured here. Distinct from #11267 (inherited
TEST=true/ better-auth originskip) — same family, different mechanism. Not fixed here; this PR's surface is the CI red.
Gate results
Re-run in full at
98e8399f(the final head, after the handover commit), workspaceclosure built first — every line below re-measured there, all green, all exit codes
captured before any pipe. The gate list was re-derived at that head with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(self-derivedfrom the merge base); adding
turbo.jsonnamed no new family. Two additions to whatthe list below already recorded:
pnpm check:type-source-resolution(
OK — 77 packages with a tsconfig.json scanned), which the derivation names and theearlier run omitted, and
pnpm check:type-check-debt --re-measure, which is now a realverdict at this head rather than a refusal:
OK — 33 ledger entr(ies) re-measured in 232.7s, 1897 raw tsc error(s) total, none above its recorded number.pnpm check:single-claim-pathsis green andturbo.jsonis not one of its declaredpaths (the list holds only
.objectui-sha), so contention was checked directly instead:none of the 12 other open PRs touches
turbo.json.Originally built and measured at
61810a5a0e:@objectstack/cliand its full workspacedependency closure.
pnpm --filter @objectstack/cli typecheck—tsc --noEmit, clean, no output.pnpm --filter @objectstack/cli exec vitest run test/serve-node-env-production-default.e2e.test.ts—Test Files 1 passed (1)/Tests 3 passed (3).node scripts/pm/dispatch-gates.mjs(no paths — self-derived from the merge base),every named local gate run:
pnpm check:changeset-gate-self-tests— ✓ (all three self-tests pass)node scripts/check-empty-changeset.mjs—✓ No empty-frontmatter changeset introducednode scripts/check-adr-0087-registration.mjs—✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition([BREAKING] not-required (no-migration-prescription))node scripts/check-changeset-no-major.mjs—✓ This diff introduces no major bumppnpm check:objectui-changeset—✓ objectui-range --self-test: all checks passedpnpm check:published-files— ✓ (self-test + repo check both pass)pnpm check:route-envelope— ✓ (existing ratchets unmoved by this diff)pnpm check:slot-lookup—✓ ratchet holds … none newpnpm check:test-source-alias—check-test-source-alias OKnode scripts/check-ci-filter-parity.mjs—OK: all 88 declared cross-package glob(s) …node scripts/check-cross-package-test-inputs.mjs—OK: 14 package(s) read outside themselves, all declarednode scripts/check-plugin-teardown-shape.mjs— ✓ (baseline fully burned down, unmoved)node scripts/docs-audit/check-affected-docs.mjs— self-test passes, exit 0pnpm check:type-check-coverage—OK — 65/78 workspace packages type-checked …(unmoved by this diff)pnpm check:query-options-erasure—✓ ratchet holds: … none newpnpm check:engine-double-contract—OK — 384 pinned, 133 in the DEBT ledger, 2 exempt(unmoved)pnpm check:where-matcher—✓ conformance holds: 282 matcher(s) … 0 silently-wrong … none newnode scripts/check-nul-bytes.mjs—OK (scanned 6529 text file(s) … no raw ASCII control bytes)pnpm check:type-check-debt --re-measure(full workspace closure built first) —check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 266.0s, 1897 raw tsc error(s) total, none above its recorded number.Firstmeasurement caught a real regression: the new pin's
spawn(..., { stdio: ['ignore', 'pipe', 'pipe'] })was cast toChildProcessWithoutNullStreams, which has astdin, when the actual return type (ChildProcessByStdio<null, Readable, Readable>) doesn't —@objectstack/cli's TEST_DEBT ledger moved 146 → 147(TS2352). Fixed by typing it correctly; re-measured green.
Changeset
.changeset/serve-node-env-production-default.md,@objectstack/cli: minor. This isa deliberate default flip with the breakage profile spelled out above (rows 3 and 10 in
particular can turn a previously-booting unset-env deployment into a boot refusal), not a
patch-level bugfix — argued explicitly in the changeset body per this card's own
instruction. Carries an ADR-0087
not-required (no-migration-prescription)disposition:this changes a CLI runtime default, not an authorable metadata contract — confirmed by
check-adr-0087-registration.mjsabove.Out of scope
Dev-only gates spelled
NODE_ENV !== 'production'still open in a real production deployment that never sets NODE_ENV (os servedoes not force it,os startdoes) #11113 already made that call; this PR implements it as ruled.Every row in the flip table above is a consequence of the default, not a separate
edit to the gate itself.
serve.ts's own auto-injection wiring (the two mechanisms that mask row 1 from thedefault
AuthPluginpath) — correct as designed for that path, not part of this card.os doctor's ownNODE_ENVreporting — unaffected (separate process), confirmed inthe "not flipped" table above.
TEST-env-leak footgun in vitest-spawned e2e tests generally (not just this pin,and not just this file —
runServe()inpackages/cli/test/helpers/serve-process.tscarries the same exposure for every test that imports it) — filed as e2e tests that spawn a real
os serveunder vitest inheritTEST=true, which makes better-auth silently skip origin/CSRF validation regardless ofNODE_ENV#11267,unassigned.
Generated by Claude Code
Generated by Claude Code
Generated by Claude Code