fix(cli): let cordis.yml own the web host/port default (single source)

The merge's "always pass adapter-resolved host/port to AppCLIEntry" made the
adapter's 127.0.0.1/3080 shadow apps/cli/cordis.yml's webserver row — editing
the yml port would have had no effect, a duplicated default.

The adapter now assigns no host/port default: an absent --host/--port leaves the
field undefined (WebInvocation.host?/port?), runWeb forwards each to AppCLIEntry
only when present, and AppCLIEntry patches the webserver row only for an
explicit flag. cordis.yml is the single source of the host/port default; the
adapter still validates a flag when given. Removes the now-unused
DEFAULT_WEB_PORT; LOOPBACK_HOST/ALL_INTERFACES_HOST stay as the allowed-value
vocabulary (validation + the printed URL/LAN line).
This commit is contained in:
Turtle
2026-07-25 15:03:17 +08:00
parent 2dfd8635e8
commit 91d86f9b21
6 changed files with 48 additions and 27 deletions

View File

@@ -14,7 +14,6 @@ import { Command, CommanderError } from 'commander'
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'
const DEFAULT_WEB_PORT = 3080
/** Interactive TUI: the default mode. Optional positional config and `--resume <id>`. */
interface TuiInvocation {
@@ -29,11 +28,16 @@ interface HeadlessInvocation {
prompt: string
}
/** Browser UI: `dsh web`. Host is loopback/all-interfaces, port a 065535 integer, `dev` mounts the HMR driver. */
/**
* 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.
*/
interface WebInvocation {
mode: 'web'
host: string
port: number
host?: string
port?: number
dev: boolean
}
@@ -47,21 +51,31 @@ function program(name: string, version: string): Command {
/** Parse `dsh web` arguments (everything after the `web` token). */
function parseWeb(argv: readonly string[], version: string): WebInvocation {
// No Commander `default`: an absent flag leaves the option undefined so the
// shipped cordis.yml value stands (the single source of the host/port default).
const web = program('dsh web', version)
.description('serve the browser UI')
.option('--host <host>', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`, LOOPBACK_HOST)
.option('--port <port>', 'listen port', String(DEFAULT_WEB_PORT))
.description('serve the browser UI (host/port default to the shipped config)')
.option('--host <host>', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`)
.option('--port <port>', 'listen port (0 requests an OS-assigned port)')
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
web.parse(argv, { from: 'user' })
const { host, port, dev } = web.opts<{ host: string; port: string; dev?: boolean }>()
if (host !== LOOPBACK_HOST && host !== ALL_INTERFACES_HOST) {
const { host, port, dev } = web.opts<{ host?: string; port?: string; dev?: boolean }>()
if (host !== undefined && host !== LOOPBACK_HOST && host !== ALL_INTERFACES_HOST) {
web.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`)
}
const portNumber = Number(port)
if (!/^\d+$/.test(port) || !Number.isInteger(portNumber) || portNumber > 65535) {
web.error('error: --port must be an integer in 0-65535')
let portNumber: number | undefined
if (port !== undefined) {
portNumber = Number(port)
if (!/^\d+$/.test(port) || !Number.isInteger(portNumber) || portNumber > 65535) {
web.error('error: --port must be an integer in 0-65535')
}
}
return {
mode: 'web',
...host !== undefined && { host },
...portNumber !== undefined && { port: portNumber },
dev: dev === true,
}
return { mode: 'web', host, port: portNumber, dev: dev === true }
}
/** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */

View File

@@ -13,13 +13,19 @@ import { ALL_INTERFACES_HOST, LOOPBACK_HOST } from './args.ts'
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
/**
* Serve the browser UI from the shipped config tree.
* @param hostAddress - the bind host: {@link LOOPBACK_HOST} or {@link ALL_INTERFACES_HOST}.
* @param port - the listen port; `0` lets the OS choose a free port.
* 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 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.
*/
export async function runWeb(hostAddress: string, port: number, dev: boolean): Promise<void> {
const entry = new AppCLIEntry({ configPath: CONFIG_PATH, dev, host: hostAddress, port })
export async function runWeb(host: string | undefined, port: number | undefined, dev: boolean): Promise<void> {
const entry = new AppCLIEntry({
configPath: CONFIG_PATH,
dev,
...host !== undefined && { host },
...port !== undefined && { port },
})
const { ctx, port: boundPort } = await entry.run()
let exiting = false
@@ -29,7 +35,7 @@ export async function runWeb(hostAddress: string, port: number, dev: boolean): P
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
const lanCandidate = hostAddress === ALL_INTERFACES_HOST
const lanCandidate = host === ALL_INTERFACES_HOST
? Object.values(networkInterfaces()).flat()
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
: undefined

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ALL_INTERFACES_HOST, LOOPBACK_HOST, parseDshArgs } from '../src/args.ts'
import { ALL_INTERFACES_HOST, parseDshArgs } from '../src/args.ts'
const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3')
@@ -29,7 +29,8 @@ describe('parseDshArgs', () => {
expect(parse(['custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
expect(parse(['--resume', 'sess', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
expect(parse(['web'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080, dev: false })
// 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 })
})