Merge remote-tracking branch 'origin/master' into worktree/align-core-web-rl-prompt

# Conflicts:
#	apps/cli/reference/README.i18n.yaml
#	apps/cli/reference/README.md
#	apps/cli/reference/README.zh.md
#	apps/cli/src/app-cli-entry.ts
#	apps/cli/src/dump-config.ts
#	apps/cli/src/web.ts
#	apps/cli/tests/built-bin.e2e.ts
#	apps/cli/tests/web-prompt-context.spec.ts
#	apps/web/tests/scaffold.ts
This commit is contained in:
Yichen Jiang
2026-08-06 20:34:57 +08:00
176 changed files with 5465 additions and 2447 deletions

View File

@@ -1,363 +0,0 @@
/**
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
* (`dsh web` and `dsh -p`).
* Everything here is what must exist before the Loader runs: the patch
* composition over the shipped base and Web overlay (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud activation audit after the tree
* settles. The environment is what the bin already loaded (ambient plus the
* invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential
* provider and is never hoisted here.
*/
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { networkInterfaces } from 'node:os'
import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import {
boot,
installFailLoud,
loadOverlayPatches,
loadPersonalPatches,
watchPersonalPatches,
} from '@deepseek-ai/dsh-app-boot'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */
const PROFILE_DIR = '.dsh-tmp-profile'
const PROFILE_FILE = 'config.json'
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */
const TELEMETRY_ROW_ID = 'telemetry-otel'
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
const ALL_INTERFACES_HOST = '0.0.0.0'
/**
* Non-internal IPv4 interface addresses of this machine — the IP-literal
* authorities an all-interfaces bind is reachable by on the LAN.
* @returns the addresses in interface order (possibly empty).
*/
function lanIPv4Addresses(): string[] {
return Object.values(networkInterfaces()).flat()
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
.map(iface => iface.address)
}
/**
* One LAN-trust resolution for one invocation, sampled exactly once: the
* machine's LAN IP literals when the effective bind is all-interfaces, and
* the `trustedHosts` value built from them plus the explicit extras. The
* single sample is deliberate — display must advertise only addresses the
* fence was configured with, so both read this snapshot. Derived entries are
* port-less IP literals: DNS rebinding needs an attacker-controlled name, so
* an IP-literal Host is safe on any port, and the bound port may be
* OS-assigned, unknowable pre-boot.
* @param bindHost - the effective webserver bind host (CLI flag, else the yml default).
* @param extra - `--trusted-host` values, in argv order.
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
*/
export function resolveLanTrust(
bindHost: string | undefined,
extra: readonly string[],
): { lanAddresses: string[]; trustedHosts: string[] } {
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
}
/**
* Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
* value (including `'0'`/`'false'`) disables: a privacy switch prefers
* off-by-mistake over on-by-mistake. Throws when the switch is set but the
* row is absent — a silently no-op "disabled" privacy switch would keep
* exporting while the user believes it is off.
* @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
* @param hasRow - whether the composition carries the {@link TELEMETRY_ROW_ID} row.
* @returns the disable patch, or `undefined` when telemetry stays enabled.
*/
export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
if ((disabledEnv ?? '') === '') return undefined
if (!hasRow) {
throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`)
}
return { id: TELEMETRY_ROW_ID, disabled: true }
}
/**
* Whether a config file carries the telemetry row, parsed under the same
* `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers
* that compose their patch lists outside {@link AppCLIEntry} (raw `dsh`).
* @param file - absolute path of the config or overlay file.
* @returns true when a top-level (or inserted) row has the telemetry id.
*/
export function configHasTelemetryRow(file: string): boolean {
const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
return (doc as { id?: string; insert?: { id?: string }[] }[]).some(row =>
row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID))
}
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
entryId: string
configKey: string
}
/**
* The static profile→row mapping table. json is user config and wins over the
* yml engineering default per field; a json key absent from this table fails
* loud (a typo silently ignored would read as "setting has no effect").
* Developers extend deployments by adding rows here.
*/
const PROFILE_MAPPINGS: ProfileMapping[] = [
{ jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' },
{ jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' },
{ jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' },
]
// The include's YAML dialect: `!!js` scalars become expression nodes the
// Loader evaluates at entry activation. The bypass parse below must accept
// them (and passing one through a patch unchanged is legal).
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: data => typeof data === 'string',
construct: data => ({ __jsExpr: String(data) }),
})
const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
export interface AppCLIEntryOptions {
/** Absolute path of the shared base config the Loader includes. */
configPath: string
/**
* Absolute path of this surface's overlay: a patch list applied over
* {@link configPath} before this entry's own profile/flag patches. Its rows
* are also merge inputs, so a flag override preserves the overlay's other
* fields on the same row.
*/
overlayPath: string
/**
* Launcher-owned patches applied after {@link overlayPath} and before the
* personal or explicit overlay, so user configuration can still override
* surface activation choices.
*/
launcherPatches?: readonly PatchOptions[]
/**
* Optional explicit overlay applied after {@link launcherPatches} and before
* this entry's own profile/flag patches. When absent, the personal
* `$DSH_HOME/config.yaml` overlay is applied in the same position instead.
*/
extraOverlayPath?: string
/** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */
dev: boolean
/** Whether `$DSH_HOME/config.yaml` remains live after the initial boot. */
watchPersonalConfig: boolean
/** --host when explicitly passed; undefined keeps the yml engineering default. */
host?: string
/**
* Listen port override onto the webserver row. Web passes the --port flag
* value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs
* never collide — and the printed URL still opens the live session in a
* browser).
*/
port?: number
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
trustedHosts?: string[]
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
prepare?: (ctx: Context) => Promise<void> | void
}
/**
* Boot driver for the config-tree dsh surfaces (web and headless share the
* one composition; the surfaces differ only in constructor facts): holds only
* what exists independently of (and prior to) cordis — argv facts, the
* composed patch set, and finally the root ctx.
*/
export class AppCLIEntry {
/** The root context, set by {@link run}. */
ctx!: Context
/**
* LAN IPv4 addresses sampled once at patch composition — the exact snapshot
* the /api trust fence was configured with. Display reads this instead of
* re-sampling, so the advertised LAN URL can never name an address the
* fence rejects. Empty unless the effective bind is all-interfaces.
*/
lanAddresses: readonly string[] = []
private patches: PatchOptions[] = []
constructor(private readonly options: AppCLIEntryOptions) {}
/**
* Run the boot chain: patch composition → Loader installation → surface
* preparation → config-tree boot (dev row before await) → fail-loud triple.
* @returns the settled root context and the listening port.
*/
async run(): Promise<{ ctx: Context; port: number }> {
this.composePatches()
await this.bootTree()
this.assertBoot()
const port = this.ctx.get('httpServer')?.port
/* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
return { ctx: this.ctx, port }
}
/**
* Compose the patch set from profile json, CLI flags, and the resolved
* frontend dist. Patches replace a row's config wholesale, so each patched row's yml
* static values are re-read here (bypass parse) and merged under the overrides.
*/
private composePatches(): void {
const rows = this.parseYmlRows()
const overrides = new Map<string, Record<string, unknown>>()
const put = (entryId: string, key: string, value: unknown): void => {
const bag = overrides.get(entryId) ?? {}
bag[key] = value
overrides.set(entryId, bag)
}
// Source 1: profile json (missing file = empty; unmapped key = loud).
for (const [key, value] of Object.entries(this.readProfile())) {
const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
if (mapping === undefined) {
throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
}
put(mapping.entryId, mapping.configKey, value)
}
// Source 2: CLI flags (field set disjoint from the json mappings).
if (this.options.host !== undefined) put('webserver', 'host', this.options.host)
if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
// Source 2b: authorities for the /api browser-trust fence (rationale on
// resolveLanTrust).
const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
this.lanAddresses = lanAddresses
if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
// Source 3: the frontend dist — an assembly fact of this app, never yml
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())
const generated = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
})
this.patches = generated
// Telemetry opt-out: a row can only be turned off at the patch layer
// (config cannot disable an entry), and the switch must hold BEFORE the
// plugin constructs — its exporter.url validation is load-time fail-loud.
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
}
/** Shared Loader boot; surface preparation precedes the tree, and the dev HMR row precedes the activation audit. */
private async bootTree(): Promise<void> {
// One include of the shared base with every overlay as a sibling patch
// list: patches never cross an include boundary, so nesting them would
// silently stop reaching base rows. The shared surface overlay applies
// first, then launcher activation, user configuration, and finally this
// entry's profile-json and CLI-flag patches.
const compose = (overlay: PatchOptions[]): PatchOptions[] => [
...loadOverlayPatches('dsh', this.options.overlayPath),
...(this.options.launcherPatches ?? []),
...overlay,
...this.patches,
]
// An explicit --config overlay REPLACES the personal overlay, so there is
// then no personal layer to keep live — the watcher is personal-only.
const watchPersonal = this.options.watchPersonalConfig && this.options.extraOverlayPath === undefined
const patches = compose(
this.options.extraOverlayPath === undefined
? loadPersonalPatches('dsh') ?? []
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
)
this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => {
await this.options.prepare?.(ctx)
// Config-only HMR for the personal overlay: module reload stays off for
// this surface (web.cordis.yml disables the shared `hmr` row until its
// reload lifecycle is tested), so this row watches no module roots.
if (watchPersonal) await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
})
if (watchPersonal) {
await watchPersonalPatches(this.ctx, { binName: 'dsh', compose })
}
}
/** Install the diagnostic for plugin rejections that happen after settled boot. */
private assertBoot(): void {
installFailLoud('dsh')
}
/**
* Bypass parse of the base and this surface's overlay (id → row) for
* patch-merge inputs; the Loader still reads both files itself. The overlay
* wins per row, matching the order its patches are applied in, and its
* `insert` rows are indexed too because a flag may target one of them.
*/
private parseYmlRows(): Map<string, { config?: unknown }> {
const rows = new Map<string, { config?: unknown }>()
const files = [this.options.configPath, this.options.overlayPath]
if (this.options.extraOverlayPath !== undefined) files.push(this.options.extraOverlayPath)
for (const file of files) {
for (const row of this.parseRowList(file)) {
if (typeof row.id === 'string') rows.set(row.id, row)
for (const inserted of row.insert ?? []) {
if (typeof inserted.id === 'string') rows.set(inserted.id, inserted)
}
}
}
return rows
}
/**
* Parse one entry or patch list, rejecting anything that is not a top-level
* array so a malformed file fails here rather than at row lookup.
* @param file - absolute path of the config or overlay file.
* @returns the parsed top-level entries.
*/
private parseRowList(file: string): { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] {
const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
return doc as { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[]
}
/** Profile json under cwd; read-only — never created here, absent = no user config. */
private readProfile(): Record<string, unknown> {
let raw: string
try {
raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
throw error
}
const parsed: unknown = JSON.parse(raw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
}
return parsed as Record<string, unknown>
}
/** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */
private resolveDistIndex(): string {
const require = createRequire(import.meta.url)
try {
return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
throw new Error('dsh: frontend dist not built; run pnpm run build from the repository root first')
}
}
}

View File

@@ -1,43 +1,42 @@
/**
* Commander adapter for the `dsh` command-line entry. The default command
* boots one required `--config` overlay over the shipped base; `-p` selects
* the one-shot headless path and `web` selects the browser application.
* Commander owns help, version, and parse errors.
* boots a named profile (`--profile <name>`), optionally with extra `--patch`
* overlays and a positional task (one-shot mode for profiles mounting the
* headless runner). `web` is a hardcoded alias for `--profile web` that adds
* the Web flag family; `plugin` manages a profile's plugin dependencies by
* forwarding to pnpm. Commander owns help, version, and parse errors.
* @module @deepseek-ai/dsh/args
*/
import { Command, CommanderError } from 'commander'
/** Boot a caller-selected overlay over the shipped base config. */
interface ConfigInvocation {
mode: 'config'
config: string
/** Boot a named profile. */
interface ProfileInvocation {
mode: 'profile'
profile: string
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
patches: string[]
/** Positional task text joined by spaces; non-empty only for one-shot runs. */
task?: string
}
/** Print a composed config tree and exit without booting. */
/** Print a composed profile tree and exit without booting. */
interface DumpConfigInvocation {
mode: 'dump-config'
surface: 'config' | 'web'
/** Omit every caller or personal layer and print the shipped tree. */
profile: string
/** Omit the profile's user layer and --patch overlays; print bundle layers only. */
defaultOnly: boolean
/** Explicit overlay to compose over the base or Web surface. */
config?: string
}
/** Headless one-shot: `dsh -p "task"`. */
interface HeadlessInvocation {
mode: 'headless'
prompt: string
patches: string[]
}
/**
* Browser UI: `dsh web`. Host and port remain unvalidated pass-throughs to
* the webserver schema; absent values leave the shipped Web overlay intact.
* Browser UI: `dsh web` (alias of `--profile web`). Host and port remain
* unvalidated pass-throughs to the webserver schema; absent values leave the
* shipped web bundle values intact.
*/
interface WebInvocation {
mode: 'web'
/** Overlay applied over the shipped Web composition instead of the personal one. */
config?: string
patches: string[]
host?: string
port?: number
dev: boolean
@@ -46,12 +45,20 @@ interface WebInvocation {
trustedHosts?: string[]
}
/** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */
interface PluginInvocation {
mode: 'plugin'
profile: string
/** Raw pnpm arguments, verbatim. */
args: string[]
}
/** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
export type DshInvocation = ConfigInvocation | DumpConfigInvocation | HeadlessInvocation | WebInvocation
export type DshInvocation = ProfileInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation
/** Raw web-subcommand options straight from Commander. */
interface WebOptions {
config?: string
patch?: string[]
host?: string
port?: string
dev?: boolean
@@ -61,43 +68,11 @@ interface WebOptions {
dumpDefaultConfig?: boolean
}
/** Resolve config-dump flags for one command shape. */
function resolveDump(
surface: 'config' | 'web',
options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean },
error: (message: string) => never,
): DumpConfigInvocation | undefined {
if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) return undefined
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
error('error: --dump-config and --dump-default-config are mutually exclusive')
}
const defaultOnly = options.dumpDefaultConfig === true
if (defaultOnly && options.config !== undefined) {
error('error: --dump-default-config prints the shipped tree and takes no --config')
}
if (surface === 'config' && !defaultOnly && options.config === undefined) {
error('error: --dump-config requires --config <path>')
}
return {
mode: 'dump-config',
surface,
defaultOnly,
...options.config !== undefined && { config: options.config },
}
}
/** Narrow raw `web` options into a {@link WebInvocation}. */
function resolveWeb(options: WebOptions): WebInvocation {
return {
mode: 'web',
...options.config !== undefined && { config: options.config },
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
dev: options.dev === true,
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
}
}
/**
* Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never
* variadic — a variadic `--patch` would swallow a following positional task.
*/
const collect = (value: string, previous: string[] = []): string[] => [...previous, value]
/**
* Resolve argv into one invocation, or print and exit for help, version, or an
@@ -111,77 +86,122 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
const program = new Command()
.name('dsh')
.version(version, '-V, --version', 'output the version number')
.description('dsh: boot a DeepSeek Harness config overlay over the shipped base configuration.')
.description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.')
.addHelpText('after', `
Examples:
dsh --config ./app.cordis.yml boot an overlay over the shipped base
dsh -p "run the tests" answer one task, print the result, and exit
dsh web serve the browser UI
dsh --profile web boot the web profile (same as: dsh web)
dsh --profile headless "run the tests" answer one task, print the result, and exit
dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
dsh plugin --profile tui add <package> install a plugin into the tui profile
dsh web --port 8080 the web alias with its flag family
`)
.exitOverride()
.enablePositionalOptions()
.option('-p, --prompt <task>', 'answer this task without an interactive UI, then exit')
.option('--config <path>', 'overlay of loader patches to apply over the shipped base')
.option('--dump-config', 'print the base plus --config overlay and exit')
.option('--dump-default-config', 'print the shipped base config and exit')
.action((options: {
config?: string
prompt?: string
.argument('[task...]', 'one-shot task text for profiles mounting the headless runner')
.option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot')
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
.option('--dump-config', 'print the composed profile tree and exit')
.option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit')
.action((task: string[], options: {
profile?: string
patch?: string[]
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}) => {
if (options.config === '') program.error('error: --config needs a path')
const dump = resolveDump('config', options, message => program.error(message))
if (dump !== undefined) {
if (options.prompt !== undefined) {
program.error('error: --dump-config/--dump-default-config take no -p/--prompt')
const profile = options.profile ?? program.error('error: --profile <name> is required')
if (profile === '') program.error('error: --profile needs a name')
const patches = options.patch ?? []
if (patches.includes('')) program.error('error: --patch needs a path')
if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
}
resolved = dump
if (task.length > 0) program.error('error: --dump-config/--dump-default-config take no task')
const defaultOnly = options.dumpDefaultConfig === true
if (defaultOnly && patches.length > 0) {
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
}
resolved = { mode: 'dump-config', profile, defaultOnly, patches }
return
}
if (options.prompt !== undefined) {
if (options.prompt === '') program.error('error: --prompt needs a task')
if (options.config !== undefined) program.error('error: --prompt takes no --config')
resolved = { mode: 'headless', prompt: options.prompt }
return
resolved = {
mode: 'profile',
profile,
patches,
...task.length > 0 ? { task: task.join(' ') } : {},
}
const config = options.config ?? program.error('error: --config <path> is required')
resolved = { mode: 'config', config }
})
/** Reject parent options that crossed a subcommand boundary. */
const rejectParentOptions = (command: string): void => {
const parent = program.opts<{
config?: string
prompt?: string
profile?: string
patch?: string[]
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}>()
if (parent.config !== undefined || parent.prompt !== undefined
if (parent.profile !== undefined || parent.patch !== undefined
|| parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
program.error(`error: ${command} takes none of parent --config, -p/--prompt, --dump-config, or --dump-default-config`)
program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`)
}
}
const web = program.command('web').description('serve the browser UI on the configured host and port')
const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port')
web
.option('--config <path>', 'apply this overlay of loader patches over the shipped Web configuration')
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit')
.option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit')
.option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit')
.option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit')
.action((options: WebOptions) => {
rejectParentOptions('web')
if (options.config === '') program.error('error: --config needs a path')
const dump = resolveDump('web', options, message => program.error(message))
if (dump !== undefined) {
resolved = dump
const patches = options.patch ?? []
if (patches.includes('')) program.error('error: --patch needs a path')
if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
}
const defaultOnly = options.dumpDefaultConfig === true
if (defaultOnly && patches.length > 0) {
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
}
// The dump is boot-free and does not derive flag patches; silently
// dropping them would print a tree that differs from the same
// invocation's boot.
if (options.host !== undefined || options.port !== undefined || options.dev === true
|| options.workspaceRoot !== undefined || options.trustedHost !== undefined) {
program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)')
}
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
return
}
resolved = resolveWeb(options)
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
}
resolved = {
mode: 'web',
patches,
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
dev: options.dev === true,
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
}
})
const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
plugin
.requiredOption('--profile <name>', 'the profile whose plugins to manage (initialized on first use)')
.allowUnknownOption()
.argument('[args...]', 'pnpm arguments, forwarded verbatim (add <pkg>, remove <pkg>, why <pkg>, ...)')
.action((args: string[], options: { profile: string }) => {
rejectParentOptions('plugin')
if (options.profile === '') program.error('error: --profile needs a name')
if (args.length === 0) program.error('error: plugin needs pnpm arguments to forward (e.g. add <package>)')
resolved = { mode: 'plugin', profile: options.profile, args }
})
try {

View File

@@ -28,24 +28,28 @@ loadEnv('dsh')
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
switch (invocation.mode) {
case 'config': {
const { runConfig } = await import('./config.ts')
await runConfig(invocation.config)
case 'profile': {
const { runProfile } = await import('./profile-boot.ts')
await runProfile({
profile: invocation.profile,
patchFiles: invocation.patches,
...invocation.task !== undefined && { task: invocation.task },
})
break
}
case 'web': {
const { runWeb } = await import('./web.ts')
await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config)
await runWeb(invocation)
break
}
case 'headless': {
const { runHeadless } = await import('./headless.ts')
await runHeadless(invocation.prompt)
case 'plugin': {
const { runPlugin } = await import('./plugin.ts')
process.exit(runPlugin(invocation.profile, invocation.args))
break
}
case 'dump-config': {
const { runDumpConfig } = await import('./dump-config.ts')
runDumpConfig(invocation.surface, invocation.defaultOnly, invocation.config)
runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches)
break
}
default:

View File

@@ -1,54 +0,0 @@
/**
* Raw `dsh --config <path>` boot: apply one required patch-list overlay over
* the shipped base config, then leave process lifetime to the mounted plugins.
* @module @deepseek-ai/dsh/config
*/
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import {
boot,
installFailLoud,
loadOverlayPatches,
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
const NAME = 'dsh'
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
/* v8 ignore start -- the source-launch and built-bin acceptance paths own executable dispatch */
/**
* Boot the shipped base with one explicit overlay.
* @param config - required patch-list path parsed from `--config`.
*/
export async function runConfig(config: string): Promise<void> {
const app: { current?: Context } = {}
let exiting = false
const shutdown = (code: number): void => {
if (exiting) return
exiting = true
void Promise.resolve(app.current?.fiber.dispose()).finally(() => { process.exit(code) })
}
// An inserted front door can publish readiness before sibling rows finish
// mounting. Signals must own teardown throughout that startup window, not
// only after boot() settles.
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })
installFailLoud(NAME, process, async () => {
await app.current?.fiber.dispose()
})
const overlay = resolveConfigPath(config, undefined)
const telemetryPatch = resolveTelemetryPatch(
process.env.DSH_TELEMETRY_DISABLED,
configHasTelemetryRow(BASE_CONFIG),
)
const ctx = await boot(NAME, BASE_CONFIG, [
...loadOverlayPatches(NAME, overlay),
...telemetryPatch === undefined ? [] : [telemetryPatch],
], (hostCtx) => {
app.current = hostCtx
})
app.current = ctx
}
/* v8 ignore stop */

View File

@@ -1,54 +1,53 @@
/**
* Config-dump entry for raw `dsh --config` and `dsh web`: compose through the
* include plugin's patch algorithm without booting or evaluating `!!js`.
* Config-dump entry for `dsh --profile <name> --dump-config`: compose the
* profile's patch layers through the include plugin's patch algorithm without
* booting or evaluating `!!js`, with one provenance layer per bundle, the
* profile's own patch file, and each `--patch` overlay.
* @module @deepseek-ai/dsh/dump-config
*/
import { basename, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import {
loadOptionalPatches,
loadOverlayPatches,
loadPersonalPatches,
PERSONAL_CONFIG_FILENAME,
renderConfigDump,
type ConfigDumpLayer,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { WEB_RUNTIME_CONTEXT_ENABLE_PATCH } from './web.ts'
import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts'
const NAME = 'dsh'
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url))
/* v8 ignore start -- built-bin acceptance drives this boot-free dispatch */
/**
* Print a raw or Web composition with provenance comments.
* @param surface - raw base-plus-config composition, or the Web composition.
* @param defaultOnly - omit the explicit or personal user layer.
* @param config - explicit overlay path; required for a non-default raw dump.
* Print a profile composition with provenance comments.
* @param profile - the profile name.
* @param defaultOnly - omit the profile's user layer and `--patch` overlays
* (the recovery diagnostic for a broken `cordis.patch.yml`, which is then
* never parsed).
* @param patches - `--patch` overlay paths, in argv order.
*/
export function runDumpConfig(surface: 'config' | 'web', defaultOnly: boolean, config?: string): void {
const layers: ConfigDumpLayer[] = []
if (surface === 'config') {
if (!defaultOnly) {
/* v8 ignore next -- parseDshArgs requires this combination */
if (config === undefined) throw new Error('dsh: raw config dump requires an overlay')
layers.push({ label: config, patches: loadOverlayPatches(NAME, config) })
export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void {
const loaded = prepareProfile(profile, !defaultOnly)
const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({
label: layer.packageName,
patches: layer.patches,
}))
if (!defaultOnly) {
if (existsSync(loaded.patchPath)) {
layers.push({ label: loaded.patchPath, patches: loaded.patches })
}
} else {
layers.push({ label: basename(WEB_OVERLAY), patches: loadOverlayPatches(NAME, WEB_OVERLAY) })
layers.push({ label: 'dsh web launcher', patches: [WEB_RUNTIME_CONTEXT_ENABLE_PATCH] })
if (!defaultOnly) {
if (config === undefined) {
const personal = loadPersonalPatches(NAME)
if (personal !== undefined) {
layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal })
}
} else {
layers.push({ label: config, patches: loadOverlayPatches(NAME, config) })
}
const homePatchFile = homePatchPath()
const homePatches = loadOptionalPatches(NAME, homePatchFile)
if (homePatches !== undefined) {
layers.push({ label: homePatchFile, patches: homePatches })
}
for (const file of patches) {
const absolute = resolve(file)
layers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) })
}
}
process.stdout.write(renderConfigDump(NAME, BASE_CONFIG, layers))
// The dump anchors on the same empty root file the boot includes.
process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers))
}
/* v8 ignore stop */

View File

@@ -1,130 +0,0 @@
/**
* `dsh -p "task"` — headless over the one shared composition: AppCLIEntry
* boots the same base plus Web overlay as `dsh web` (port 0, so parallel runs never
* collide), then in-process isomorphic injection (InProcessApiClient over
* toFetchHandler(ctx.apiProxy), so the full carrier chain — wire
* serialization, zod, SSE framing — really runs). The printed URL opens the
* live session in a browser while the task runs. Runs one task turn, prints
* the final assistant text, exits (completed → 0, else 1).
*/
import { fileURLToPath } from 'node:url'
import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AppCLIEntry } from './app-cli-entry.ts'
import { createProcessShutdown } from './process-shutdown.ts'
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
interface TurnOutcome {
text: string
reason: string
}
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (shutdown first). */
async function unwrap<T>(response: RpcResponse<T>, shutdown: () => Promise<void>): Promise<T> {
if (response.result.ok) return response.result.value
const { code, message } = response.result.error
process.stderr.write(`dsh: ${code}: ${message}\n`)
await shutdown()
process.exit(1)
}
/**
* Consume mux frames until the agent reaches idle, per the one-shot CLI
* idle-to-idle contract: the stream opens immediately before the prompt, and
* its first observed turn/start begins the task. Text is the last committed
* assistant message of the whole interval (steering or injected work may run
* further turns before quiescence), and the outcome reason is the final
* turn/end's kind. Idleness is signalled out of band by the caller's
* `agent/status` subscription; the stream itself carries no status frame.
* @param frames - the mux stream opened before the prompt.
* @param sessionId - the headless session.
* @param idle - resolves when the agent reaches quiescence.
* @returns the aggregated outcome.
*/
async function consumeUntilIdle(
frames: AsyncIterable<RpcRequest<MuxFrame>>,
sessionId: SessionId,
idle: Promise<void>,
): Promise<TurnOutcome> {
let started = false
let text = ''
let reason: string = 'error'
void (async () => {
try {
for await (const frame of frames) {
const payload = frame.payload
if (payload.type === 'stream/error') return
if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
const event = payload.event
if (event.type === 'turn/start') {
started = true
continue
}
if (!started) continue
if (event.type === 'assistant/message') {
const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('')
if (joined !== '') text = joined
}
if (event.type === 'turn/end') reason = event.data.reason.kind
}
} catch (error: unknown) {
process.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
}
})()
await idle
return { text, reason }
}
/**
* 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('../config/base.cordis.yml', import.meta.url)),
overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
dev: false,
watchPersonalConfig: false,
port: 0,
})
const { ctx, port } = await entry.run()
// Normal completion and signals share one bounded drain. A signal received
// during that drain escalates immediately instead of becoming a no-op.
const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
process.on('SIGTERM', () => { shutdown.interrupt(143) })
process.on('SIGINT', () => { shutdown.interrupt(130) })
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const created = await unwrap(await api.sessions.create({}), () => shutdown.shutdown(1))
// Open the stream before prompting so no frame is lost — kept in this order
// even though in-process delivery has no race, so the code survives a move
// to a remote HTTP carrier unchanged.
const abort = new AbortController()
const frames = api.events.mux({}, abort.signal)
const idle = new Promise<void>((resolve) => {
ctx.on('agent/status', ({ agent, status }) => {
if (agent.id === created.sessionId && status === 'idle') resolve()
})
})
const done = consumeUntilIdle(frames, created.sessionId, idle)
await unwrap(await api.sessions.prompt({
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: task }],
}), () => shutdown.shutdown(1))
const outcome = await done
process.stdout.write(outcome.text + '\n')
abort.abort()
await shutdown.shutdown(outcome.reason === 'completed' ? 0 : 1)
}

158
apps/cli/src/plugin.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* `dsh plugin --profile <name> <args...>` — profile plugin management as a
* thin pnpm forwarder: initialize the profile on first use, run
* `pnpm <args...>` in the profile directory, then reconcile the
* `dsh.profile.bundles` layer list against the installed state (a dependency
* resolving to a package that declares `dsh.bundle` joins the layer stack; a
* removed or bundle-less dependency leaves it). Reconciling by installed
* state, not by dependency diff, means `update` activates a package that
* gained its `dsh.bundle` declaration in a newer version.
* @module @deepseek-ai/dsh/plugin
*/
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import {
DEFAULT_PROFILE_BUNDLES,
initProfile,
PROFILE_TEMPLATES,
readProfileManifest,
resolveBundleDir,
resolveProfileDir,
writeProfileManifest,
type ProfileManifest,
} from '@deepseek-ai/dsh-app-boot'
import { INSTALL_ANCHOR } from './profile-boot.ts'
const NAME = 'dsh'
/**
* Whether a resolved dependency exports a profile patch, i.e. is a bundle.
* @param packageName - the dependency's package name.
* @param profileDir - the profile directory (resolution anchor).
* @returns true when the package manifest declares `dsh.bundle`.
*/
function exportsPatch(packageName: string, profileDir: string): boolean {
let dir: string
try {
dir = resolveBundleDir(NAME, packageName, INSTALL_ANCHOR, profileDir)
} catch {
return false // pnpm reported success yet the package is unresolvable — treat as plain
}
const manifest = readProfileManifest(NAME, dir)
return manifest.dsh?.bundle?.patch !== undefined
}
/**
* Reconcile `dsh.profile.bundles` against the installed state: pnpm has
* already written the real installed names (so a git/path/tarball/alias spec
* on the command line reconciles by its true package name) and materialized
* the packages. A dependency that resolves to a `dsh.bundle`-declaring
* package joins the layer stack (appended in dependency order); a
* dependency-listed name that no longer does — removed, or the installed
* version dropped the declaration — leaves it. In-box bundles from the
* profile template are not dependencies and are never touched. Warns once
* per newly-added bundle-less dependency (a plain library is fine; the
* warning is orientation).
*/
function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
const after = readProfileManifest(NAME, profileDir)
const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
const dependencies = Object.keys(after.dependencies ?? {})
const plugins = after.dsh?.profile?.bundles ?? []
let changed = false
for (const packageName of dependencies) {
const isBundle = exportsPatch(packageName, profileDir)
if (isBundle && !plugins.includes(packageName)) {
plugins.push(packageName)
changed = true
} else if (!isBundle && !beforeDeps.has(packageName)) {
process.stderr.write(
`${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
+ '(a later update that gains one activates it automatically)\n',
)
}
}
const dependencySet = new Set(dependencies)
for (const packageName of [...plugins]) {
// Only dependency-managed entries are subject to removal; template
// bundles (dsh-base and friends) are not dependencies.
const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName)
const stillBundle = dependencySet.has(packageName) && exportsPatch(packageName, profileDir)
if (wasDependency && !stillBundle) {
plugins.splice(plugins.indexOf(packageName), 1)
changed = true
}
}
if (!changed) return
after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles: plugins } }
writeProfileManifest(profileDir, after)
}
/**
* Rewrite relative filesystem specs against the user's invoking directory.
* pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin`
* (or their `file:`/`link:` forms) would silently resolve inside the profile
* — `add .` from a plugin checkout would self-link the profile. Absolute
* specs, registry names, and every other pnpm argument pass through
* untouched.
* @param argument - one pnpm argument, verbatim from argv.
* @param cwd - the directory `dsh` was invoked from.
* @returns the argument with a relative path spec anchored to `cwd`.
*/
function anchorPathSpec(argument: string, cwd: string): string {
const match = /^(?<prefix>(?:file|link):)?(?<path>\.{1,2}(?:[/\\].*)?)$/.exec(argument)
if (match?.groups?.path === undefined) return argument
// A bare path stays bare and a prefixed spec keeps its prefix: pnpm's
// link-vs-copy semantics differ between `file:` and a plain directory
// path, and the anchor must not change which one the user asked for.
const prefix = match.groups.prefix ?? ''
return `${prefix}${resolve(cwd, match.groups.path)}`
}
/**
* Run one `dsh plugin` invocation: init if needed, forward to pnpm, reconcile.
* @param profile - the profile name.
* @param args - pnpm arguments with relative path specs anchored to the invoking directory.
* @returns the pnpm exit code.
*/
export function runPlugin(profile: string, args: readonly string[]): number {
const dir = resolveProfileDir(profile)
if (!existsSync(join(dir, 'package.json'))) {
initProfile(dir, PROFILE_TEMPLATES[profile] ?? DEFAULT_PROFILE_BUNDLES)
process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`)
}
const before = readProfileManifest(NAME, dir)
// Windows resolves pnpm through its .cmd shim, which spawn() refuses
// without a shell since the CVE-2024-27980 hardening.
const result = spawnSync('pnpm', args.map(argument => anchorPathSpec(argument, process.cwd())), {
cwd: dir,
stdio: 'inherit',
shell: process.platform === 'win32',
})
if (result.error !== undefined) {
const code = (result.error as NodeJS.ErrnoException).code
if (code === 'ENOENT') {
process.stderr.write(`${NAME}: pnpm not found on PATH — install pnpm to manage profile plugins\n`)
return 127
}
throw result.error
}
const exitCode = result.status ?? 1
if (exitCode === 0) {
reconcilePlugins(before, dir)
} else {
// pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH
// one; the profile owns it, and the commonest failure here is pnpm ≥10
// blocking a git dependency's prepare (build) script until allowlisted.
process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`)
if (args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) {
process.stderr.write(
`${NAME}: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — `
+ `add the exact key pnpm printed above under allowBuilds in ${join(dir, 'pnpm-workspace.yaml')}, then re-run\n`,
)
}
}
return exitCode
}

View File

@@ -0,0 +1,269 @@
/**
* Shared profile boot for every `dsh` surface: resolve the profile, stack its
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own
* `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry
* switch), mount the tree over the profile's empty root config, keep the
* profile patch layer live, and wire fail-loud plus bounded shutdown.
* @module @deepseek-ai/dsh/profile-boot
*/
import { writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import {
boot,
composeEntries,
healProfilesModuleFallback,
installFailLoud,
loadOptionalPatches,
loadOverlayPatches,
loadProfile,
PROFILE_PATCH_FILENAME,
watchUserPatches,
type Profile,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
const NAME = 'dsh'
/**
* The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied
* over every profile's own layer. Resolved per call, not at module load:
* `$DSH_HOME` may be set by the test or launcher after import.
* @returns the absolute patch-file path.
*/
export function homePatchPath(): string {
return join(resolveDshHome(), PROFILE_PATCH_FILENAME)
}
/** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */
export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url))
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
const TELEMETRY_ROW_ID = 'telemetry-otel'
/** The one-shot runner row a positional task requires and configures. */
const HEADLESS_ROW_ID = 'headless-runner'
/** The empty root entry list every profile tree patches over. */
const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
# --patch overlays. Edit cordis.patch.yml, not this file.
[]
`
/** Root config filename inside a profile directory. */
export const PROFILE_ROOT_FILENAME = 'cordis.yml'
/**
* Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
* value (including `'0'`/`'false'`) disables: a privacy switch prefers
* off-by-mistake over on-by-mistake. A composition without the telemetry row
* exports nothing, so the switch is then trivially satisfied and no patch is
* generated — custom profiles need not mount telemetry to run with the
* switch set.
* @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
* @param hasRow - whether the composition carries the telemetry row.
* @returns the disable patch, or `undefined` when telemetry stays enabled or is not mounted.
*/
export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
if ((disabledEnv ?? '') === '' || !hasRow) return undefined
return { id: TELEMETRY_ROW_ID, disabled: true }
}
/**
* Load a resolved profile for `name`: heal the shared module fallback, then
* (re)write the empty root config. The root is always rewritten: the whole
* composition is patch layers, and the vendored Loader's tree write-back (a
* plugin self-disposing persists the current tree) can bake composed rows
* into this file — which would duplicate every bundle insert on the next
* boot. The file exists on disk only because the Loader needs a real include
* root to anchor `baseUrl` at the profile directory (the config dump anchors
* on the same file, so both compose over the identical base).
* @param name - the profile name.
* @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
* @returns the loaded profile.
*/
export function prepareProfile(name: string, userLayer = true): Profile {
healProfilesModuleFallback(INSTALL_ANCHOR)
const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
return profile
}
/** Read-only row index of a profile composition before launcher flag patches. */
export type ProfileRows = ReadonlyMap<string, { name?: string; config?: unknown }>
/** One profile's patch layers (application order) and the row index of its pre-flag composition. */
interface ComposedProfile {
profile: Profile
/** Bundle layers concatenated — the part below the user layers on a live reload. */
bundlePatches: PatchOptions[]
/** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
homePatches: PatchOptions[]
/** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */
overlayAndFlags: PatchOptions[]
/**
* id → row of the pre-flag composition (bundles + user layers + overlays),
* for flag merges and row checks. Flag patches must not insert rows the
* launcher consults here (they only override values and insert dev glue).
*/
rows: ProfileRows
}
/** The full patch stack of one composed profile, in application order. */
function allPatches(composed: ComposedProfile): PatchOptions[] {
return [...composed.bundlePatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlayAndFlags]
}
/**
* Load `name` and compose its effective patch stack: bundle layers in
* `dsh.profile.bundles` order, the profile's user layer, the home-level user layer
* (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to
* every profile, so it outranks the per-profile layer), `--patch` overlays,
* then flag patches derived from the composed rows, then the telemetry
* switch.
* @param name - the profile name.
* @param patchFiles - `--patch` overlay paths, in argv order.
* @param deriveFlagPatches - launcher hook turning composed rows into flag patches.
* @returns the profile, its patch layers, and the composed row index.
*/
function composeProfile(
name: string,
patchFiles: readonly string[],
deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [],
): ComposedProfile {
const profile = prepareProfile(name)
const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
const bundlePatches = profile.layers.flatMap(layer => layer.patches)
const rows = new Map<string, { name?: string; config?: unknown }>()
for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
if (typeof row.id === 'string') rows.set(row.id, row)
}
const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)]
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch)
return { profile, bundlePatches, homePatches, overlayAndFlags, rows }
}
/** Options for {@link runProfile}. */
export interface RunProfileOptions {
/** The profile name to boot. */
profile: string
/** `--patch` overlay paths, in argv order. */
patchFiles: readonly string[]
/** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */
deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[]
/** One-shot task text; requires the composition to mount the headless runner row. */
task?: string
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
prepare?: (ctx: Context, rows: ProfileRows) => Promise<void> | void
}
/**
* Boot one profile invocation end to end and leave process lifetime to the
* mounted plugins (or to the one-shot runner when `task` is present).
* @param options - profile name, overlays, flag patches, and the optional task.
* @returns the settled root context and the shutdown controller.
*/
export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches)
if (options.task !== undefined) {
if (!composed.rows.has(HEADLESS_ROW_ID)) {
throw new Error(
`dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row `
+ '(the headless profile does)',
)
}
composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } })
} else if (composed.rows.has(HEADLESS_ROW_ID)) {
// The inverse misuse: a one-shot composition booted without its task
// would otherwise die in the runner row's schema with a raw "required"
// error naming no fix.
throw new Error(
`dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: `
+ `dsh --profile ${options.profile} "<task>"`,
)
}
const app: { current?: Context } = {}
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
// Signals own teardown throughout the startup window, not only after boot()
// settles: an inserted front door can publish readiness before sibling rows
// finish mounting.
process.on('SIGTERM', () => { shutdown.interrupt(options.task === undefined ? 0 : 143) })
process.on('SIGINT', () => { shutdown.interrupt(130) })
installFailLoud(NAME, process, async () => {
await app.current?.fiber.dispose()
})
const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
// Recomposition for the live user layers: bundle layers below, overlays
// and flag patches above, so a user edit can never displace them. BOTH
// user files are re-read per generation (the HMR watcher hands us only the
// changed file's patches, which one of the reads duplicates — fresh reads
// keep the two watchers from stitching in each other's stale copy).
// Fresh clones per generation: the include pushes `insert` rows into the
// mounted tree BY REFERENCE and later id-targeted patches mutate those
// objects in place. Reusing one parsed patch object across applications
// would bake a user override into the bundle's in-memory insert row, so
// removing the override could never revert the row to the bundle default.
const composeLive = (): PatchOptions[] => structuredClone([
...composed.bundlePatches,
...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
...composed.overlayAndFlags,
])
// One-shot runs exit through the runner; watching would only hold the
// process open after its exit request.
const watchProfilePatch = options.task === undefined
// Cloned for the same insert-aliasing reason as composeLive: the boot
// application must not mutate the objects later reloads recompose from.
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
app.current = hostCtx
if (options.task !== undefined) {
const io: HeadlessIo = {
stdout: process.stdout,
stderr: process.stderr,
exit: (code) => { void shutdown.shutdown(code) },
}
hostCtx.provide('headlessIo', io)
}
await options.prepare?.(hostCtx, composed.rows)
})
app.current = ctx
// A surface can dispose the whole tree while startup was still in flight
// (early SIGTERM); the Loader service goes with it and there is nothing to
// keep live.
if (watchProfilePatch && ctx.get('loader') !== undefined) {
// Config-only HMR for the live profile patch layer: the web bundle
// disables the shared module-reload `hmr` row (its reload lifecycle is
// untested), so when the composition leaves no HMR service, mount a
// watch-only instance with no module roots — cordis.patch.yml edits stay
// live on every long-lived surface. A silent skip would break the
// documented hot-reload contract. HMR injects the timer service, which a
// bare custom profile may not mount either.
if (ctx.get('hmr') === undefined) {
if (ctx.get('timer') === undefined) {
await ctx.loader.create({ name: '@cordisjs/plugin-timer' })
}
await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
}
await watchUserPatches(ctx, {
binName: NAME,
filename: composed.profile.patchPath,
compose: composeLive,
})
await watchUserPatches(ctx, {
binName: NAME,
filename: homePatchPath(),
compose: composeLive,
})
}
return { ctx, shutdown }
}

View File

@@ -1,159 +1,140 @@
/**
* `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 the shared base plus Web overlay; all boot glue lives in AppCLIEntry. Host and
* port are unvalidated pass-through overrides — the `dsh-host-webserver` schema
* gates them at boot.
* `dsh web` — the browser-surface alias over the profile boot: `--profile web`
* plus the Web flag family (`--host/--port/--dev/--workspace-root/
* --trusted-host`), each flag becoming a patch over the composed profile
* tree. All web runtime glue (dist serving, prompt section, URL line) lives
* in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives
* flag patches and the LAN-trust snapshot.
* @module @deepseek-ai/dsh/web
*/
import { networkInterfaces } from 'node:os'
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-bash-env'
import { AppCLIEntry } from './app-cli-entry.ts'
import { createProcessShutdown } from './process-shutdown.ts'
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
import { runProfile, type ProfileRows } from './profile-boot.ts'
// The shipped base plus the Web application's overlay.
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url))
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
const DSH_WEB_URL = 'DSH_WEB_URL' as const
const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
const WEB_RUNTIME_CONTEXT_BUILTIN = 'web-runtime-context' as const
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */
const ALL_INTERFACES_HOST = '0.0.0.0'
/** Web-launcher activation applied before personal or explicit configuration. */
export const WEB_RUNTIME_CONTEXT_ENABLE_PATCH = {
id: WEB_RUNTIME_CONTEXT_BUILTIN,
disabled: false,
} as const satisfies PatchOptions
type WebMode = 'production' | 'development'
// Display-only mirror of the webserver schema's loopback host: the address the
// local URL always prints. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
function webSurfacePrompt(webUrl: string, mode: WebMode): string {
const updateContract = mode === 'development'
? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. '
+ 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. '
+ 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. '
: 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. '
+ 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. '
return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. `
+ 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. '
+ 'The browser provides no implicit DOM, route, or screenshot context. '
+ updateContract
+ 'Starting another server does not update this GUI. '
+ 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. '
+ 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.'
}
/** Resolve the canonical loopback URL from the active Web server. */
function localWebUrl(ctx: Context): string {
const port = ctx.get('httpServer')?.port
if (port === undefined) throw new Error('dsh web: httpServer service missing while resolving Web runtime')
return `http://${LOOPBACK_HOST}:${String(port)}`
}
/** Register the model-facing Web orientation and its matching shell variables. */
function applyWebRuntimeContext(ctx: Context, sourceRoot: string, mode: WebMode): void {
ctx.inject(['systemPrompt'], (promptCtx) => {
addHarnessSourceSection(promptCtx, sourceRoot)
promptCtx.systemPrompt.section({
name: 'app:web-surface',
order: -98,
text: () => webSurfacePrompt(localWebUrl(promptCtx), mode),
})
})
ctx.inject(['bashEnv'], (runtimeCtx) => {
runtimeCtx.bashEnv.register({
name: 'web-runtime',
variables: {
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
[DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
},
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: mode }),
})
})
/**
* Non-internal IPv4 interface addresses of this machine — the IP-literal
* authorities an all-interfaces bind is reachable by on the LAN.
* @returns the addresses in interface order (possibly empty).
*/
function lanIPv4Addresses(): string[] {
return Object.values(networkInterfaces()).flat()
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
.map(iface => iface.address)
}
/**
* Register the launcher-owned runtime-context builtin before the config tree
* mounts. The Web overlay decides whether to mount it, so profiles that own
* their complete model prompt can disable the contribution without changing
* launcher control flow. Dynamic values read the bound server only when used.
* @param ctx - Web root context with Loader installed but no config tree mounted.
* @param sourceRoot - absolute checkout root resolved from the launcher module.
* @param mode - whether this process mounted the client-plugin HMR receiver.
* One LAN-trust resolution for one invocation, sampled exactly once: the
* machine's LAN IP literals when the effective bind is all-interfaces, and
* the `trustedHosts` value built from them plus the explicit extras. The
* single sample is deliberate — display must advertise only addresses the
* fence was configured with, so the web-app row receives this same snapshot.
* Derived entries are port-less IP literals: DNS rebinding needs an
* attacker-controlled name, so an IP-literal Host is safe on any port, and
* the bound port may be OS-assigned, unknowable pre-boot.
* @param bindHost - the effective webserver bind host (CLI flag, else the composed row value).
* @param extra - `--trusted-host` values, in argv order.
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
*/
export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: WebMode): void {
const { builtins } = ctx.loader
if (builtins[WEB_RUNTIME_CONTEXT_BUILTIN] !== undefined) {
throw new Error(`dsh web: Loader builtin "${WEB_RUNTIME_CONTEXT_BUILTIN}" is already registered`)
}
const plugin = (runtimeCtx: Context): void => {
applyWebRuntimeContext(runtimeCtx, sourceRoot, mode)
}
builtins[WEB_RUNTIME_CONTEXT_BUILTIN] = plugin
ctx.effect(() => () => {
if (builtins[WEB_RUNTIME_CONTEXT_BUILTIN] === plugin) {
Reflect.deleteProperty(builtins, WEB_RUNTIME_CONTEXT_BUILTIN)
}
}, 'dsh web runtime-context builtin')
export function resolveLanTrust(
bindHost: string | undefined,
extra: readonly string[],
): { lanAddresses: string[]; trustedHosts: string[] } {
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
}
/** The `dsh web` flag family, already parsed by the argument adapter. */
export interface WebFlags {
patches: string[]
host?: string
port?: number
dev: boolean
workspaceRoot?: string
trustedHosts?: string[]
}
/**
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
* through only when the flag was given; absent, the shipped Web overlay 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 receiver; `pnpm run dev:web` separately rebuilds watched plugin bundles.
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
* @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
* @param config - an overlay of loader patches applied over the shipped web
* composition instead of `$DSH_HOME/config.yaml`, or `undefined` to use the
* personal overlay; already parsed from `--config`.
* Derive the web alias's flag patches over an already-composed profile tree.
* Patches replace a row's whole config, so each patched row's composed values
* are re-read and merged under the overrides.
* @param rows - the composed row index from {@link composeProfile}.
* @param flags - the parsed flag family.
* @returns the flag patch list, in application order.
*/
export async function runWeb(
host: string | undefined,
port: number | undefined,
dev: boolean,
workspaceRoot: string | undefined,
trustedHosts: string[] | undefined,
config?: string,
): Promise<void> {
const mode: WebMode = dev ? 'development' : 'production'
const entry = new AppCLIEntry({
configPath: BASE_CONFIG,
overlayPath: WEB_OVERLAY,
launcherPatches: [WEB_RUNTIME_CONTEXT_ENABLE_PATCH],
...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) },
dev,
prepare: (ctx) => { prepareWebRuntimeContext(ctx, SOURCE_ROOT, mode) },
watchPersonalConfig: true,
...host !== undefined && { host },
...port !== undefined && { port },
...workspaceRoot !== undefined && { workspaceRoot },
...trustedHosts !== undefined && { trustedHosts },
function deriveWebFlagPatches(
rows: ProfileRows,
flags: WebFlags,
): PatchOptions[] {
const overrides = new Map<string, Record<string, unknown>>()
const put = (entryId: string, key: string, value: unknown): void => {
const bag = overrides.get(entryId) ?? {}
bag[key] = value
overrides.set(entryId, bag)
}
if (flags.host !== undefined) put('webserver', 'host', flags.host)
if (flags.port !== undefined) put('webserver', 'port', flags.port)
if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot)
const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? [])
if (trustedHosts.length > 0) {
// Additive over the composed value: a cordis.patch.yml-configured fence
// authority must survive the derived LAN literals and flag extras — a
// silent drop of security-relevant fence configuration.
const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? []
put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts])
}
// mode and lanAddresses are launcher-derived on every boot (--dev also
// inserts the client-hmr row), never pass-throughs of composed values.
put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
put('web-runtime', 'lanAddresses', lanAddresses)
const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
const composed = rows.get(id)
if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`)
return { id, config: { ...(composed.config ?? {}) as Record<string, unknown>, ...bag } }
})
if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] })
return patches
}
/**
* Whether the composed Web runtime keeps its model- and shell-visible surface
* context. The bundle schema defaults the field to true, so only an explicit
* false suppresses both the bundle contributions and the launcher-owned
* source-checkout section.
* @param rows - the composed Web profile rows before launcher flag patches.
* @returns true unless the web-runtime row explicitly disables surface context.
*/
export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
return (rows.get('web-runtime')?.config as { surfaceContext?: boolean } | undefined)?.surfaceContext !== false
}
/**
* Serve the browser UI from the web profile. Host/port/workspace-root flags
* are passed through only when given (absent, the composed profile values
* stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on
* every boot. The URL line is printed by the web-app bundle's runtime row
* after Loader settlement.
* @param flags - the parsed `dsh web` flag family.
*/
export async function runWeb(flags: WebFlags): Promise<void> {
await runProfile({
profile: 'web',
patchFiles: flags.patches,
deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags),
prepare: (ctx: Context, rows: ProfileRows) => {
if (!webSurfaceContextEnabled(rows)) return
ctx.inject(['systemPrompt'], (promptCtx) => {
addHarnessSourceSection(promptCtx, SOURCE_ROOT)
})
},
})
const { ctx, port: boundPort } = await entry.run()
const resolvedLocalWebUrl = localWebUrl(ctx)
const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
// Install shutdown handling before publishing readiness: supervisors may
// send a signal as soon as they observe the URL line.
process.on('SIGTERM', () => { shutdown.interrupt(0) })
process.on('SIGINT', () => { shutdown.interrupt(130) })
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
// must name an address the /api trust fence was configured with.
const lanCandidate = entry.lanAddresses[0]
console.log(`dsh web: ${resolvedLocalWebUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
}