refactor(cli): parse dsh argv through one Commander adapter
Replace the dsh CLI's three hand-rolled parsing idioms (raw argv[0]/includes dispatch in bin.ts, per-mode node:util parseArgs in headless.ts/web.ts, and the bespoke parseResumeArg scanner in dsh-app-boot) with a single Commander adapter in apps/cli/src/args.ts. parseDshArgs resolves argv into a discriminated DshInvocation union; bin.ts switches on the mode and dynamic-imports the chosen module, which now consumes already-parsed values. - web is a real subcommand; --host uses choices and --port an argParser range check, moving validation into the parser. - --resume rejects empty and repeated forms; --prompt rejects empty; a config positional after --prompt and a root flag placed before web fail loud. - adds --help/--version; removes parseResumeArg from dsh-app-boot. - new apps/cli/tests/args.spec.ts (apps/*/tests added to vitest include, apps/cli/tests to tsconfig.host.json); the tui-agent keyless PTY smoke covers bin.ts dispatch end to end unchanged.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` 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)) that resolves the invocation into a single mode; `src/bin.ts` switches on that mode and dynamic-imports only the chosen mode's module. `dsh --help` and `dsh web --help` render 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.
|
||||
|
||||
The TUI surface:
|
||||
|
||||
- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@deepseek-ai/dsh-host-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^"
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"commander": "^15.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
183
apps/cli/src/args.ts
Normal file
183
apps/cli/src/args.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Commander adapter for the `dsh` command-line entry: the one place argv is
|
||||
* parsed and routed to a mode. `bin.ts` switches on the returned discriminant
|
||||
* and dynamic-imports that mode's module; each mode module then consumes the
|
||||
* already-parsed values instead of re-reading argv. Output is suppressed and
|
||||
* `exitOverride` is set so Commander never writes or exits on its own — every
|
||||
* outcome (including `--help`/`--version` and parse errors) is returned to the
|
||||
* caller as data.
|
||||
* @module @deepseek-ai/dsh/args
|
||||
*/
|
||||
|
||||
import { Command, CommanderError, InvalidArgumentError, Option } 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'
|
||||
const DEFAULT_WEB_PORT = 3080
|
||||
|
||||
/** Interactive TUI: the default mode. Optional positional config and `--resume <id>`. */
|
||||
interface TuiInvocation {
|
||||
mode: 'tui'
|
||||
config?: string
|
||||
resume?: string
|
||||
}
|
||||
|
||||
/** Headless one-shot: `dsh -p "task"`. */
|
||||
interface HeadlessInvocation {
|
||||
mode: 'headless'
|
||||
prompt: string
|
||||
}
|
||||
|
||||
/** Browser UI: `dsh web`. Host constrained to {@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST}; port already coerced and range-checked. */
|
||||
interface WebInvocation {
|
||||
mode: 'web'
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
|
||||
/** `--help` or `--version` requested: `bin.ts` prints `text` to stdout and exits 0. */
|
||||
interface InfoInvocation {
|
||||
mode: 'help' | 'version'
|
||||
text: string
|
||||
}
|
||||
|
||||
/** A parse error (unknown option, missing/invalid argument): `bin.ts` prints `message` to stderr and exits 1. */
|
||||
interface ErrorInvocation {
|
||||
mode: 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
/** The resolved `dsh` invocation: exactly one mode, all values parsed and validated. */
|
||||
export type DshInvocation =
|
||||
| TuiInvocation
|
||||
| HeadlessInvocation
|
||||
| WebInvocation
|
||||
| InfoInvocation
|
||||
| ErrorInvocation
|
||||
|
||||
/** Raw Commander option bag for the root command before it is narrowed to a mode. */
|
||||
interface RootOptions {
|
||||
prompt?: string
|
||||
resume?: string
|
||||
}
|
||||
|
||||
/** Commander option bag for the `web` subcommand after `--port` coercion. */
|
||||
interface WebOptions {
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce `--port` to an integer in 0–65535; a bad value throws
|
||||
* {@link InvalidArgumentError}, which Commander reports as a parse error the
|
||||
* adapter returns as an {@link ErrorInvocation}.
|
||||
*/
|
||||
function parsePort(raw: string): number {
|
||||
const port = Number(raw)
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
throw new InvalidArgumentError(`invalid --port ${raw}`)
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
/** Reject an empty `--prompt` task; an empty headless prompt has nothing to run. */
|
||||
function parsePrompt(raw: string): string {
|
||||
if (raw === '') throw new InvalidArgumentError("option '-p, --prompt <task>' must not be empty")
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a `--resume` value: reject an empty id and a repeated flag. Both are
|
||||
* mistypes that must fail loud, never silently start a fresh session or keep
|
||||
* only the last id. `previous` is the value from an earlier `--resume` on the
|
||||
* same invocation (Commander threads it in), so a second occurrence is caught.
|
||||
*/
|
||||
function parseResume(raw: string, previous: string | undefined): string {
|
||||
if (previous !== undefined) throw new InvalidArgumentError("option '--resume <id>' may be given only once")
|
||||
if (raw === '') throw new InvalidArgumentError("option '--resume <id>' must not be empty")
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the raw argv into a single {@link DshInvocation}. Never writes to a
|
||||
* stream and never exits; `--help`/`--version` and every parse error come back
|
||||
* as data for `bin.ts` to act on.
|
||||
* @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
|
||||
* @param version - the version string `--version` prints; read from this app's package.json.
|
||||
* @returns the resolved invocation, discriminated by `mode`.
|
||||
*/
|
||||
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
|
||||
let resolved: DshInvocation | undefined
|
||||
const output: string[] = []
|
||||
|
||||
const program = new Command()
|
||||
.name('dsh')
|
||||
.description('dsh: interactive TUI, headless task, and browser UI')
|
||||
.version(version, '-V, --version', 'output the version number')
|
||||
.exitOverride()
|
||||
.configureOutput({
|
||||
writeOut: chunk => void output.push(chunk),
|
||||
writeErr: chunk => void output.push(chunk),
|
||||
})
|
||||
|
||||
// Positional options keep `dsh -p x web` from routing to the `web`
|
||||
// subcommand: a token after a root option is a positional, not a command.
|
||||
program
|
||||
.enablePositionalOptions()
|
||||
.argument('[config]', 'config to boot instead of the shipped default (TUI mode)')
|
||||
.addOption(new Option('-p, --prompt <task>', 'run one headless turn for this task, print the result, and exit').argParser(parsePrompt))
|
||||
.addOption(new Option('--resume <id>', 'resume the persisted session with this id (TUI mode)').argParser(parseResume))
|
||||
.action((config: string | undefined, options: RootOptions) => {
|
||||
if (options.prompt !== undefined) {
|
||||
// A headless prompt owns the invocation; a config positional is meaningless there.
|
||||
if (config !== undefined) {
|
||||
throw new InvalidArgumentError(`error: --prompt takes no config argument (got '${config}')`)
|
||||
}
|
||||
resolved = { mode: 'headless', prompt: options.prompt }
|
||||
return
|
||||
}
|
||||
resolved = {
|
||||
mode: 'tui',
|
||||
...config !== undefined ? { config } : {},
|
||||
...options.resume !== undefined ? { resume: options.resume } : {},
|
||||
}
|
||||
})
|
||||
|
||||
program
|
||||
.command('web')
|
||||
.description('serve the browser UI')
|
||||
.addOption(
|
||||
new Option('--host <host>', 'bind host')
|
||||
.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])
|
||||
.default(LOOPBACK_HOST),
|
||||
)
|
||||
.addOption(
|
||||
new Option('--port <port>', 'listen port').default(DEFAULT_WEB_PORT).argParser(parsePort),
|
||||
)
|
||||
.action((options: WebOptions, command: Command) => {
|
||||
// Root options placed before `web` (`dsh -p x web`) leak onto the parent;
|
||||
// reject them so a misplaced flag fails loud instead of silently serving.
|
||||
const leaked = command.parent?.opts<RootOptions>()
|
||||
if (leaked?.prompt !== undefined || leaked?.resume !== undefined) {
|
||||
throw new InvalidArgumentError('error: web takes no --prompt or --resume; place web first')
|
||||
}
|
||||
resolved = { mode: 'web', host: options.host, port: options.port }
|
||||
})
|
||||
|
||||
try {
|
||||
program.parse(argv, { from: 'user' })
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- Commander only throws CommanderError from parse under exitOverride */
|
||||
if (!(error instanceof CommanderError)) throw error
|
||||
if (error.code === 'commander.helpDisplayed') return { mode: 'help', text: output.join('') }
|
||||
if (error.code === 'commander.version') return { mode: 'version', text: output.join('') }
|
||||
// Every other CommanderError is a parse failure; its message is the diagnostic.
|
||||
return { mode: 'error', message: error.message }
|
||||
}
|
||||
|
||||
/* v8 ignore next -- one action always resolves the invocation or parse throws above */
|
||||
if (resolved === undefined) throw new Error('dsh: argument parsing did not resolve a mode')
|
||||
return resolved
|
||||
}
|
||||
@@ -1,25 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* dsh — command-line entry. Coarse dispatch only; each surface module owns its
|
||||
* argument handling. Dynamic imports keep unrelated surfaces out of each
|
||||
* dispatch path; everything except `web` and headless prompts opens the TUI.
|
||||
* dsh — command-line entry. Parses argv once through the Commander adapter and
|
||||
* switches on the resolved mode; dynamic imports keep unrelated modes out of
|
||||
* each dispatch path. `web` and headless prompts run their own module;
|
||||
* everything else opens the TUI. `--help`/`--version` print and exit 0; a parse
|
||||
* error prints to stderr and exits 1.
|
||||
* @module @deepseek-ai/dsh/bin
|
||||
*/
|
||||
|
||||
/* v8 ignore file -- built-bin and PTY tests exercise this self-executing dispatch. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
|
||||
import { parseDshArgs } from './args.ts'
|
||||
|
||||
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
|
||||
// one directory under apps/cli, so the checked-in manifest resolves with the
|
||||
// same relative hop from either artifact.
|
||||
/** This app's version, read from its checked-in package.json. */
|
||||
function readVersion(): string {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
|
||||
) as { version?: unknown }
|
||||
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
|
||||
}
|
||||
|
||||
loadEnv('dsh')
|
||||
const argv = process.argv.slice(2)
|
||||
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
|
||||
|
||||
if (argv[0] === 'web') {
|
||||
const { runWeb } = await import('./web.ts')
|
||||
await runWeb(argv.slice(1))
|
||||
} else if (argv.includes('-p') || argv.includes('--prompt')) {
|
||||
const { runHeadless } = await import('./headless.ts')
|
||||
await runHeadless(argv)
|
||||
} else {
|
||||
const { runTui } = await import('./tui.ts')
|
||||
await runTui(argv)
|
||||
switch (invocation.mode) {
|
||||
case 'web': {
|
||||
const { runWeb } = await import('./web.ts')
|
||||
await runWeb(invocation.host, invocation.port)
|
||||
break
|
||||
}
|
||||
case 'headless': {
|
||||
const { runHeadless } = await import('./headless.ts')
|
||||
await runHeadless(invocation.prompt)
|
||||
break
|
||||
}
|
||||
case 'tui': {
|
||||
const { runTui } = await import('./tui.ts')
|
||||
await runTui(invocation.config, invocation.resume)
|
||||
break
|
||||
}
|
||||
case 'help':
|
||||
case 'version':
|
||||
process.stdout.write(invocation.text)
|
||||
process.exit(0)
|
||||
case 'error':
|
||||
process.stderr.write(`${invocation.message}\n`)
|
||||
process.exit(1)
|
||||
default:
|
||||
invocation satisfies never
|
||||
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* (completed → 0, else 1).
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
import { startHost } from '@deepseek-ai/dsh-host-runtime'
|
||||
import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -65,17 +64,13 @@ async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>,
|
||||
return { text, reason: 'error' }
|
||||
}
|
||||
|
||||
export async function runHeadless(argv: string[]): Promise<void> {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: { prompt: { type: 'string', short: 'p' } },
|
||||
allowPositionals: false,
|
||||
})
|
||||
const task = values.prompt
|
||||
if (task === undefined || task === '') {
|
||||
process.stderr.write('usage: dsh -p "task"\n')
|
||||
process.exit(1)
|
||||
}
|
||||
/**
|
||||
* Run one headless turn for `task` and exit (completed → 0, else 1). The task
|
||||
* is the non-empty prompt the argument adapter parsed from `-p`/`--prompt`
|
||||
* (the adapter rejects an empty task, so no guard is needed here).
|
||||
* @param task - the prompt text for the single turn.
|
||||
*/
|
||||
export async function runHeadless(task: string): Promise<void> {
|
||||
|
||||
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
|
||||
const host = await startHost({
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
installFailLoud,
|
||||
loadEnv,
|
||||
loadPersonalPatches,
|
||||
parseResumeArg,
|
||||
resolveConfigPath,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
@@ -45,11 +44,12 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
the tui-agent PTY smoke drives this path end to end, personal overlay included */
|
||||
/**
|
||||
* Run the interactive TUI from the invoking directory.
|
||||
* @param argv - arguments after the subcommand dispatch; a `--resume <id>` flag
|
||||
* resumes that persisted session, and the first non-flag argument may name a
|
||||
* config to boot instead of the shipped default.
|
||||
* @param config - a config path to boot instead of the shipped default, or
|
||||
* `undefined` for the default; already parsed from the optional positional.
|
||||
* @param resumeSessionId - a persisted session id to resume, or `undefined`;
|
||||
* already parsed and non-empty-validated from `--resume`.
|
||||
*/
|
||||
export async function runTui(argv: string[]): Promise<void> {
|
||||
export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise<void> {
|
||||
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree
|
||||
// is logged per-entry rather than rethrown, so a piped launch would
|
||||
// otherwise settle into an idle UI-less process instead of exiting nonzero.
|
||||
@@ -63,9 +63,8 @@ export async function runTui(argv: string[]): Promise<void> {
|
||||
loadEnv(NAME, resolveDshHome())
|
||||
// An explicit `--resume` flag beats any ambient RESUME_SESSION_ID, so set it
|
||||
// after loadEnv and before boot reads it through the config's `!!js`.
|
||||
const { resumeSessionId, rest } = parseResumeArg(argv)
|
||||
if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId
|
||||
const ctx = await boot(NAME, resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME))
|
||||
const ctx = await boot(NAME, resolveConfigPath(config ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME))
|
||||
addHarnessSourceSection(ctx, SOURCE_ROOT)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
@@ -4,37 +4,19 @@
|
||||
* concerns is this app module's job (packages stay single-sided).
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime'
|
||||
import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { ALL_INTERFACES_HOST, LOOPBACK_HOST } from './args.ts'
|
||||
|
||||
const LOOPBACK_HOST = '127.0.0.1'
|
||||
const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
|
||||
export async function runWeb(argv: string[]): Promise<void> {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
host: { type: 'string', default: LOOPBACK_HOST },
|
||||
port: { type: 'string', default: '3080' },
|
||||
},
|
||||
allowPositionals: false,
|
||||
})
|
||||
if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
|
||||
process.stderr.write(
|
||||
`dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
const hostAddress = values.host
|
||||
const port = Number(values.port)
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the browser UI. Host and port are already validated by the argument
|
||||
* adapter (host constrained to loopback/all-interfaces, port a 0–65535 integer).
|
||||
* @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.
|
||||
*/
|
||||
export async function runWeb(hostAddress: string, port: number): Promise<void> {
|
||||
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
|
||||
const host = await startHost({
|
||||
boot: {
|
||||
|
||||
120
apps/cli/tests/args.spec.ts
Normal file
120
apps/cli/tests/args.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ALL_INTERFACES_HOST, LOOPBACK_HOST, parseDshArgs } from '../src/args.ts'
|
||||
|
||||
const VERSION = '1.2.3'
|
||||
const parse = (argv: string[]) => parseDshArgs(argv, VERSION)
|
||||
|
||||
/** Assert argv resolves to an error invocation whose message contains `needle`. */
|
||||
function expectError(argv: string[], needle: string): void {
|
||||
const result = parse(argv)
|
||||
expect(result.mode).toBe('error')
|
||||
if (result.mode !== 'error') throw new Error('expected error mode')
|
||||
expect(result.message).toContain(needle)
|
||||
}
|
||||
|
||||
describe('parseDshArgs — TUI (default mode)', () => {
|
||||
it('defaults to the TUI with no config and no resume when given no arguments', () => {
|
||||
expect(parse([])).toEqual({ mode: 'tui' })
|
||||
})
|
||||
|
||||
it('carries a positional config into the TUI mode', () => {
|
||||
expect(parse(['custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
|
||||
})
|
||||
|
||||
it('parses --resume in the space and inline forms, independent of a config positional', () => {
|
||||
expect(parse(['--resume', 'sess-1'])).toEqual({ mode: 'tui', resume: 'sess-1' })
|
||||
expect(parse(['--resume=sess-2'])).toEqual({ mode: 'tui', resume: 'sess-2' })
|
||||
expect(parse(['--resume', 'sess-3', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess-3' })
|
||||
expect(parse(['app.yml', '--resume', 'sess-4'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess-4' })
|
||||
})
|
||||
|
||||
it('fails loud on a valueless or empty --resume rather than silently starting fresh', () => {
|
||||
expectError(['--resume'], '--resume')
|
||||
expectError(['--resume='], 'must not be empty')
|
||||
})
|
||||
|
||||
it('rejects a repeated --resume instead of silently keeping the last id', () => {
|
||||
expectError(['--resume', 'a', '--resume', 'b'], 'may be given only once')
|
||||
expectError(['--resume=a', '--resume=b'], 'may be given only once')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDshArgs — headless', () => {
|
||||
it('routes -p / --prompt to the headless mode with the task text', () => {
|
||||
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
|
||||
expect(parse(['--prompt', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
|
||||
})
|
||||
|
||||
it('routes to headless regardless of the prompt flag position', () => {
|
||||
// Positional-independent: the old `argv.includes('-p')` dispatch could not
|
||||
// tell a real prompt flag from one buried after other tokens.
|
||||
expect(parse(['-p', 'task'])).toEqual({ mode: 'headless', prompt: 'task' })
|
||||
})
|
||||
|
||||
it('rejects an empty prompt and a stray config positional', () => {
|
||||
expectError(['-p', ''], 'must not be empty')
|
||||
expectError(['-p', 'task', 'app.yml'], 'takes no config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDshArgs — web', () => {
|
||||
it('defaults the web mode to loopback and port 3080', () => {
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080 })
|
||||
})
|
||||
|
||||
it('accepts an explicit loopback or all-interfaces host and a valid port', () => {
|
||||
expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080']))
|
||||
.toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080 })
|
||||
expect(parse(['web', '--port', '0'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 0 })
|
||||
})
|
||||
|
||||
it('rejects a non-integer or out-of-range port with a --port diagnostic', () => {
|
||||
expectError(['web', '--port', 'abc'], '--port')
|
||||
expectError(['web', '--port', '70000'], '--port')
|
||||
expectError(['web', '--port', '-1'], '--port')
|
||||
})
|
||||
|
||||
it('rejects a host outside the allowed choices with a --host diagnostic', () => {
|
||||
expectError(['web', '--host', '10.0.0.1'], '--host')
|
||||
})
|
||||
|
||||
it('rejects an unexpected positional after web', () => {
|
||||
expectError(['web', 'extra'], 'too many arguments')
|
||||
})
|
||||
|
||||
it('fails loud when a root flag is placed before web instead of serving with it dropped', () => {
|
||||
// `dsh web -p x` and `dsh -p x web` both misrouted or dropped the flag under
|
||||
// the old `argv[0]==='web'` / `argv.includes('-p')` dispatch.
|
||||
expectError(['web', '-p', 'x'], "unknown option '-p'")
|
||||
expectError(['web', '--resume', 'y'], "unknown option '--resume'")
|
||||
expectError(['-p', 'x', 'web'], 'web takes no')
|
||||
expectError(['--resume', 'y', 'web'], 'web takes no')
|
||||
})
|
||||
|
||||
it('renders web usage for web --help', () => {
|
||||
const help = parse(['web', '--help'])
|
||||
expect(help.mode).toBe('help')
|
||||
if (help.mode !== 'help') throw new Error('expected help mode')
|
||||
expect(help.text).toContain('Usage: dsh web')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDshArgs — help, version, and errors', () => {
|
||||
it('returns the rendered usage for --help / -h', () => {
|
||||
const help = parse(['--help'])
|
||||
expect(help.mode).toBe('help')
|
||||
if (help.mode !== 'help') throw new Error('expected help mode')
|
||||
expect(help.text).toContain('Usage: dsh')
|
||||
expect(help.text).toContain('web')
|
||||
expect(parse(['-h']).mode).toBe('help')
|
||||
})
|
||||
|
||||
it('returns the version string for --version / -V', () => {
|
||||
expect(parse(['--version'])).toEqual({ mode: 'version', text: `${VERSION}\n` })
|
||||
expect(parse(['-V'])).toEqual({ mode: 'version', text: `${VERSION}\n` })
|
||||
})
|
||||
|
||||
it('reports an unknown option as an error invocation', () => {
|
||||
expectError(['--nope'], "unknown option '--nope'")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user