app-cli-entry.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. /**
  2. * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
  3. * (`dsh web` and `dsh -p` boot the one composition; TUI migrates later).
  4. * Everything here is what must exist before the Loader runs: layered env,
  5. * the patch composition over the shipped cordis.yml (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 { pathToFileURL } from 'node:url'
  14. import { Context } from 'cordis'
  15. import type { FiberState } from 'cordis'
  16. import Loader from '@cordisjs/plugin-loader'
  17. import Include, { type PatchOptions } from '@cordisjs/plugin-include'
  18. import yaml from 'js-yaml'
  19. import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot'
  20. import { resolveDshHome } from '@deepseek-ai/dsh-paths'
  21. // Empty type import carries the httpServer Context merge for the port read below.
  22. import type {} from '@deepseek-ai/dsh-host-webserver'
  23. /** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */
  24. const PROFILE_DIR = '.dsh-tmp-profile'
  25. const PROFILE_FILE = 'config.json'
  26. /** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
  27. export const ALL_INTERFACES_HOST = '0.0.0.0'
  28. /**
  29. * Non-internal IPv4 interface addresses of this machine — the IP-literal
  30. * authorities an all-interfaces bind is reachable by on the LAN.
  31. * @returns the addresses in interface order (possibly empty).
  32. */
  33. export function lanIPv4Addresses(): string[] {
  34. return Object.values(networkInterfaces()).flat()
  35. .filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
  36. .map(iface => iface.address)
  37. }
  38. /**
  39. * Authorities the /api browser-trust fence must accept for one invocation:
  40. * the machine's LAN IP literals when the effective bind is all-interfaces
  41. * (advertised by the printed LAN URL, so they must not answer 403), followed
  42. * by the explicit extras. Derived entries are port-less IP literals — DNS
  43. * rebinding needs an attacker-controlled name, so an IP-literal Host is safe
  44. * on any port, and the bound port may be OS-assigned, unknowable pre-boot.
  45. * @param bindHost - the effective webserver bind host (CLI flag, else the yml default).
  46. * @param extra - `--trusted-host` values, in argv order.
  47. * @returns the connection row's `trustedHosts` value (possibly empty).
  48. */
  49. export function resolveTrustedHosts(bindHost: string | undefined, extra: readonly string[]): string[] {
  50. return [
  51. ...bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [],
  52. ...extra,
  53. ]
  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. /**
  82. * Value mirror of cordis's `FiberState` const enum members the sweep needs
  83. * (a const enum has no runtime object to import; same rationale as the
  84. * client-side mirror in dsh-client-web).
  85. */
  86. const FIBER_ACTIVE = 2 as FiberState.ACTIVE
  87. const FIBER_PENDING = 0 as FiberState.PENDING
  88. /** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
  89. export interface AppCLIEntryOptions {
  90. /** Absolute path of the shipped cordis.yml. */
  91. configPath: string
  92. /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
  93. dev: boolean
  94. /** --host when explicitly passed; undefined keeps the yml engineering default. */
  95. host?: string
  96. /**
  97. * Listen port override onto the webserver row. Web passes the --port flag
  98. * value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs
  99. * never collide — and the printed URL still opens the live session in a
  100. * browser).
  101. */
  102. port?: number
  103. /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
  104. workspaceRoot?: string
  105. /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
  106. trustedHosts?: string[]
  107. }
  108. /**
  109. * Boot driver for the config-tree dsh surfaces (web and headless share the
  110. * one composition; the surfaces differ only in constructor facts): holds only
  111. * what exists independently of (and prior to) cordis — argv facts, the
  112. * composed patch set, and finally the root ctx.
  113. */
  114. export class AppCLIEntry {
  115. /** The root context, set by {@link run}. */
  116. ctx!: Context
  117. private patches: PatchOptions[] = []
  118. constructor(private readonly options: AppCLIEntryOptions) {}
  119. /**
  120. * Run the boot chain: layered env → patch composition → Loader include
  121. * boot (dev row before await) → fail-loud triple.
  122. * @returns the settled root context and the listening port.
  123. */
  124. async run(): Promise<{ ctx: Context; port: number }> {
  125. this.loadEnvLayers()
  126. this.composePatches()
  127. await this.bootTree()
  128. this.assertBoot()
  129. const port = this.ctx.get('httpServer')?.port
  130. /* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
  131. if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
  132. return { ctx: this.ctx, port }
  133. }
  134. /** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
  135. private loadEnvLayers(): void {
  136. loadEnv('dsh', resolveDshHome())
  137. }
  138. /**
  139. * Compose the patch set from the non-yml config sources: computed
  140. * engineering defaults (the global session root), profile json (user
  141. * config, overriding those defaults), CLI flags, and the resolved frontend
  142. * dist. Patches replace a row's config wholesale, so each patched row's yml
  143. * static values are re-read here (bypass parse) and merged under the overrides.
  144. */
  145. private composePatches(): void {
  146. const rows = this.parseYmlRows()
  147. const overrides = new Map<string, Record<string, unknown>>()
  148. const put = (entryId: string, key: string, value: unknown): void => {
  149. const bag = overrides.get(entryId) ?? {}
  150. bag[key] = value
  151. overrides.set(entryId, bag)
  152. }
  153. // Source 0: computed engineering defaults. The session store defaults to
  154. // a global dir under the Harness home ($DSH_HOME, else ~/.dsh) so history
  155. // is shared across every cwd, not a project-local ./.sessions. The profile
  156. // (Source 1) overwrites this same field via last-write-wins in put().
  157. put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions'))
  158. // Source 1: profile json (missing file = empty; unmapped key = loud).
  159. for (const [key, value] of Object.entries(this.readProfile())) {
  160. const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
  161. if (mapping === undefined) {
  162. throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
  163. }
  164. put(mapping.entryId, mapping.configKey, value)
  165. }
  166. // Source 2: CLI flags (field set disjoint from the json mappings).
  167. if (this.options.host !== undefined) put('webserver', 'host', this.options.host)
  168. if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
  169. if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
  170. // Source 2b: authorities for the /api browser-trust fence (rationale on
  171. // resolveTrustedHosts).
  172. const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
  173. const trustedHosts = resolveTrustedHosts(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
  174. if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
  175. // Source 3: the frontend dist — an assembly fact of this app, never yml
  176. // user config. Workspace knowledge stays here.
  177. put('webserver', 'distIndex', this.resolveDistIndex())
  178. this.patches = [...overrides.entries()].map(([id, bag]) => {
  179. const yml = rows.get(id)
  180. if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
  181. return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
  182. })
  183. }
  184. /** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */
  185. private async bootTree(): Promise<void> {
  186. const ctx = new Context()
  187. ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/'
  188. await ctx.plugin(Loader)
  189. ctx.loader.builtins.include = Include
  190. await ctx.loader.create({
  191. name: 'cordis:include',
  192. config: {
  193. path: pathToFileURL(resolve(this.options.configPath)).href,
  194. ...this.patches.length > 0 ? { patches: this.patches } : {},
  195. },
  196. })
  197. if (this.options.dev) {
  198. await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
  199. }
  200. this.ctx = ctx
  201. await ctx.loader.await()
  202. }
  203. /**
  204. * Fail-loud triple: assertEntriesLoaded catches import failures,
  205. * installFailLoud catches late apply rejections, and the all-ACTIVE sweep
  206. * below catches PENDING fibers (cordis inject waiting has no timeout).
  207. */
  208. private assertBoot(): void {
  209. installFailLoud('dsh')
  210. assertEntriesLoaded(this.ctx, 'dsh')
  211. const failures: string[] = []
  212. for (const entry of this.ctx.loader.entries()) {
  213. if (entry.fiber === undefined || entry.disabled) continue
  214. const state = entry.fiber.state
  215. if (state === FIBER_ACTIVE) continue
  216. if (state === FIBER_PENDING) {
  217. const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined)
  218. failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
  219. } else {
  220. failures.push(`${entry.options.name}: fiber state ${String(state)}`)
  221. }
  222. }
  223. if (failures.length > 0) {
  224. throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
  225. }
  226. }
  227. /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
  228. private parseYmlRows(): Map<string, { config?: unknown }> {
  229. const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
  230. if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`)
  231. const rows = new Map<string, { config?: unknown }>()
  232. for (const row of doc as { id?: string; config?: unknown }[]) {
  233. if (typeof row.id === 'string') rows.set(row.id, row)
  234. }
  235. return rows
  236. }
  237. /** Profile json under cwd; read-only — never created here, absent = no user config. */
  238. private readProfile(): Record<string, unknown> {
  239. let raw: string
  240. try {
  241. raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8')
  242. } catch (error) {
  243. if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
  244. throw error
  245. }
  246. const parsed: unknown = JSON.parse(raw)
  247. if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
  248. throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
  249. }
  250. return parsed as Record<string, unknown>
  251. }
  252. /** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */
  253. private resolveDistIndex(): string {
  254. const require = createRequire(import.meta.url)
  255. try {
  256. return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
  257. } catch {
  258. throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
  259. }
  260. }
  261. }