2026-07-25 01:19:47 +08:00
|
|
|
/**
|
2026-07-25 12:49:46 +08:00
|
|
|
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
|
2026-07-29 21:59:44 +08:00
|
|
|
* for the Web/headless surface.
|
2026-07-30 15:44:32 +08:00
|
|
|
* Everything here is what must exist before the Loader runs: the patch
|
2026-07-30 19:46:04 +08:00
|
|
|
* 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. 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.
|
2026-07-25 01:19:47 +08:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
|
import { createRequire } from 'node:module'
|
2026-07-28 15:40:02 +08:00
|
|
|
import { networkInterfaces } from 'node:os'
|
2026-07-25 01:19:47 +08:00
|
|
|
import { join, resolve } from 'node:path'
|
|
|
|
|
import { Context } from 'cordis'
|
2026-07-29 23:39:09 +08:00
|
|
|
import type { PatchOptions } from '@cordisjs/plugin-include'
|
2026-07-25 01:19:47 +08:00
|
|
|
import yaml from 'js-yaml'
|
2026-07-30 19:46:04 +08:00
|
|
|
import { boot, installFailLoud, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
|
2026-07-25 02:15:58 +08:00
|
|
|
// Empty type import carries the httpServer Context merge for the port read below.
|
|
|
|
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
2026-07-25 01:19:47 +08:00
|
|
|
|
|
|
|
|
/** 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'
|
|
|
|
|
|
2026-07-31 00:50:04 +08:00
|
|
|
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */
|
|
|
|
|
const TELEMETRY_ROW_ID = 'telemetry-otel'
|
|
|
|
|
|
2026-07-28 15:40:02 +08:00
|
|
|
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
|
2026-07-28 21:54:02 +08:00
|
|
|
const ALL_INTERFACES_HOST = '0.0.0.0'
|
2026-07-28 15:40:02 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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).
|
|
|
|
|
*/
|
2026-07-28 17:47:56 +08:00
|
|
|
function lanIPv4Addresses(): string[] {
|
2026-07-28 15:40:02 +08:00
|
|
|
return Object.values(networkInterfaces()).flat()
|
|
|
|
|
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
|
|
|
|
|
.map(iface => iface.address)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-28 17:47:56 +08:00
|
|
|
* 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.
|
2026-07-28 15:40:02 +08:00
|
|
|
* @param bindHost - the effective webserver bind host (CLI flag, else the yml default).
|
|
|
|
|
* @param extra - `--trusted-host` values, in argv order.
|
2026-07-28 17:47:56 +08:00
|
|
|
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
|
2026-07-28 15:40:02 +08:00
|
|
|
*/
|
2026-07-28 17:47:56 +08:00
|
|
|
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] }
|
2026-07-28 15:40:02 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-31 00:50:04 +08:00
|
|
|
/**
|
|
|
|
|
* 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 }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 01:19:47 +08:00
|
|
|
/** 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.
|
|
|
|
|
*/
|
2026-07-25 02:15:58 +08:00
|
|
|
const PROFILE_MAPPINGS: ProfileMapping[] = [
|
2026-07-25 01:19:47 +08:00
|
|
|
{ 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)
|
|
|
|
|
|
2026-07-25 12:49:46 +08:00
|
|
|
/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
|
2026-07-25 01:19:47 +08:00
|
|
|
export interface AppCLIEntryOptions {
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
/** Absolute path of the shared base config the Loader includes. */
|
2026-07-25 01:19:47 +08:00
|
|
|
configPath: string
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
/**
|
2026-07-29 23:36:58 +08:00
|
|
|
* 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.
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
*/
|
|
|
|
|
extraOverlayPath?: string
|
2026-07-25 12:49:46 +08:00
|
|
|
/** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
|
2026-07-25 01:19:47 +08:00
|
|
|
dev: boolean
|
|
|
|
|
/** --host when explicitly passed; undefined keeps the yml engineering default. */
|
|
|
|
|
host?: string
|
2026-07-25 12:49:46 +08:00
|
|
|
/**
|
|
|
|
|
* 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).
|
|
|
|
|
*/
|
2026-07-25 01:19:47 +08:00
|
|
|
port?: number
|
2026-07-25 16:04:48 +08:00
|
|
|
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
|
|
|
|
|
workspaceRoot?: string
|
2026-07-28 15:40:02 +08:00
|
|
|
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
|
|
|
|
|
trustedHosts?: string[]
|
2026-07-25 01:19:47 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-25 12:49:46 +08:00
|
|
|
* 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.
|
2026-07-25 01:19:47 +08:00
|
|
|
*/
|
|
|
|
|
export class AppCLIEntry {
|
|
|
|
|
/** The root context, set by {@link run}. */
|
|
|
|
|
ctx!: Context
|
|
|
|
|
|
2026-07-28 17:47:56 +08:00
|
|
|
/**
|
|
|
|
|
* 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[] = []
|
|
|
|
|
|
2026-07-25 01:19:47 +08:00
|
|
|
private patches: PatchOptions[] = []
|
|
|
|
|
|
|
|
|
|
constructor(private readonly options: AppCLIEntryOptions) {}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-30 19:46:04 +08:00
|
|
|
* Run the boot chain: patch composition → Loader include boot (dev row
|
|
|
|
|
* before await) → fail-loud triple.
|
2026-07-25 01:19:47 +08:00
|
|
|
* @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 */
|
2026-07-25 12:49:46 +08:00
|
|
|
if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
|
2026-07-25 01:19:47 +08:00
|
|
|
return { ctx: this.ctx, port }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-30 14:56:39 +08:00
|
|
|
* 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
|
2026-07-27 17:53:31 +08:00
|
|
|
* static values are re-read here (bypass parse) and merged under the overrides.
|
2026-07-25 01:19:47 +08:00
|
|
|
*/
|
|
|
|
|
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) {
|
2026-07-25 12:49:46 +08:00
|
|
|
throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
|
2026-07-25 01:19:47 +08:00
|
|
|
}
|
|
|
|
|
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)
|
2026-07-25 16:04:48 +08:00
|
|
|
if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
|
2026-07-25 01:19:47 +08:00
|
|
|
|
2026-07-28 15:40:02 +08:00
|
|
|
// Source 2b: authorities for the /api browser-trust fence (rationale on
|
2026-07-28 17:47:56 +08:00
|
|
|
// resolveLanTrust).
|
2026-07-28 15:40:02 +08:00
|
|
|
const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
|
2026-07-28 17:47:56 +08:00
|
|
|
const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
|
|
|
|
|
this.lanAddresses = lanAddresses
|
2026-07-28 15:40:02 +08:00
|
|
|
if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
|
|
|
|
|
|
2026-07-25 01:19:47 +08:00
|
|
|
// 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)
|
2026-07-25 12:49:46 +08:00
|
|
|
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
|
2026-07-25 02:15:58 +08:00
|
|
|
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
|
2026-07-25 01:19:47 +08:00
|
|
|
})
|
2026-07-30 14:51:33 +08:00
|
|
|
|
|
|
|
|
// 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.
|
2026-07-31 00:50:04 +08:00
|
|
|
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
|
|
|
|
|
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
|
2026-07-25 01:19:47 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-29 23:39:09 +08:00
|
|
|
/** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */
|
2026-07-25 01:19:47 +08:00
|
|
|
private async bootTree(): Promise<void> {
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
// 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
|
2026-07-29 23:36:58 +08:00
|
|
|
? loadPersonalPatches('dsh') ?? []
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
|
|
|
|
|
...this.patches,
|
|
|
|
|
]
|
2026-07-29 23:39:09 +08:00
|
|
|
this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => {
|
|
|
|
|
if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
|
2026-07-25 01:19:47 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 14:56:39 +08:00
|
|
|
/** Install the diagnostic for plugin rejections that happen after settled boot. */
|
2026-07-25 01:19:47 +08:00
|
|
|
private assertBoot(): void {
|
2026-07-25 12:49:46 +08:00
|
|
|
installFailLoud('dsh')
|
2026-07-25 01:19:47 +08:00
|
|
|
}
|
|
|
|
|
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-07-25 01:19:47 +08:00
|
|
|
private parseYmlRows(): Map<string, { config?: unknown }> {
|
|
|
|
|
const rows = new Map<string, { config?: unknown }>()
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-25 01:19:47 +08:00
|
|
|
}
|
|
|
|
|
return rows
|
|
|
|
|
}
|
|
|
|
|
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
/**
|
|
|
|
|
* 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 }[] }[]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 01:19:47 +08:00
|
|
|
/** 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)) {
|
2026-07-25 12:49:46 +08:00
|
|
|
throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
|
2026-07-25 01:19:47 +08:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-07-25 12:49:46 +08:00
|
|
|
throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
|
2026-07-25 01:19:47 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|