/** * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share * for the Web/headless surface. * Everything here is what must exist before the Loader runs: layered env, * the patch composition over the shipped base and surface overlay (profile json + CLI * flags + the resolved frontend dist), and the fail-loud triple after the * tree settles. */ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { networkInterfaces } from 'node:os' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { Context } from 'cordis' import type { FiberState } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' import { assertEntriesLoaded, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' // 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 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 => 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] } } /** 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) /** * Value mirror of cordis's `FiberState` const enum members the sweep needs * (a const enum has no runtime object to import; same rationale as the * client-side mirror in dsh-client-web). */ const FIBER_ACTIVE = 2 as FiberState.ACTIVE const FIBER_PENDING = 0 as FiberState.PENDING /** 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 the HMR row (the whole prod/dev difference; web surface only). */ dev: 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[] } /** * 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: layered env → patch composition → Loader include * 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.loadEnvLayers() 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 } } /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */ private loadEnvLayers(): void { loadEnv('dsh', resolveDshHome()) } /** * Compose the patch set from the non-yml config sources: computed * engineering defaults (the global session root), profile json (user * config, overriding those defaults), 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>() const put = (entryId: string, key: string, value: unknown): void => { const bag = overrides.get(entryId) ?? {} bag[key] = value overrides.set(entryId, bag) } // Source 0: computed engineering defaults. The session store is the one // shared root every dsh surface resolves, so history follows the user across // working directories instead of splitting per project. The profile // (Source 1) overwrites this same field via last-write-wins in put(). put('session-persistence-jsonl', 'root', resolveSessionsRoot()) // 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()) this.patches = [...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, ...bag } } }) } /** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */ private async bootTree(): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include // 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 patches = [ ...loadOverlayPatches('dsh', this.options.overlayPath), ...this.options.extraOverlayPath === undefined ? loadPersonalPatches('dsh') ?? [] : loadOverlayPatches('dsh', this.options.extraOverlayPath), ...this.patches, ] await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(resolve(this.options.configPath)).href, ...patches.length > 0 ? { patches } : {}, }, }) if (this.options.dev) { await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) } this.ctx = ctx await ctx.loader.await() } /** * Fail-loud triple: assertEntriesLoaded catches import failures, * installFailLoud catches late apply rejections, and the all-ACTIVE sweep * below catches PENDING fibers (cordis inject waiting has no timeout). */ private assertBoot(): void { installFailLoud('dsh') assertEntriesLoaded(this.ctx, 'dsh') const failures: string[] = [] for (const entry of this.ctx.loader.entries()) { if (entry.fiber === undefined || entry.disabled) continue const state = entry.fiber.state if (state === FIBER_ACTIVE) continue if (state === FIBER_PENDING) { const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined) failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) } else { failures.push(`${entry.options.name}: fiber state ${String(state)}`) } } if (failures.length > 0) { throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) } } /** * 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 { const rows = new Map() 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 { 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 } /** 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 --filter @deepseek-ai/dsh-frontend build first') } } }