Просмотр исходного кода

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).
Turtle 1 месяц назад
Родитель
Сommit
9f6dbde7f6

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-2026-07-24-dsh-commander-argument-adapter.md: e023d9ff296dd4a4024824865358964c8a66f49a
-2026-07-24-dsh-commander-argument-adapter.zh.md: 762e3e4b1609e9bc6f9f5bd5cc509c4084573833
+2026-07-24-dsh-commander-argument-adapter.md: ac06f37507c8f4e718904fd8c98f17021ff4b5ae
+2026-07-24-dsh-commander-argument-adapter.zh.md: 63f37077074f92d167419f9329d3e2e79abf7b4b

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md


+ 1 - 1
apps/cli/README.md

@@ -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:
 

+ 18 - 25
apps/cli/src/args.ts

@@ -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 0–65535 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' })

+ 10 - 4
apps/cli/src/web.ts

@@ -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 (0–65535).
+ * 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.
  */

+ 9 - 9
apps/cli/tests/args.spec.ts

@@ -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)

Некоторые файлы не были показаны из-за большого количества измененных файлов