Uh oh!
There was an error while loading. Please reload this page.
fix(cli): one port contract, three doors — dev/start/serve refuse in the operator's own spelling (#12673) - #12898
Conversation
… the operator's own spelling Extract the port validation that #12662 landed in `serve.ts` — the range constants, the `parseInt`-based reader and the refusal prose — into `packages/cli/src/utils/port-contract.ts`, and have `dev`, `start` and `serve` each call it at their own door, before spawning anything. Before this, only `serve` validated. A value typed at `dev` or `start` reached the spawned child on a channel that renamed it, so the refusal named a spelling the operator had not used: `PORT=abc os dev` was refused as `--port "abc"`, and `os start --port 99999` as `PORT="99999"`. The range is declared in exactly one place in the repository, which is what the ruling protects: #12620 and #12662 both declined to copy it to a second entry point, and a shared single source is the front of that judgement. `start` deliberately does NOT gain `Flags.integer({ min, max })` — measured against @oclif/core 4.13.3, neither a flag `parse` nor an integer `min`/`max` runs over a value supplied by a flag's `default`, which is how $PORT and $OS_PORT reach the CLI, so a flag-layer bound would be inert on two of the three channels and would be a second copy of the range besides. The end-to-end accept set is unchanged, measured rather than argued: 18 port texts driven through all three real commands on all three channels, before and after, verdict-identical row for row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…ngle-source-port-contract
📓 Docs Drift CheckThis PR changes 1 package(s): 33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 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 f57ae266669ac7442c06cf39cf50b3efe412ed49 && git checkout f57ae266669ac7442c06cf39cf50b3efe412ed49
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 96732b03a1dd387f427dd65fdc9a84dc9695f50a cbc34b9723377e6e95d672f845cf9d39093c3074 && git checkout -B drift-repro 96732b03a1dd387f427dd65fdc9a84dc9695f50a && git merge --no-ff cbc34b9723377e6e95d672f845cf9d39093c3074
node scripts/docs-audit/affected-docs.mjs --json 96732b03a1dd387f427dd65fdc9a84dc9695f50a
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12673
Maintainer ruling 2026-08-28 (option 甲, comment 5448628200), answering the
2026-08-27 instruction 「12673 不要临时打补丁,给我考虑长远合理的方案」: the port
validation #12662 landed in
serve.tsbecomes ONE shared module, anddev/start/serveeach refuse at their own door, in the operator's own spelling,before spawning. The child keeps validating from the same import.
What moved
packages/cli/src/utils/port-contract.tsnow owns the range constants, theparseInt-based reader, the source discriminator and both notices. All threecommands import it.
serve's own call site is byte-identical to before, so theplacement pins #12662 left behind (guard ahead of the auto-shift branch, refusal
written through
printDiagnostic) still hold what they held.Criterion 1 — exactly one copy of the range, falsified rather than asserted
The ruling is explicit that a shared single source is the FRONT of the #12620 /
#12662 judgement, not its reverse: "still exactly one copy of the range, which
is what #12620/#12662 protected". Measured across the working tree, tracked and
untracked, excluding
distandnode_modules:The control is not a substring of either term under test, and neither term is a
substring of it — asserted mechanically in the new test, not eyeballed, because
this card touches
PORTandOS_PORTwhere one contains the other.The zero-assertion caught a bad grep of its own on the way: the first run used
git grep, which reads only TRACKED files, so it returned nothing about abrand-new untracked module. The control is what exposed it.
Precision the claim needs:
65535does appear elsewhere in the repo, and thoseare DIFFERENT contracts, not copies of this one — an SMTP client port bound in
plugin-emailandservice-settings(floor 1, not 0, deliberately) and SQL rowbyte budgets in
driver-sql. The claim is about the CLI listen-port range.New test
packages/cli/src/utils/port-contract-single-source.test.tskeeps thezero, the control and the door wiring pinned. Ablation, from the committed
state, restoring with
git checkout HEAD --under a trap and proving therestore by blob hash plus an empty
git diff HEAD:Criterion 2 — the end-to-end accept set does not change, measured
A table of 18 port texts driven through all three REAL commands on all three
channels (
--port,$PORT,$OS_PORT) — 162 rows — before the change andagain after, compared row by row:
And 18 REAL binds (the server actually listening, port read off its own banner),
covering the coercing spellings, all identical before and after:
The 13 spelling changes are the repair:
PORT=abc os devInvalid port: --port "abc"Invalid port: PORT="abc"OS_PORT=abc os devInvalid port: --port "abc"Invalid port: OS_PORT="abc"os start --port 99999Invalid port: PORT="99999"Invalid port: --port "99999"os servechanged on zero rows: it already named what it could see.One accept-set row is load-bearing and deliberately preserved:
os dev --port ""BOOTS today. The empty string is falsy, so
devforwards nothing and the childresolves its own default. A door that refused every text the reader rejects
would refuse a value that starts a server, so
dev's door shares the forwardingguard rather than restating it.
The oclif behaviour, re-measured
Driving
Parser.parsedirectly against this checkout's@oclif/core4.13.3:So the warning holds and is wider than stated:
Flags.integer'smin/maxisinert over a
defaulttoo, not onlyparse.$PORT/$OS_PORTreach the CLIthrough exactly such a default, so a validator hung on the flag layer would guard
--portalone. The ruling's engineering fallback is therefore the route taken:an explicit pre-spawn call of the shared module at each door. Note also that
oclif's own
env:OPTION is a different channel from adefaultthat readsprocess.env— the first is validated, the second is not, and no command hereuses the first.
Verification
Run at the final commit
cbc34b972.pnpm --filter @objectstack/cli typecheck— clean. Confirmed withtsc --listFilesthat all three edited/added files are in the program (2 testfiles plus the new module), so the green covers them rather than skipping them.
pnpm --filter @objectstack/cli exec vitest runover the 4 port suites plus 3neighbouring dev/start pins —
Test Files 7 passed (7) · Tests 66 passed (66).pnpm lint— the FULL repo run (eslint . --no-inline-config), exit 0 in 93s.No narrowing claimed.
check:nul-bytes,check:cross-package-test-inputs,check-comment-mask-adoption,check-empty-changeset,check-changeset-no-major,check-adr-0087-registration,check:pm-half-states,check:objectui-changeset,check:changeset-gate-self-tests,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:test-source-alias,check:type-source-resolution,check:route-envelope,check:published-files,check:page-declaration-shape,check:slot-lookup,check:objectql-double-limit,check-plugin-teardown-shape,check-ci-filter-parity,check-affected-docs— all exit 0.check:type-check-coverageandcheck:type-check-debt --re-measurewere NOT run locally. They need the wholeworkspace closure built and re-measure tsc per ledger entry; CI runs them on
every PR. What that green would have covered and this run does not: whether the
new test file lands outside every tsc program accounting for its package — the
--listFilescheck above is the same question asked of this package only.check:nul-bytesearned its keep here: the first draft of the new test wrote anANSI escape as a regex literal and the editing tool materialised a real 0x1b
byte. The neighbouring suite already builds that byte with
String.fromCharCodefor exactly this reason, and this file now does too.
Out of scope, found on the way
Neither is repaired in this PR, and neither is filed: the dedup channel returns
403 from this seat, so both are reported upward for the PM to file.
os start --portis silently overridden by$OS_PORT.startwrites theflag into the child's environment as
PORT, but the child's flag defaultprefers
$OS_PORTover$PORTandstartnever clears it. Measured:OS_PORT=41077 os start --port 41078printsConsole: http://localhost:41078/_console/and then binds 41077. Pre-existing, untouched by this diff, and separate from
this card (a precedence/forwarding question, not the range or the spelling).
its own message text in
plugins/plugin-email/src/transports/smtp.ts, and theform bound in
services/service-settings/src/manifests/mail.manifest.ts. Samedefect class this card repairs, different family.
Generated by Claude Code