app-cli-entry.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. /**
  2. * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
  3. * for the Web/headless surface.
  4. * Everything here is what must exist before the Loader runs: layered env,
  5. * the patch composition over the shipped base and surface overlay (profile json + CLI
  6. * flags + the resolved frontend dist), and the fail-loud triple after the
  7. * tree settles.
  8. */
  9. import { readFileSync } from 'node:fs'
  10. import { createRequire } from 'node:module'
  11. import { networkInterfaces } from 'node:os'
  12. import { join, resolve } from 'node:path'
  13. import { Context } from 'cordis'
  14. import type { PatchOptions } from '@cordisjs/plugin-include'
  15. import yaml from 'js-yaml'
  16. import { boot, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
  17. import { resolveDshHome } from '@deepseek-ai/dsh-paths'
  18. // Empty type import carries the httpServer Context merge for the port read below.
  19. import type {} from '@deepseek-ai/dsh-host-webserver'
  20. /** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */
  21. const PROFILE_DIR = '.dsh-tmp-profile'
  22. const PROFILE_FILE = 'config.json'
  23. /** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
  24. const ALL_INTERFACES_HOST = '0.0.0.0'
  25. /**
  26. * Non-internal IPv4 interface addresses of this machine — the IP-literal
  27. * authorities an all-interfaces bind is reachable by on the LAN.
  28. * @returns the addresses in interface order (possibly empty).
  29. */
  30. function lanIPv4Addresses(): string[] {
  31. return Object.values(networkInterfaces()).flat()
  32. .filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
  33. .map(iface => iface.address)
  34. }
  35. /**
  36. * One LAN-trust resolution for one invocation, sampled exactly once: the
  37. * machine's LAN IP literals when the effective bind is all-interfaces, and
  38. * the `trustedHosts` value built from them plus the explicit extras. The
  39. * single sample is deliberate — display must advertise only addresses the
  40. * fence was configured with, so both read this snapshot. Derived entries are
  41. * port-less IP literals: DNS rebinding needs an attacker-controlled name, so
  42. * an IP-literal Host is safe on any port, and the bound port may be
  43. * OS-assigned, unknowable pre-boot.
  44. * @param bindHost - the effective webserver bind host (CLI flag, else the yml default).
  45. * @param extra - `--trusted-host` values, in argv order.
  46. * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
  47. */
  48. export function resolveLanTrust(
  49. bindHost: string | undefined,
  50. extra: readonly string[],
  51. ): { lanAddresses: string[]; trustedHosts: string[] } {
  52. const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
  53. return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
  54. }
  55. /** One profile-json key mapped onto a yml row's config field. */
  56. interface ProfileMapping {
  57. jsonPath: string
  58. entryId: string
  59. configKey: string
  60. }
  61. /**
  62. * The static profile→row mapping table. json is user config and wins over the
  63. * yml engineering default per field; a json key absent from this table fails
  64. * loud (a typo silently ignored would read as "setting has no effect").
  65. * Developers extend deployments by adding rows here.
  66. */
  67. const PROFILE_MAPPINGS: ProfileMapping[] = [
  68. { jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' },
  69. { jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' },
  70. { jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' },
  71. ]
  72. // The include's YAML dialect: `!!js` scalars become expression nodes the
  73. // Loader evaluates at entry activation. The bypass parse below must accept
  74. // them (and passing one through a patch unchanged is legal).
  75. const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
  76. kind: 'scalar',
  77. resolve: data => typeof data === 'string',
  78. construct: data => ({ __jsExpr: String(data) }),
  79. })
  80. const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
  81. /** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
  82. export interface AppCLIEntryOptions {
  83. /** Absolute path of the shared base config the Loader includes. */
  84. configPath: string
  85. /**
  86. * Absolute path of this surface's overlay: a patch list applied over
  87. * {@link configPath} before this entry's own profile/flag patches. Its rows
  88. * are also merge inputs, so a flag override preserves the overlay's other
  89. * fields on the same row.
  90. */
  91. overlayPath: string
  92. /**
  93. * Optional explicit overlay applied after {@link overlayPath} and before
  94. * this entry's own profile/flag patches. When absent, the personal
  95. * `$DSH_HOME/config.yaml` overlay is applied instead.
  96. */
  97. extraOverlayPath?: string
  98. /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
  99. dev: boolean
  100. /** --host when explicitly passed; undefined keeps the yml engineering default. */
  101. host?: string
  102. /**
  103. * Listen port override onto the webserver row. Web passes the --port flag
  104. * value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs
  105. * never collide — and the printed URL still opens the live session in a
  106. * browser).
  107. */
  108. port?: number
  109. /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
  110. workspaceRoot?: string
  111. /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
  112. trustedHosts?: string[]
  113. }
  114. /**
  115. * Boot driver for the config-tree dsh surfaces (web and headless share the
  116. * one composition; the surfaces differ only in constructor facts): holds only
  117. * what exists independently of (and prior to) cordis — argv facts, the
  118. * composed patch set, and finally the root ctx.
  119. */
  120. export class AppCLIEntry {
  121. /** The root context, set by {@link run}. */
  122. ctx!: Context
  123. /**
  124. * LAN IPv4 addresses sampled once at patch composition — the exact snapshot
  125. * the /api trust fence was configured with. Display reads this instead of
  126. * re-sampling, so the advertised LAN URL can never name an address the
  127. * fence rejects. Empty unless the effective bind is all-interfaces.
  128. */
  129. lanAddresses: readonly string[] = []
  130. private patches: PatchOptions[] = []
  131. constructor(private readonly options: AppCLIEntryOptions) {}
  132. /**
  133. * Run the boot chain: layered env → patch composition → Loader include
  134. * boot (dev row before await) → fail-loud triple.
  135. * @returns the settled root context and the listening port.
  136. */
  137. async run(): Promise<{ ctx: Context; port: number }> {
  138. this.loadEnvLayers()
  139. this.composePatches()
  140. await this.bootTree()
  141. this.assertBoot()
  142. const port = this.ctx.get('httpServer')?.port
  143. /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
  144. if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
  145. return { ctx: this.ctx, port }
  146. }
  147. /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
  148. private loadEnvLayers(): void {
  149. loadEnv('dsh', resolveDshHome())
  150. }
  151. /**
  152. * Compose the patch set from profile json, CLI flags, and the resolved
  153. * frontend dist. Patches replace a row's config wholesale, so each patched row's yml
  154. * static values are re-read here (bypass parse) and merged under the overrides.
  155. */
  156. private composePatches(): void {
  157. const rows = this.parseYmlRows()
  158. const overrides = new Map<string, Record<string, unknown>>()
  159. const put = (entryId: string, key: string, value: unknown): void => {
  160. const bag = overrides.get(entryId) ?? {}
  161. bag[key] = value
  162. overrides.set(entryId, bag)
  163. }
  164. // Source 1: profile json (missing file = empty; unmapped key = loud).
  165. for (const [key, value] of Object.entries(this.readProfile())) {
  166. const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
  167. if (mapping === undefined) {
  168. throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
  169. }
  170. put(mapping.entryId, mapping.configKey, value)
  171. }
  172. // Source 2: CLI flags (field set disjoint from the json mappings).
  173. if (this.options.host !== undefined) put('webserver', 'host', this.options.host)
  174. if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
  175. if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
  176. // Source 2b: authorities for the /api browser-trust fence (rationale on
  177. // resolveLanTrust).
  178. const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
  179. const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
  180. this.lanAddresses = lanAddresses
  181. if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
  182. // Source 3: the frontend dist — an assembly fact of this app, never yml
  183. // user config. Workspace knowledge stays here.
  184. put('webserver', 'distIndex', this.resolveDistIndex())
  185. this.patches = [...overrides.entries()].map(([id, bag]) => {
  186. const yml = rows.get(id)
  187. if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
  188. return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
  189. })
  190. }
  191. /** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */
  192. private async bootTree(): Promise<void> {
  193. // One include of the shared base with every overlay as a sibling patch
  194. // list: patches never cross an include boundary, so nesting them would
  195. // silently stop reaching base rows. The surface overlay applies first, then
  196. // this entry's profile-json and CLI-flag patches, which therefore win.
  197. const patches = [
  198. ...loadOverlayPatches('dsh', this.options.overlayPath),
  199. ...this.options.extraOverlayPath === undefined
  200. ? loadPersonalPatches('dsh') ?? []
  201. : loadOverlayPatches('dsh', this.options.extraOverlayPath),
  202. ...this.patches,
  203. ]
  204. this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => {
  205. if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
  206. })
  207. }
  208. /** Install the diagnostic for plugin rejections that happen after settled boot. */
  209. private assertBoot(): void {
  210. installFailLoud('dsh')
  211. }
  212. /**
  213. * Bypass parse of the base and this surface's overlay (id → row) for
  214. * patch-merge inputs; the Loader still reads both files itself. The overlay
  215. * wins per row, matching the order its patches are applied in, and its
  216. * `insert` rows are indexed too because a flag may target one of them.
  217. */
  218. private parseYmlRows(): Map<string, { config?: unknown }> {
  219. const rows = new Map<string, { config?: unknown }>()
  220. const files = [this.options.configPath, this.options.overlayPath]
  221. if (this.options.extraOverlayPath !== undefined) files.push(this.options.extraOverlayPath)
  222. for (const file of files) {
  223. for (const row of this.parseRowList(file)) {
  224. if (typeof row.id === 'string') rows.set(row.id, row)
  225. for (const inserted of row.insert ?? []) {
  226. if (typeof inserted.id === 'string') rows.set(inserted.id, inserted)
  227. }
  228. }
  229. }
  230. return rows
  231. }
  232. /**
  233. * Parse one entry or patch list, rejecting anything that is not a top-level
  234. * array so a malformed file fails here rather than at row lookup.
  235. * @param file - absolute path of the config or overlay file.
  236. * @returns the parsed top-level entries.
  237. */
  238. private parseRowList(file: string): { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] {
  239. const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
  240. if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
  241. return doc as { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[]
  242. }
  243. /** Profile json under cwd; read-only — never created here, absent = no user config. */
  244. private readProfile(): Record<string, unknown> {
  245. let raw: string
  246. try {
  247. raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8')
  248. } catch (error) {
  249. if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
  250. throw error
  251. }
  252. const parsed: unknown = JSON.parse(raw)
  253. if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
  254. throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
  255. }
  256. return parsed as Record<string, unknown>
  257. }
  258. /** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */
  259. private resolveDistIndex(): string {
  260. const require = createRequire(import.meta.url)
  261. try {
  262. return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
  263. } catch {
  264. throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
  265. }
  266. }
  267. }