feat(cli)!: dsh boots profiles; plugin subcommand manages them via pnpm

dsh --profile <name> replaces the fixed entry modes: --config and -p are
removed, --patch adds overlays over the composed profile, a positional task
selects one-shot mode (requires the headless-runner row), and dsh web stays as
the alias for --profile web carrying the Web flag family as patches. dsh
plugin --profile <name> forwards verbatim to pnpm in the profile directory,
initializes on first use, and reconciles the dsh.plugins layer list after
add/remove (patch-less packages warn and stay plain dependencies). Config
dumps and the keyless web e2e scaffold compose the same bundle layers over the
same empty root as the boot.
This commit is contained in:
Turtle
2026-08-06 04:40:32 +08:00
parent 9235d0f90f
commit cd6b4ee3c9
44 changed files with 1126 additions and 1845 deletions

View File

@@ -1,355 +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
/**
* Optional explicit overlay applied after {@link overlayPath} and before
* this entry's own profile/flag patches. When absent, the personal
* `$DSH_HOME/config.yaml` overlay is applied 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 surface overlay applies first, then
// this entry's profile-json and CLI-flag patches, which therefore win.
const compose = (overlay: PatchOptions[]): PatchOptions[] => [
...loadOverlayPatches('dsh', this.options.overlayPath),
...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,112 @@ 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')
}
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
return
}
resolved = resolveWeb(options)
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,52 +1,57 @@
/**
* 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, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import {
healProfilesModuleFallback,
loadOverlayPatches,
loadPersonalPatches,
PERSONAL_CONFIG_FILENAME,
loadProfile,
renderConfigDump,
type ConfigDumpLayer,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { INSTALL_ANCHOR } 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.
* @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 {
healProfilesModuleFallback(INSTALL_ANCHOR)
const loaded = loadProfile(NAME, profile, INSTALL_ANCHOR)
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) })
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) })
}
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))
// renderConfigDump anchors on a base entry-list file; a profile's base is
// the empty list, materialized as a temp document.
const emptyRoot = mkdtempSync(join(tmpdir(), 'dsh-dump-'))
const emptyRootFile = join(emptyRoot, 'profile-root.yml')
writeFileSync(emptyRootFile, '[]\n')
try {
process.stdout.write(renderConfigDump(NAME, emptyRootFile, layers))
} finally {
rmSync(emptyRoot, { recursive: true, force: true })
}
}
/* v8 ignore stop */

View File

@@ -1,114 +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 task turn ends, per the cli-demo runOneShot
* correlation precedent: anchor on the first turn/start whose trigger kind is
* 'message' (startup-injected turns are skipped), aggregate text from that
* turn's assistant/message events (last one wins), finish on its turn/end.
*/
async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>, sessionId: SessionId): Promise<TurnOutcome> {
let targetTurn: number | undefined
let text = ''
try {
for await (const frame of frames) {
const payload = frame.payload
if (payload.type === 'stream/error') {
process.stderr.write(`dsh: stream error: ${payload.error.message}\n`)
return { text, reason: 'error' }
}
if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
const event = payload.event
if (targetTurn === undefined) {
if (event.type === 'turn/start' && event.data.trigger.kind === 'message') targetTurn = event.data.turn
continue
}
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
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' && event.data.turn === targetTurn) {
return { text, reason: event.data.reason.kind }
}
}
} catch (error: unknown) {
process.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
}
return { text, reason: 'error' }
}
/**
* 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 done = consumeUntilTurnEnd(frames, created.sessionId)
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)
}

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

@@ -0,0 +1,108 @@
/**
* `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.plugins`
* bundle-layer list from the manifest's dependency diff (a package exporting
* a `dsh.patch` joins the layer stack; one without only warns — it is a plain
* library dependency; a removed dependency leaves the stack).
* @module @deepseek-ai/dsh/plugin
*/
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import {
DEFAULT_PROFILE_PLUGINS,
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.patch`.
*/
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?.patch !== undefined
}
/**
* Reconcile `dsh.plugins` against the manifest's dependency diff: 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. Added bundle
* dependencies append (in dependency order); removed dependencies drop.
*/
function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
const after = readProfileManifest(NAME, profileDir)
const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
const afterDeps = Object.keys(after.dependencies ?? {})
const plugins = after.dsh?.plugins ?? []
let changed = false
for (const packageName of afterDeps) {
if (beforeDeps.has(packageName) || plugins.includes(packageName)) continue
if (!exportsPatch(packageName, profileDir)) {
process.stderr.write(`${NAME}: warning: ${packageName} declares no dsh.patch — installed as a plain dependency, not a profile layer\n`)
continue
}
plugins.push(packageName)
changed = true
}
const afterSet = new Set(afterDeps)
for (const packageName of beforeDeps) {
if (afterSet.has(packageName) || !plugins.includes(packageName)) continue
plugins.splice(plugins.indexOf(packageName), 1)
changed = true
}
if (!changed) return
after.dsh = { ...after.dsh, plugins }
writeProfileManifest(profileDir, after)
}
/**
* Run one `dsh plugin` invocation: init if needed, forward to pnpm, reconcile.
* @param profile - the profile name.
* @param args - pnpm arguments, verbatim.
* @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_PLUGINS)
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], {
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)
return exitCode
}

View File

@@ -0,0 +1,236 @@
/**
* Shared profile boot for every `dsh` surface: resolve the profile, stack its
* patch layers (bundle layers in `dsh.plugins` 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,
loadOverlayPatches,
loadProfile,
watchPersonalPatches,
type Profile,
} from '@deepseek-ai/dsh-app-boot'
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
const NAME = 'dsh'
/** 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.plugins, then cordis.patch.yml, then any
# --patch overlays. Edit cordis.patch.yml, not this file.
[]
`
/** Root config filename inside a profile directory. */
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. 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 telemetry 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 }
}
/** Load a resolved profile for `name`, healing the shared module fallback first. */
function prepareProfile(name: string): Profile {
healProfilesModuleFallback(INSTALL_ANCHOR)
const profile = loadProfile(NAME, name, INSTALL_ANCHOR)
const rootConfig = join(profile.dir, PROFILE_ROOT_FILENAME)
// The root is always rewritten to the empty list: 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 stays a real on-disk include root only because the Loader needs
// one to anchor `baseUrl` at the profile directory.
writeFileSync(rootConfig, PROFILE_ROOT_CONFIG)
return profile
}
/** One profile's full patch stack and the row index of its composed tree. */
interface ComposedProfile {
profile: Profile
/** Bundle + profile + --patch + flag layers, in application order. */
patches: PatchOptions[]
/** id → composed row (post-composition), for flag merges and row checks. */
rows: Map<string, { name?: string; config?: unknown }>
}
/**
* Load `name` and compose its effective patch stack. Flag patches derive from
* the pre-flag composition (`deriveFlagPatches` receives the row index of
* bundle + profile + overlay layers), then apply last, 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 stack, and the composed row index (flags included).
*/
function composeProfile(
name: string,
patchFiles: readonly string[],
deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [],
): ComposedProfile {
const profile = prepareProfile(name)
const overlayLayers = patchFiles.map(file => loadOverlayPatches(NAME, resolve(file)))
const layers = [
...profile.layers.map(layer => layer.patches),
profile.patches,
...overlayLayers,
]
const indexRows = (composedEntries: { id?: string; name?: string; config?: unknown; group?: unknown }[]): ComposedProfile['rows'] => {
const rows = new Map<string, { name?: string; config?: unknown }>()
const walk = (entries: typeof composedEntries): void => {
for (const row of entries) {
if (typeof row.id === 'string') rows.set(row.id, row)
if (row.group === true && Array.isArray(row.config)) walk(row.config as typeof composedEntries)
}
}
walk(composedEntries)
return rows
}
const flagPatches = deriveFlagPatches(indexRows(composeEntries(layers)))
layers.push(flagPatches)
const rows = indexRows(composeEntries(layers))
const patches = layers.flat()
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) patches.push(telemetryPatch)
return { profile, patches, 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: ComposedProfile['rows']) => 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) => 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.patches.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 profile layer: bundle layers below, overlays
// and flag patches above, so a profile edit can never displace them.
const overlayAndFlags = composed.patches.slice(
composed.profile.layers.reduce((n, layer) => n + layer.patches.length, 0)
+ composed.profile.patches.length,
)
const composeLive = (profilePatches: PatchOptions[]): PatchOptions[] => [
...composed.profile.layers.flatMap(layer => layer.patches),
...profilePatches,
...overlayAndFlags,
]
// One-shot runs exit through the runner; watching would only hold the
// process open after its exit request.
const watchProfilePatch = options.task === undefined
const ctx = await boot(NAME, rootConfig, composed.patches, 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)
})
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 watchPersonalPatches(ctx, {
binName: NAME,
filename: composed.profile.patchPath,
compose: composeLive,
})
}
return { ctx, shutdown }
}

View File

@@ -1,133 +1,117 @@
/**
* `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 { 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 type { PatchOptions } from '@cordisjs/plugin-include'
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
import { runProfile } 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
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */
const ALL_INTERFACES_HOST = '0.0.0.0'
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)}`
/**
* 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 prompt and shell runtime context before the
* shared config tree mounts. The earlier injections install the prompt
* sections and managed Bash contributor when their owning services activate;
* dynamic values read the bound server only when consumed.
* @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 {
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 }),
})
})
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,
...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: Map<string, { name?: string; config?: unknown }>,
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) put('connection', 'trustedHosts', trustedHosts)
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
}
/**
* Serve the browser UI from the web profile. Flags are passed through only
* when given; absent, the composed profile values stand. 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) => {
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})`}`)
}