$DSH_HOME/config.yaml was an implicit composition layer: if the file existed, every launch applied an arbitrary Loader patch graph over the shipped tree, kept live by a dedicated HMR watcher. Three costs came from the implicitness, not the capability. A patch replaces its target row's whole config, so a file written months ago pins that row to the field set it knew and every default the shipped tree later adds silently stops applying. It competed with the typed settings namespaces llm-deepseek and llm-pi-ai already register, so which one wins was a function of layer order rather than meaning. And the explicit escape hatch it was supposedly redundant with did not exist on every surface: dsh -p, dsh meta, and dsh upgrade all rejected --config, so for them the implicit file was the only composition route at all. Complete the explicit layer first: --config and --config-replace now work on every booting surface. A headless --config-replace tree must still mount a webserver row, because that surface reaches its own agent over the same HTTP gateway the browser uses; AppCLIEntry names that contract in the failure instead of reporting a bare missing service. Then delete the implicit one. PERSONAL_CONFIG_FILENAME, loadPersonalPatches, watchPersonalPatches, and the config-only HMR row mounted for it are gone; a file left at that path is inert, and --dump-config no longer reads the Harness home. --config therefore stops *replacing* the personal overlay and simply *is* the user overlay. No migration: a user who wants the old behavior names the same file (dsh --config ~/.dsh/config.yaml), which a shell alias makes permanent.
327 lines
16 KiB
TypeScript
327 lines
16 KiB
TypeScript
/**
|
|
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
|
|
* (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly).
|
|
* Everything here is what must exist before the Loader runs: the patch
|
|
* composition over the shipped base and surface overlay (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 over the
|
|
* invoking directory's `.env` over `$DSH_HOME/.env`); credentials live in
|
|
* `$DSH_HOME/.credentials.yaml` and are never hoisted into it.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import { networkInterfaces } from 'node:os'
|
|
import { resolve } from 'node:path'
|
|
import { Context } from 'cordis'
|
|
import type { PatchOptions } from '@cordisjs/plugin-include'
|
|
import yaml from 'js-yaml'
|
|
import { boot, installFailLoud, loadOverlayPatches } 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'
|
|
|
|
/** 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} (the TUI).
|
|
* @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))
|
|
}
|
|
|
|
// 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 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 `--config` overlay applied after {@link overlayPath} and before this entry's own flag patches. */
|
|
extraOverlayPath?: string
|
|
/**
|
|
* Optional `--config-replace` tree: booted INSTEAD of {@link configPath},
|
|
* {@link overlayPath}, {@link extraOverlayPath}, and every generated patch,
|
|
* so the caller's file is the whole composition. It must still supply the
|
|
* serving rows this entry needs — {@link run} rejects a settled tree with no
|
|
* `httpServer`.
|
|
*/
|
|
configReplacePath?: string
|
|
/** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */
|
|
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[]
|
|
/** 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
|
|
if (port === undefined) {
|
|
// The shipped tree always carries the webserver row, so this is only
|
|
// reachable through --config-replace: name the missing contract rather
|
|
// than report a bare missing service.
|
|
throw new Error(
|
|
`dsh: no httpServer after booting ${this.bootConfigPath()}; this surface serves over HTTP, so a`
|
|
+ ' --config-replace tree must mount a webserver row',
|
|
)
|
|
}
|
|
return { ctx: this.ctx, port }
|
|
}
|
|
|
|
/**
|
|
* Compose the patch set from 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()
|
|
if (this.options.configReplacePath !== undefined) {
|
|
// A replacement tree is the caller's whole composition: the generated
|
|
// patches target shipped row ids this file cannot assume exist, and a
|
|
// patch whose id is absent is a silent no-op rather than a diagnostic.
|
|
// Telemetry stays, judged against the tree actually booting, because a
|
|
// privacy switch that silently no-ops is worse than a loud one.
|
|
const replaceTelemetry = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
|
|
this.patches = replaceTelemetry === undefined ? [] : [replaceTelemetry]
|
|
return
|
|
}
|
|
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: CLI flags.
|
|
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 1b: 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 2: 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
|
|
// any --config overlay, then this entry's CLI-flag patches, which win.
|
|
// --config-replace discards all three and boots the named file alone.
|
|
const patches = this.options.configReplacePath !== undefined
|
|
? this.patches
|
|
: [
|
|
...loadOverlayPatches('dsh', this.options.overlayPath),
|
|
...this.options.extraOverlayPath === undefined
|
|
? []
|
|
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
|
|
...this.patches,
|
|
]
|
|
this.ctx = await boot('dsh', resolve(this.bootConfigPath()), patches, async (ctx) => {
|
|
await this.options.prepare?.(ctx)
|
|
if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
|
|
})
|
|
}
|
|
|
|
/** The file the Loader includes: the replacement tree when named, otherwise the shared base. */
|
|
private bootConfigPath(): string {
|
|
return this.options.configReplacePath ?? this.options.configPath
|
|
}
|
|
|
|
/** 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 }>()
|
|
// A replacement tree stands alone, so only its own rows are indexed —
|
|
// the telemetry-row check must judge the tree that actually boots.
|
|
if (this.options.configReplacePath !== undefined) {
|
|
for (const row of this.parseRowList(this.options.configReplacePath)) {
|
|
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
|
|
}
|
|
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 }[] }[]
|
|
}
|
|
|
|
/** 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')
|
|
}
|
|
}
|
|
}
|