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
12 changes: 12 additions & 0 deletions .changeset/26243-port-flag-reaches-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/cli': patch
---

fix(cli): `os dev -p N` and `os serve --port N` export the port as `OS_PORT`/`PORT`, so a config that reads the port sees the one the server listens on

Clause-②: no
Metadata-protocol: none

- **Before.** The port flag reached the listener only. A config that builds a URL from `OS_PORT`/`PORT` while it loads, such as `examples/app-showcase`'s self URL, got the inherited value or the 3000 default. On the showcase with no `OS_PORT`/`PORT`, `os dev -p 41302` and `os serve --port 41303` both served `GET /api/v1/meta/connector/showcase_status_api` with `http://127.0.0.1:3000`, and the self-ping flow answered `400 FLOW_FAILED … fetch failed`.
- **Now.** `os dev` and `os serve` export the port the flag selected as both `OS_PORT` and `PORT` before anything loads the config, the way `os start` already handed it to its server. Under `os dev` that covers the compile, the watch recompiles and the server. The flag overrides an inherited `OS_PORT` for the config, as it already did for the listener. The same two boots now serve `:41302` / `:41303`, and the ping answers 200.
- With no port flag, the environment is left as it is. The exported value is the parsed port (`--port 3e3` exports `3`, the port it selects).
9 changes: 6 additions & 3 deletions examples/app-showcase/src/system/self-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@
* 2. `OS_PORT`, then its deprecated alias `PORT` — **the same names, in the
* same order, that the CLI itself reads to choose the listen port**
* (`packages/cli/src/commands/serve.ts`: `readEnvWithDeprecation('OS_PORT',
* 'PORT') ?? '3000'`). Following the CLI's own inputs is what makes the
* isolated-port boot self-ping correctly with no extra configuration — the
* exact case #7538 was filed for.
* 'PORT') ?? '3000'`). A `--port` flag reaches these names too: `os dev`,
* `os serve` and `os start` export it as both before the config loads
* (`portFlagEnv` in `packages/cli/src/utils/port-contract.ts`, #26243).
* Following the CLI's own inputs is what makes the isolated-port boot
* self-ping correctly with no extra configuration — the exact case #7538
* was filed for.
* 3. `http://127.0.0.1:3000` — the historical literal, unchanged, so a plain
* `pnpm dev` behaves exactly as before.
*
Expand Down
104 changes: 57 additions & 47 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { OBJECTSTACK_MCP_SERVER_NAME } from '@objectstack/mcp';
// command spawns (#12673). ⛔ Nothing about ports is declared in this file —
// no range, no reader, no wording; a second copy of the bound is exactly what
// #12620 and #12662 protected against.
import { describePortSource, parseRequestedPort, formatInvalidPortNotice } from '../utils/port-contract.js';
import { describePortSource, parseRequestedPort, formatInvalidPortNotice, portFlagEnv } from '../utils/port-contract.js';
// The ONE dev-TLS contract, shared with the `serve` child this command spawns
// (#16804). ⛔ Nothing about certificates is declared in this file, and ⛔ no
// certificate is ever generated — the developer brings their own.
Expand Down Expand Up @@ -516,6 +516,62 @@ export default class Dev extends Command {
printKV('Config', configPath, '📂');
}

const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true });

// ── dev's own door on the ONE port contract (#12673) ──────────────
// Everything about the port — the range, the reader, the refusal prose —
// is imported from `utils/port-contract.ts`; this command declares none
// of it. That is the point of the card: `os dev` had no port validation
// at all, so an impossible value travelled to the `serve` child, which
// refused it under the name of the CHANNEL it arrived on. Measured on
// `origin/main` before this door existed: `PORT=abc os dev` and
// `OS_PORT=abc os dev` were both refused as `--port "abc"` — the one
// spelling the operator had not used, because the forwarding below
// renames every source to `--port`.
//
// ⭐ Placement: ahead of the compile below, which is the first child this
// command starts and the first process that evaluates the config, so a
// refused port starts nothing and a valid one is exported (below) before
// anything reads it. The refusal covers exactly the text this command
// FORWARDS: the `port ? …` guard in the spawn argv reads this same
// variable, so the two cannot drift.
//
// ⛔ The `if (port)` is load-bearing, and MEASURED rather than assumed:
// `os dev --port ""` boots today. An empty string is falsy, so the guard
// below drops it and the child resolves its own default — so a door that
// refused every non-parsing text would refuse a value that starts a
// server, narrowing a published command's accept set, which this card is
// forbidden to do. (`PORT=""` reaches the child by inheritance instead,
// and `serve` refuses it there naming `PORT` — correctly, since that IS
// the spelling the operator set.)
if (port) {
// `flags.port === undefined` answers the same question oclif's
// `setFromDefault` answers for `serve`: did this come from argv? This
// flag carries no `default`, so there is no oclif metadata to read —
// and none to trust either, since oclif runs neither a flag `parse`
// nor an integer `min`/`max` over a default (measured; see the
// `describePortSource` docblock).
const portSource = describePortSource(flags.port === undefined);
if (parseRequestedPort(port) === null) {
// stderr, like `serve`'s refusal and for the same #7915 reason: this
// command's stdout is the fd the child's stdio MCP transport writes
// JSON-RPC frames to.
process.stderr.write(`${formatInvalidPortNotice(port, portSource)}\n`);
process.exit(1);
}
// ── The flag's port is the port the config reads (#26243) ──────────
// A config reads `OS_PORT`/`PORT` at module evaluation (the showcase's
// self URL does), and it is evaluated in the compile below, the watch
// recompiles and the `serve` child, which all run in THIS process's
// environment. The flag reached none of them before: it travelled on
// argv alone, so `os dev -p 41302` served and dispatched
// `http://127.0.0.1:3000`. Exported here as `os start` hands it down
// (`portFlagEnv`), so the flag overrides an inherited `OS_PORT` for the
// config exactly as it does for the listener. With no flag, the
// operator's own variables stay as they are.
if (flags.port !== undefined) Object.assign(process.env, portFlagEnv(parseRequestedPort(port) ?? undefined));
}

// Compile only when the boot's artifact is one this command builds — a
// pinned artifact means "use this, don't rebuild" — and then by default
// (#26130, `devCompilesAtBoot`).
Expand Down Expand Up @@ -765,52 +821,6 @@ export default class Dev extends Command {
}
printKV('Database', redactConnectionUrl(effectiveDb), '🗄️');

const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true });

// ── dev's own door on the ONE port contract (#12673) ──────────────
// Everything about the port — the range, the reader, the refusal prose —
// is imported from `utils/port-contract.ts`; this command declares none
// of it. That is the point of the card: `os dev` had no port validation
// at all, so an impossible value travelled to the `serve` child, which
// refused it under the name of the CHANNEL it arrived on. Measured on
// `origin/main` before this door existed: `PORT=abc os dev` and
// `OS_PORT=abc os dev` were both refused as `--port "abc"` — the one
// spelling the operator had not used, because the forwarding below
// renames every source to `--port`.
//
// ⭐ Placement is adjacent to `port` ON PURPOSE, not merely convenient.
// The refusal has to cover exactly the text this command FORWARDS, and
// the `port ? …` guard in the spawn argv below is what decides that. A
// door hoisted to the top of `run()` would be a second copy of that
// decision, free to drift from it; four lines apart, the two read one
// variable. It is still ahead of every spawn, every socket and every
// child process, which is all "before spawning" has ever meant here.
//
// ⛔ The `if (port)` is load-bearing, and MEASURED rather than assumed:
// `os dev --port ""` boots today. An empty string is falsy, so the guard
// below drops it and the child resolves its own default — so a door that
// refused every non-parsing text would refuse a value that starts a
// server, narrowing a published command's accept set, which this card is
// forbidden to do. (`PORT=""` reaches the child by inheritance instead,
// and `serve` refuses it there naming `PORT` — correctly, since that IS
// the spelling the operator set.)
if (port) {
// `flags.port === undefined` answers the same question oclif's
// `setFromDefault` answers for `serve`: did this come from argv? This
// flag carries no `default`, so there is no oclif metadata to read —
// and none to trust either, since oclif runs neither a flag `parse`
// nor an integer `min`/`max` over a default (measured; see the
// `describePortSource` docblock).
const portSource = describePortSource(flags.port === undefined);
if (parseRequestedPort(port) === null) {
// stderr, like `serve`'s refusal and for the same #7915 reason: this
// command's stdout is the fd the child's stdio MCP transport writes
// JSON-RPC frames to.
process.stderr.write(`${formatInvalidPortNotice(port, portSource)}\n`);
process.exit(1);
}
}

const binPath = process.argv[1];
const requestedPort = port ?? '3000';

Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import {
parseRequestedPort,
formatInvalidPortNotice,
portTextReadNotice,
portFlagEnv,
MAX_PORT,
type PortInputSource,
} from '../utils/port-contract.js';
Expand Down Expand Up @@ -2209,6 +2210,14 @@ export default class Serve extends Command {
this.exit(1);
}
const requestedPort = parsedPort;
// ── A `--port` on argv is the port the config reads (#26243) ──────────
// The config is loaded in this process further down, and a config may read
// `OS_PORT`/`PORT` at module evaluation (the showcase's self URL does). The
// flag reached only the listener before: `os serve --port 41303` with no
// `OS_PORT` served and dispatched `http://127.0.0.1:3000`. Exported here
// the way `os dev` and `os start` export theirs (`portFlagEnv`); a port
// that came from the environment or the default is left as it is.
if (metadata.flags.port?.setFromDefault !== true) Object.assign(process.env, portFlagEnv(requestedPort));

// ── The TLS pair, refused here for the same reason the port is (#16804) ──
// Ahead of every socket and every import that branches on the protocol, and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ import { describe, it, expect, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
import { readEnvWithDeprecation } from '@objectstack/types';
import { parseRequestedPort } from '../utils/port-contract.js';
import { childPortEnv } from './start.js';
import { parseRequestedPort, portFlagEnv } from '../utils/port-contract.js';

/** The operator's own value, inherited by `start` and passed down. */
const OPERATOR_OS_PORT = '41077';
Expand All @@ -68,7 +67,7 @@ afterEach(() => {
* about: an inherited environment, plus whatever the port channel contributes.
*/
function composeChildEnv(flagPort: number | undefined, inherited: Record<string, string> = {}) {
return { ...inherited, ...childPortEnv(flagPort) };
return { ...inherited, ...portFlagEnv(flagPort) };
}

/**
Expand All @@ -92,7 +91,7 @@ describe('`os start` forwards --port on the channel its child reads first', () =
});

it('states the one value on the canonical name AND its legacy alias', () => {
const env = childPortEnv(FLAG_PORT);
const env = portFlagEnv(FLAG_PORT);

// Exact values, never containment — see the instrument note in the header.
expect(env.OS_PORT).toBe(String(FLAG_PORT));
Expand Down Expand Up @@ -134,8 +133,8 @@ describe('`os start` forwards --port on the channel its child reads first', () =
// ⭐ POSITIVE CONTROL first: the same probe, on the same helper, DOES see
// the keys when a flag is given. Without this line the assertion below
// would pass just as happily against a probe that can never see anything.
expect(hasPortKey(childPortEnv(FLAG_PORT))).toBe(true);
expect(hasPortKey(childPortEnv(undefined))).toBe(false);
expect(hasPortKey(portFlagEnv(FLAG_PORT))).toBe(true);
expect(hasPortKey(portFlagEnv(undefined))).toBe(false);

// …so an operator's own environment reaches the child untouched, under its
// own names, which is what `start`'s refusal door one process earlier
Expand Down
Loading
Loading