refactor(cli): let the webserver schema own web --host/--port validation

The adapter no longer validates --host/--port or declares the allowed set:
LOOPBACK_HOST/ALL_INTERFACES_HOST leave args.ts. --host/--port are now
unvalidated pass-through overrides — the adapter only Number-coerces the port
string (the dsh-host-webserver schema wants a number). That schema
(host a 127.0.0.1/0.0.0.0 literal union, port a natural <= 65535) is the single
source of both the default (the shipped cordis.yml webserver row) and validity;
AppCLIEntry patches an explicit flag into that row, so a bad host/port fails
loud at the schema on boot (verified: `dsh web --host 9.9.9.9` and `--port abc`
both exit 1 with the schema's ValidationError).

web.ts keeps two display-only literals (the printed loopback URL, the
all-interfaces LAN-detection check), commented as mirrors of the schema, not a
source of truth. Agent Note + Chinese pair and README updated; the args spec
drops the host/port exit-code cases (now the schema's job, covered by the web
smoke on boot).
This commit is contained in:
Turtle
2026-07-25 16:19:02 +08:00
parent 6a8049879e
commit 9f6dbde7f6
7 changed files with 44 additions and 45 deletions

View File

@@ -2,7 +2,7 @@
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or an invalid `--host`/`--port`/`--resume` value fails loud (stderr, exit 1) instead of misrouting.
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot.
The TUI surface:

View File

@@ -11,11 +11,6 @@
import { Command, CommanderError } from 'commander'
/** The loopback host `dsh web` binds by default. */
export const LOOPBACK_HOST = '127.0.0.1'
/** The all-interfaces host `dsh web` accepts to expose the UI on the LAN. */
export const ALL_INTERFACES_HOST = '0.0.0.0'
/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume <id>` rehydrates a session. */
interface TuiInvocation {
mode: 'tui'
@@ -31,9 +26,12 @@ interface HeadlessInvocation {
/**
* Browser UI: `dsh web`. `host`/`port` are present only when the flag was
* passed (validated: host is loopback/all-interfaces, port a 065535 integer);
* absent means the shipped `cordis.yml` default stands, so the yml is the sole
* source of the default. `dev` mounts the client HMR driver.
* passed — pass-through overrides with no CLI default and no CLI validation:
* the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal,
* `port` a natural ≤ 65535) is the single source of both the default (the
* shipped `cordis.yml` value stands when a flag is absent) and validity (a bad
* value fails loud at boot). `port` is `Number`-coerced only because the schema
* wants a number, not a string. `dev` mounts the client HMR driver.
*/
interface WebInvocation {
mode: 'web'
@@ -45,29 +43,24 @@ interface WebInvocation {
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
/** Raw web-subcommand options before validation. */
/** Raw web-subcommand options straight from Commander. */
interface WebOptions {
host?: string
port?: string
dev?: boolean
}
/** Validate and narrow the raw `web` options; a bad value fails loud via `command.error`. */
function resolveWeb(command: Command, options: WebOptions): WebInvocation {
if (options.host !== undefined && options.host !== LOOPBACK_HOST && options.host !== ALL_INTERFACES_HOST) {
command.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`)
}
let port: number | undefined
if (options.port !== undefined) {
port = Number(options.port)
if (!/^\d+$/.test(options.port) || !Number.isInteger(port) || port > 65535) {
command.error('error: --port must be an integer in 0-65535')
}
}
/**
* Narrow the raw `web` options into a {@link WebInvocation}. No host/port
* validation: both flow to the webserver schema, which is the sole gate. `port`
* is coerced to a number (the schema rejects a string) but not range-checked
* here — `NaN`/out-of-range fail loud at the schema on boot.
*/
function resolveWeb(options: WebOptions): WebInvocation {
return {
mode: 'web',
...options.host !== undefined && { host: options.host },
...port !== undefined && { port },
...options.port !== undefined && { port: Number(options.port) },
dev: options.dev === true,
}
}
@@ -116,10 +109,10 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)')
web
.option('--host <host>', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`)
.option('--port <port>', 'listen port (0 requests an OS-assigned port)')
.option('--host <host>', 'override the config bind host (127.0.0.1 or 0.0.0.0)')
.option('--port <port>', 'override the config listen port (0 requests an OS-assigned port)')
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
.action((options: WebOptions) => { resolved = resolveWeb(web, options) })
.action((options: WebOptions) => { resolved = resolveWeb(options) })
try {
program.parse(argv, { from: 'user' })

View File

@@ -1,21 +1,27 @@
/**
* `dsh web` — thin bin over the config-tree boot: run AppCLIEntry with the
* already-parsed host/port/dev, print the URL line, wire signals. All
* composition lives in cordis.yml; all boot glue lives in AppCLIEntry. The
* argument adapter validated host (loopback/all-interfaces) and port (065535).
* composition lives in cordis.yml; all boot glue lives in AppCLIEntry. Host and
* port are unvalidated pass-through overrides — the `dsh-host-webserver` schema
* gates them at boot.
*/
import { networkInterfaces } from 'node:os'
import { fileURLToPath } from 'node:url'
import { AppCLIEntry } from './app-cli-entry.ts'
import { ALL_INTERFACES_HOST, LOOPBACK_HOST } from './args.ts'
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
// Display-only mirrors of the webserver schema's allowed hosts: the loopback
// address the local URL always prints, and the all-interfaces value that gates
// LAN-address discovery. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
const ALL_INTERFACES_HOST = '0.0.0.0'
/**
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
* through only when the flag was given; absent, the `cordis.yml` value stands.
* @param host - the bind host ({@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST}), or `undefined` to keep the config default.
* @param host - the bind host, or `undefined` to keep the config default.
* @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
* @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
*/

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ALL_INTERFACES_HOST, parseDshArgs } from '../src/args.ts'
import { parseDshArgs } from '../src/args.ts'
const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3')
@@ -31,18 +31,18 @@ describe('parseDshArgs', () => {
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
// Bare `web` carries no host/port: the shipped cordis.yml owns the default.
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080', '--dev']))
.toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080, dev: true })
// Host/port are unvalidated pass-throughs (the webserver schema gates them
// at boot); the adapter only coerces the port string to a number.
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev']))
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true })
})
it('exits nonzero instead of silently starting fresh, serving, or dropping inputs', () => {
// Empty resume/prompt would be swallowed downstream; bad host/port must not
// reach the listener; --prompt mixed with TUI inputs must not lose them.
it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
// Empty resume/prompt would be swallowed downstream; --prompt mixed with
// TUI inputs must not lose them. (Bad host/port are gated by the webserver
// schema at boot, not here.)
expect(exitCode(['--resume='])).toBe(1)
expect(exitCode(['-p', ''])).toBe(1)
expect(exitCode(['web', '--host', '10.0.0.1'])).toBe(1)
expect(exitCode(['web', '--port', 'abc'])).toBe(1)
expect(exitCode(['web', '--port='])).toBe(1)
expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1)
expect(exitCode(['--bogus'])).toBe(1)