Merge remote-tracking branch 'origin/master' into worktree/web-model-request-retry
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
# `@deepseek-ai/dsh`
|
||||
|
||||
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.
|
||||
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 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:
|
||||
|
||||
- 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);
|
||||
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume flag; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session;
|
||||
- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config <path>` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
|
||||
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume <id>`; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session;
|
||||
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
|
||||
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
|
||||
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
|
||||
|
||||
@@ -224,8 +224,8 @@
|
||||
- id: ui-theme
|
||||
name: '@deepseek-ai/dsh-client-ui-theme'
|
||||
|
||||
- id: i18n
|
||||
name: '@deepseek-ai/dsh-client-i18n'
|
||||
- id: locale
|
||||
name: '@deepseek-ai/dsh-client-locale'
|
||||
|
||||
- id: ui-layout
|
||||
name: '@deepseek-ai/dsh-client-ui-layout'
|
||||
@@ -233,6 +233,15 @@
|
||||
- id: ui-sidebar
|
||||
name: '@deepseek-ai/dsh-client-ui-sidebar'
|
||||
|
||||
- id: ui-settings
|
||||
name: '@deepseek-ai/dsh-client-ui-settings'
|
||||
|
||||
- id: ui-settings-general
|
||||
name: '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
|
||||
- id: ui-models
|
||||
name: '@deepseek-ai/dsh-client-ui-models'
|
||||
|
||||
- id: ui-conversation
|
||||
name: '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
|
||||
@@ -23,12 +23,15 @@
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-hmr": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-i18n": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
@@ -75,6 +78,7 @@
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"commander": "^15.0.0",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"js-yaml": "^4.2.0"
|
||||
},
|
||||
|
||||
143
apps/cli/src/args.ts
Normal file
143
apps/cli/src/args.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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. One program: the default (no
|
||||
* subcommand) is the TUI/headless surface with option-only flags; `web` is a
|
||||
* real subcommand. Commander owns `--help`/`--version` and parse errors — it
|
||||
* prints and exits at the point of failure (a domain failure routes through
|
||||
* `command.error`), so this returns only a resolved mode.
|
||||
* @module @deepseek-ai/dsh/args
|
||||
*/
|
||||
|
||||
import { Command, CommanderError } from 'commander'
|
||||
|
||||
/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume <id>` rehydrates a session. */
|
||||
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`/`port` are present only when the flag was
|
||||
* 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;
|
||||
* `workspaceRoot` is the parent directory for name-created workspaces.
|
||||
*/
|
||||
interface WebInvocation {
|
||||
mode: 'web'
|
||||
host?: string
|
||||
port?: number
|
||||
dev: boolean
|
||||
workspaceRoot?: string
|
||||
}
|
||||
|
||||
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
|
||||
export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
|
||||
|
||||
/** Raw web-subcommand options straight from Commander. */
|
||||
interface WebOptions {
|
||||
host?: string
|
||||
port?: string
|
||||
dev?: boolean
|
||||
workspaceRoot?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
dev: options.dev === true,
|
||||
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the raw argv into a {@link DshInvocation}, or print and exit for
|
||||
* `--help`/`--version`/a parse error. The default (no subcommand) is the
|
||||
* TUI/headless surface; `web` is a subcommand.
|
||||
* @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 (only reached on a valid, non-help invocation).
|
||||
*/
|
||||
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
|
||||
let resolved: DshInvocation | undefined
|
||||
const program = new Command()
|
||||
.name('dsh')
|
||||
.version(version, '-V, --version', 'output the version number')
|
||||
.description('dsh: interactive TUI (default), headless task, and browser UI')
|
||||
.exitOverride()
|
||||
// Default surface: option-only (no positional), so `web` can be a real
|
||||
// subcommand without a positional collision.
|
||||
.option('--config <path>', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)')
|
||||
.option('-p, --prompt <task>', 'run one headless turn for this task, print the result, and exit')
|
||||
.option('--resume <id>', 'resume the persisted session with this id (TUI mode)')
|
||||
.action((options: { config?: string; prompt?: string; resume?: string }) => {
|
||||
if (options.prompt !== undefined) {
|
||||
// A headless prompt owns the invocation; an empty task has nothing to
|
||||
// run, and --config/--resume are TUI inputs that must not silently
|
||||
// vanish from a headless run.
|
||||
if (options.prompt === '') program.error('error: --prompt needs a task')
|
||||
if (options.config !== undefined || options.resume !== undefined) {
|
||||
program.error('error: --prompt takes no --config or --resume')
|
||||
}
|
||||
resolved = { mode: 'headless', prompt: options.prompt }
|
||||
return
|
||||
}
|
||||
// An empty --resume= id would silently start a fresh session downstream
|
||||
// (agent-loop treats '' as no-resume), so a mistyped resume must fail loud.
|
||||
if (options.resume === '') program.error('error: --resume needs a session id')
|
||||
resolved = {
|
||||
mode: 'tui',
|
||||
...options.config !== undefined && { config: options.config },
|
||||
...options.resume !== undefined && { resume: options.resume },
|
||||
}
|
||||
})
|
||||
|
||||
const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)')
|
||||
web
|
||||
.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')
|
||||
.option('--workspace-root <path>', 'parent directory for name-created workspaces')
|
||||
.action((options: WebOptions) => {
|
||||
// Commander parses the parent (default-surface) options on either side of
|
||||
// the subcommand into `program.opts()`. `web` shares none of them, so a
|
||||
// leaked `--config`/`-p`/`--resume` is a mistyped invocation that must
|
||||
// fail loud rather than silently start the web server and drop it.
|
||||
const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
|
||||
if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) {
|
||||
program.error('error: web takes none of --config, -p/--prompt, or --resume')
|
||||
}
|
||||
resolved = resolveWeb(options)
|
||||
})
|
||||
|
||||
try {
|
||||
program.parse(argv, { from: 'user' })
|
||||
} catch (error) {
|
||||
// Commander printed help/version/the error under `exitOverride`; exit with
|
||||
// the code it chose (0 for help/version, 1 for a parse or domain error).
|
||||
/* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */
|
||||
return process.exit(error instanceof CommanderError ? error.exitCode : 1)
|
||||
}
|
||||
/* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */
|
||||
if (resolved === undefined) throw new Error('dsh: no invocation resolved')
|
||||
return resolved
|
||||
}
|
||||
@@ -1,25 +1,49 @@
|
||||
#!/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. Dynamic imports per mode keep unrelated modes out
|
||||
* of each dispatch path; the adapter prints and exits for
|
||||
* `--help`/`--version`/a parse error, so only a valid mode reaches the switch.
|
||||
* @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, invocation.dev, invocation.workspaceRoot)
|
||||
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
|
||||
}
|
||||
default:
|
||||
invocation satisfies never
|
||||
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* the final assistant text, exits (completed → 0, else 1).
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -67,18 +66,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 entry = new AppCLIEntry({
|
||||
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* `dsh` default surface — the interactive TUI coding agent. Boots the shipped
|
||||
* tui-agent config (or an explicit config argument) with the personal overlay
|
||||
* tui-agent config (or the `--config` override) with the personal overlay
|
||||
* from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence:
|
||||
* ambient environment, then the invoking directory's `.env`, then the personal one)
|
||||
* and its `config.yaml` patches the booted tree. The workspace is the invoking
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
installFailLoud,
|
||||
loadEnv,
|
||||
loadPersonalPatches,
|
||||
parseResumeArg,
|
||||
replaceResumeArg,
|
||||
RESUME_SESSION_ID_KEY,
|
||||
resolveConfigPath,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
@@ -28,12 +27,6 @@ import type { TuiResumeHost } from '@deepseek-ai/dsh-tui'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
// The env var the shipped tui-agent config reads (`resumeSessionId: !!js
|
||||
// process.env.RESUME_SESSION_ID`) to rehydrate a persisted session. The
|
||||
// `--resume <id>` flag is CLI sugar that sets it before boot, so the printed
|
||||
// `dsh --resume <id>` exit hint runs back through this same intake.
|
||||
const RESUME_SESSION_ID_ENV = 'RESUME_SESSION_ID'
|
||||
|
||||
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
|
||||
// one directory under apps/cli, so the shipped default config resolves with
|
||||
// the same relative hop from either artifact.
|
||||
@@ -48,26 +41,30 @@ 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 `--config`.
|
||||
* @param resumeSessionId - a persisted session id to resume, or `undefined`;
|
||||
* already parsed and non-empty-validated from `--resume`. It is provided on the
|
||||
* boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config
|
||||
* reads through `!!js` to rehydrate that session.
|
||||
*/
|
||||
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.
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs\n`)
|
||||
process.stderr.write(
|
||||
`${NAME}: the TUI requires stdin and stdout to be interactive TTYs; use \`${NAME} -p "task"\` for pipes and automation\n`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
installFailLoud(NAME)
|
||||
// The bin already loaded the invoking directory's .env; the personal .env
|
||||
// only fills what is still unset (process.loadEnvFile never overrides).
|
||||
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
|
||||
// The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
|
||||
// flag, so the resumed process rehydrates through this same intake. The host
|
||||
// is offered only when Node exposes `process.execve` and knows its own entry.
|
||||
const entry = process.argv[1]
|
||||
const execve = process.execve?.bind(process)
|
||||
const app: { current?: Context } = {}
|
||||
@@ -75,13 +72,15 @@ export async function runTui(argv: string[]): Promise<void> {
|
||||
async handoff(sessionId): Promise<never> {
|
||||
const current = app.current
|
||||
if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
|
||||
// Rebuild argv from the parsed config plus the selected id: TUI mode's
|
||||
// only arguments are `--config <path>` and `--resume <id>`.
|
||||
const nextArgv = [
|
||||
process.execPath,
|
||||
...process.execArgv,
|
||||
entry,
|
||||
...replaceResumeArg(process.argv.slice(2), sessionId),
|
||||
`--resume=${sessionId}`,
|
||||
...config !== undefined ? ['--config', config] : [],
|
||||
]
|
||||
process.env[RESUME_SESSION_ID_ENV] = sessionId
|
||||
try {
|
||||
await current.fiber.dispose()
|
||||
execve(process.execPath, nextArgv, process.env)
|
||||
@@ -94,9 +93,12 @@ export async function runTui(argv: string[]): Promise<void> {
|
||||
}
|
||||
const ctx = await boot(
|
||||
NAME,
|
||||
resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined),
|
||||
resolveConfigPath(config ?? DEFAULT_CONFIG, undefined),
|
||||
loadPersonalPatches(NAME),
|
||||
(hostCtx) => {
|
||||
// Inject the resume id (or undefined) so the shipped config's `!!js`
|
||||
// reads it as a bare identifier; then offer the in-place handoff host.
|
||||
hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId)
|
||||
if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,51 +1,43 @@
|
||||
/**
|
||||
* `dsh web` — thin bin over the config-tree boot: parse argv, run
|
||||
* AppCLIEntry, print the URL line, wire signals. All composition lives in
|
||||
* cordis.yml; all boot glue lives in AppCLIEntry.
|
||||
* `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. Host and
|
||||
* port are unvalidated pass-through overrides — the `dsh-host-webserver` schema
|
||||
* gates them at boot.
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { AppCLIEntry } from './app-cli-entry.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'
|
||||
|
||||
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
|
||||
|
||||
export async function runWeb(argv: string[]): Promise<void> {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
host: { type: 'string' },
|
||||
port: { type: 'string' },
|
||||
dev: { type: 'boolean', default: false },
|
||||
'workspace-root': { type: 'string' },
|
||||
},
|
||||
allowPositionals: false,
|
||||
})
|
||||
if (values.host !== undefined && 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)
|
||||
}
|
||||
let port: number | undefined
|
||||
if (values.port !== undefined) {
|
||||
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 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, 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.
|
||||
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
|
||||
*/
|
||||
export async function runWeb(
|
||||
host: string | undefined,
|
||||
port: number | undefined,
|
||||
dev: boolean,
|
||||
workspaceRoot: string | undefined,
|
||||
): Promise<void> {
|
||||
const entry = new AppCLIEntry({
|
||||
configPath: CONFIG_PATH,
|
||||
dev: values.dev,
|
||||
...values.host !== undefined ? { host: values.host } : {},
|
||||
...port !== undefined ? { port } : {},
|
||||
...values['workspace-root'] !== undefined ? { workspaceRoot: values['workspace-root'] } : {},
|
||||
dev,
|
||||
...host !== undefined && { host },
|
||||
...port !== undefined && { port },
|
||||
...workspaceRoot !== undefined && { workspaceRoot },
|
||||
})
|
||||
const { ctx, port: boundPort } = await entry.run()
|
||||
|
||||
@@ -56,7 +48,7 @@ export async function runWeb(argv: string[]): Promise<void> {
|
||||
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
|
||||
}
|
||||
|
||||
const lanCandidate = values.host === ALL_INTERFACES_HOST
|
||||
const lanCandidate = host === ALL_INTERFACES_HOST
|
||||
? Object.values(networkInterfaces()).flat()
|
||||
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
|
||||
: undefined
|
||||
|
||||
61
apps/cli/tests/args.spec.ts
Normal file
61
apps/cli/tests/args.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parseDshArgs } from '../src/args.ts'
|
||||
|
||||
const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3')
|
||||
|
||||
/**
|
||||
* `parseDshArgs` calls `process.exit` for `--help`/`--version`/errors and lets
|
||||
* Commander print to the real streams; capture the exit code and mute output.
|
||||
*/
|
||||
function exitCode(argv: string[]): number {
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
|
||||
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
parse(argv)
|
||||
throw new Error(`expected ${JSON.stringify(argv)} to exit`)
|
||||
} catch {
|
||||
return exit.mock.calls.at(-1)?.[0] as number
|
||||
} finally {
|
||||
vi.restoreAllMocks()
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('parseDshArgs', () => {
|
||||
it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => {
|
||||
expect(parse([])).toEqual({ mode: 'tui' })
|
||||
expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
|
||||
expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
|
||||
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 })
|
||||
// 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', '--workspace-root', '/w']))
|
||||
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
|
||||
})
|
||||
|
||||
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(['-p', 'x', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['--bogus'])).toBe(1)
|
||||
expect(exitCode(['bogus-positional'])).toBe(1)
|
||||
// A default-surface flag on either side of `web` leaks into program.opts()
|
||||
// but the web subcommand shares none of them: reject rather than serve.
|
||||
expect(exitCode(['web', '-p', 'task'])).toBe(1)
|
||||
expect(exitCode(['web', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits 0 for --help (disclosing web) and --version', () => {
|
||||
expect(exitCode(['--help'])).toBe(0)
|
||||
expect(exitCode(['--version'])).toBe(0)
|
||||
})
|
||||
})
|
||||
55
apps/cli/tests/built-bin.e2e.ts
Normal file
55
apps/cli/tests/built-bin.e2e.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
|
||||
* plain Node (no tsx) with PIPED stdio and assert the TUI refuses to boot.
|
||||
* `dsh` is the sole terminal front door; the TUI owns no non-TTY fallback, so a
|
||||
* piped launch must exit nonzero with a stderr pointer at the one-shot `-p`
|
||||
* mode. The guard fires inside `runTui` BEFORE the Loader resolves the config
|
||||
* tree — a compose-time throw inside the tree is logged per-entry, not
|
||||
* rethrown, so without this guard a piped launch would settle into an idle
|
||||
* UI-less process. The bin resolves its workspace deps through the repo's
|
||||
* node_modules, so no external consumer is assembled; missing-config fail-loud
|
||||
* and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's
|
||||
* built-bin suite, and interactive TTY behavior is PTY-covered by
|
||||
* examples/tui-agent. Skips before the bin is built.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
|
||||
/** Run the built bin with PIPED stdio; resolve with output + exit code. */
|
||||
function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [dshBin], { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (c: string) => { stdout += c })
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (c: string) => { stderr += c })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, 25_000)
|
||||
// Resolve on `close` (all stdio drained), not `exit`, so captured output is complete.
|
||||
child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
|
||||
child.on('error', (err) => { clearTimeout(timer); reject(err) })
|
||||
child.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin()
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
|
||||
expect(stderr).toContain('dsh -p')
|
||||
// The refusal happens before any plugin mounts: stdout stays silent.
|
||||
expect(stdout).toBe('')
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -42,7 +42,16 @@
|
||||
"path": "../../packages/client/ui-theme"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/i18n"
|
||||
"path": "../../packages/client/ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-settings-general"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-models"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/locale"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-layout"
|
||||
|
||||
Reference in New Issue
Block a user