profile-boot.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. /**
  2. * Shared profile boot for every `dsh` surface: resolve the profile, stack its
  3. * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's
  4. * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the
  5. * tree over the profile's empty root config, apply its selected patch-reload
  6. * lifecycle, and wire fail-loud plus bounded shutdown.
  7. *
  8. * App flags are not the launcher's business: the invocation's inner arguments
  9. * are provided to the tree through `ctx.cmdlineArgs`, where any injected app
  10. * plugin may read the same immutable snapshot.
  11. * @module @deepseek-ai/dsh/profile-boot
  12. */
  13. import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
  14. import { dirname, join, resolve } from 'node:path'
  15. import { fileURLToPath } from 'node:url'
  16. import { FiberState, type Context } from '@deepseek-ai/cordis'
  17. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  18. import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
  19. import {
  20. boot,
  21. type ProfileResolutionMode,
  22. type ProfileResolutionGeneration,
  23. PluginPackages,
  24. createProfileResolutionGeneration,
  25. composeEntries,
  26. healProfilesModuleFallback,
  27. healIsolatedProfileModuleFallback,
  28. initProfile,
  29. installFailLoud,
  30. loadOptionalPatches,
  31. loadOverlayPatches,
  32. loadProfile,
  33. PROFILE_PATCH_FILENAME,
  34. PROFILE_TEMPLATES,
  35. resolveProfileDir,
  36. watchUserPatches,
  37. type Profile,
  38. } from '@deepseek-ai/dsh-app-boot'
  39. import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
  40. import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
  41. import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
  42. import { provideCmdline, type AppReady } from '@deepseek-ai/dsh-cmdline'
  43. import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
  44. const NAME = 'dsh'
  45. /** Launcher-owned readiness signal committed only after boot and host setup succeed. */
  46. function createAppReady(): { service: AppReady; commit(): void } {
  47. let ready = false
  48. const listeners = new Set<() => void>()
  49. return {
  50. service: {
  51. onReady(listener) {
  52. if (ready) {
  53. listener()
  54. return () => {}
  55. }
  56. listeners.add(listener)
  57. return () => { listeners.delete(listener) }
  58. },
  59. },
  60. commit() {
  61. if (ready) return
  62. ready = true
  63. for (const listener of [...listeners]) listener()
  64. listeners.clear()
  65. },
  66. }
  67. }
  68. /**
  69. * The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied
  70. * over every profile's own layer. Resolved per call, not at module load:
  71. * `$DSH_HOME` may be set by the test or launcher after import.
  72. * @returns the absolute patch-file path.
  73. */
  74. export function homePatchPath(): string {
  75. return join(resolveDshHome(), PROFILE_PATCH_FILENAME)
  76. }
  77. /** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */
  78. export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url))
  79. /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
  80. const TELEMETRY_ROW_ID = 'session-telemetry-otel'
  81. /** The empty root entry list every profile tree patches over. */
  82. const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
  83. # each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
  84. # --patch overlays. Edit cordis.patch.yml, not this file.
  85. []
  86. `
  87. /** Root config filename inside a profile directory. */
  88. export const PROFILE_ROOT_FILENAME = 'cordis.yml'
  89. /**
  90. * Initialize a missing profile from one shipped template. This copies only
  91. * the template's bundle list and patch-reload policy; local state from the
  92. * same-named shipped profile is not read, and no inheritance metadata is
  93. * persisted. Shipped profile names are reserved, and the target directory is
  94. * claimed exclusively so existing or concurrent state is never reused.
  95. * @param name - the new profile name.
  96. * @param fromDefaultProfile - shipped profile template to copy.
  97. * @param home - Harness home containing the profile directory.
  98. * @throws when the template is unknown, the target name is shipped, or the target directory exists.
  99. */
  100. export function initializeProfileFromDefault(
  101. name: string,
  102. fromDefaultProfile: string,
  103. home: string = resolveDshHome(),
  104. ): void {
  105. const dir = resolveProfileDir(name, home)
  106. const template = Object.hasOwn(PROFILE_TEMPLATES, fromDefaultProfile)
  107. ? PROFILE_TEMPLATES[fromDefaultProfile]
  108. : undefined
  109. if (template === undefined) {
  110. const expected = Object.keys(PROFILE_TEMPLATES).sort().map(value => JSON.stringify(value)).join(', ')
  111. throw new Error(
  112. `${NAME}: unknown default profile ${JSON.stringify(fromDefaultProfile)}; expected one of ${expected}`,
  113. )
  114. }
  115. if (Object.hasOwn(PROFILE_TEMPLATES, name)) {
  116. throw new Error(
  117. `${NAME}: profile ${JSON.stringify(name)} is shipped and cannot be a custom profile target; `
  118. + 'omit --from-default-profile to use it',
  119. )
  120. }
  121. mkdirSync(dirname(dir), { recursive: true })
  122. try {
  123. mkdirSync(dir)
  124. } catch (error) {
  125. if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
  126. const manifestPath = join(dir, 'package.json')
  127. if (existsSync(manifestPath)) {
  128. throw new Error(
  129. `${NAME}: profile ${JSON.stringify(name)} already exists at ${manifestPath}; `
  130. + 'omit --from-default-profile to use it',
  131. )
  132. }
  133. throw new Error(
  134. `${NAME}: profile directory ${dir} already exists; choose an unused profile name`,
  135. )
  136. }
  137. try {
  138. initProfile(dir, template.bundles, template.patchReload)
  139. } catch (error) {
  140. try {
  141. rmSync(dir, { recursive: true, force: true })
  142. } catch (cleanupError) {
  143. throw new AggregateError(
  144. [error, cleanupError],
  145. `${NAME}: profile initialization failed and ${dir} could not be removed`,
  146. )
  147. }
  148. throw error
  149. }
  150. }
  151. /**
  152. * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
  153. * value (including `'0'`/`'false'`) disables: a privacy switch prefers
  154. * off-by-mistake over on-by-mistake. A composition without the telemetry row
  155. * exports nothing, so the switch is then trivially satisfied and no patch is
  156. * generated — custom profiles need not mount telemetry to run with the
  157. * switch set.
  158. * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
  159. * @param hasRow - whether the composition carries the telemetry row.
  160. * @returns the disable patch, or `undefined` when no hard-disable patch is required.
  161. */
  162. export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
  163. if ((disabledEnv ?? '') === '' || !hasRow) return undefined
  164. return { id: TELEMETRY_ROW_ID, disabled: true }
  165. }
  166. /**
  167. * Load a resolved profile for `name` and (re)write the empty root config. The
  168. * root is always rewritten: the whole composition is patch layers, and the
  169. * vendored Loader's tree write-back (a plugin self-disposing persists the
  170. * current tree) can bake composed rows into this file — which would duplicate
  171. * every bundle insert on the next boot. The file exists on disk only because
  172. * the Loader needs a real include root to anchor `baseUrl` at the profile
  173. * directory (the config dump anchors on the same file, so both compose over
  174. * the identical base).
  175. * @param name - the profile name.
  176. * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
  177. * @param fromDefaultProfile - shipped template used once to initialize a missing profile.
  178. * @returns the loaded profile.
  179. * @throws when explicit initialization names an unknown template or an existing profile.
  180. */
  181. export function prepareProfile(name: string, userLayer = true, fromDefaultProfile?: string): Profile {
  182. if (fromDefaultProfile !== undefined) initializeProfileFromDefault(name, fromDefaultProfile)
  183. const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
  184. writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
  185. return profile
  186. }
  187. /** One profile's patch layers, in application order. */
  188. interface ComposedProfile {
  189. resolution: ProfileResolutionGeneration
  190. profile: Profile
  191. /** Bundle layers concatenated — the part below the user layers on a live reload. */
  192. bundlePatches: PatchOptions[]
  193. /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
  194. homePatches: PatchOptions[]
  195. /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
  196. overlays: PatchOptions[]
  197. }
  198. /** The full patch stack of one composed profile, in application order. */
  199. function allPatches(composed: ComposedProfile): PatchOptions[] {
  200. return [
  201. ...composed.bundlePatches,
  202. ...composed.profile.patches,
  203. ...composed.homePatches,
  204. ...composed.overlays,
  205. ]
  206. }
  207. /**
  208. * Load `name` and compose its effective patch stack: bundle layers in
  209. * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's
  210. * platform-gated shell rows), the profile's user layer, the home-level user
  211. * layer (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply
  212. * to every profile, so it outranks the per-profile layer), `--patch` overlays,
  213. * then the telemetry switch.
  214. * @param name - the profile name.
  215. * @param patchFiles - `--patch` overlay paths, in argv order.
  216. * @param fromDefaultProfile - shipped template for a missing named profile.
  217. * @param resolvedProfile - application-owned profile and installation; bypasses named discovery.
  218. * @returns the profile and its patch layers.
  219. */
  220. async function composeProfile(
  221. name: string,
  222. patchFiles: readonly string[],
  223. resolutionMode: ProfileResolutionMode,
  224. fromDefaultProfile?: string,
  225. resolvedProfile?: ResolvedProfileRuntime,
  226. ): Promise<ComposedProfile> {
  227. const profile = resolvedProfile?.profile ?? prepareProfile(name, true, fromDefaultProfile)
  228. if (resolvedProfile !== undefined) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
  229. const resolutionOptions = { installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR, profile }
  230. if (resolvedProfile !== undefined && resolutionMode !== 'runtime') healIsolatedProfileModuleFallback(resolvedProfile)
  231. const resolution = resolutionMode === 'runtime' || resolvedProfile !== undefined
  232. ? await createProfileResolutionGeneration(resolutionOptions)
  233. : await healProfilesModuleFallback(resolutionOptions)
  234. const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
  235. const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
  236. const bundlePatches = profile.layers.flatMap(layer => layer.patches)
  237. const rows = new Map<string, EntryOptions>()
  238. for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
  239. if (typeof row.id === 'string') rows.set(row.id, row)
  240. }
  241. const composedOverlays = [...overlays]
  242. const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
  243. if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
  244. return { profile, resolution, bundlePatches, homePatches, overlays: composedOverlays }
  245. }
  246. /** An application-owned profile and its independent installation fallback. */
  247. export interface ResolvedProfileRuntime {
  248. /** Profile already loaded from the application's own directory. */
  249. profile: Profile
  250. /** Absolute package.json path of the application's dsh installation. */
  251. installAnchor: string
  252. }
  253. /** Options for {@link runProfile}. */
  254. export interface RunProfileOptions {
  255. /** Package lookup strategy; packaged executables always use runtime resolution. */
  256. resolutionMode?: ProfileResolutionMode
  257. /** This run's frozen environment snapshot, provided before any entry mounts. */
  258. environment: LaunchEnvironmentSnapshot
  259. /** The profile name to boot. */
  260. profile: string
  261. /** Loaded application profile; bypasses named profile initialization when supplied. */
  262. resolvedProfile?: ResolvedProfileRuntime | undefined
  263. /** Shipped template used once to initialize a missing profile. */
  264. fromDefaultProfile?: string | undefined
  265. /** `--patch` overlay paths, in argv order. */
  266. patchFiles: readonly string[]
  267. /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
  268. args: readonly string[]
  269. }
  270. /**
  271. * Re-throw a watcher-setup failure unless a shutdown already owns the tree:
  272. * a signal aborted this invocation, or an app requested exit (`ctx.appExit`
  273. * from a fast one-shot) and the root's disposal rejected the in-flight setup
  274. * await. Either way the failure describes a tree that is exiting as asked,
  275. * not a broken watch.
  276. * @param ctx - the booted root context.
  277. * @param signal - this invocation's signal-shutdown fact.
  278. * @param error - the setup failure.
  279. */
  280. function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown): void {
  281. if (signal.aborted) return
  282. if (ctx.fiber.state !== FiberState.ACTIVE || ctx.get('loader') === undefined) return
  283. throw error
  284. }
  285. /**
  286. * Boot one profile invocation end to end and leave process lifetime to the
  287. * mounted plugins (or to a one-shot runner the composition mounts).
  288. * @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
  289. * @returns the settled root context and the shutdown controller.
  290. * @throws after disposing startup resources; a cleanup failure retains both errors in an AggregateError.
  291. */
  292. export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
  293. // Before the first plugin mounts and before anything can issue a request: Node's fetch ignores the
  294. // proxy environment on its own, so every profile would otherwise connect directly. Resolving from
  295. // the launcher's snapshot — not `process.env` — is what lets a proxy declared in a `.env` layer
  296. // work, which the NODE_USE_ENV_PROXY flag cannot do because Node samples the environment at start.
  297. const disposeProxy = await installProxyFromEnvironment(
  298. options.environment,
  299. (message) => { process.stderr.write(`${NAME}: ${message}\n`) },
  300. )
  301. const packaged = (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined
  302. const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'link'
  303. const app: { current?: Context } = {}
  304. let disposal: Promise<void> | undefined
  305. const dispose = (): Promise<void> => disposal ??= (async () => {
  306. const failures: unknown[] = []
  307. for (const release of [() => app.current?.fiber.dispose(), disposeProxy]) {
  308. try { await release() } catch (error) { failures.push(error) }
  309. }
  310. if (failures.length === 1) throw failures[0]
  311. if (failures.length > 1) throw new AggregateError(failures, 'dsh: profile cleanup failed')
  312. })()
  313. try {
  314. const composed = await composeProfile(
  315. options.profile, options.patchFiles, resolutionMode, options.fromDefaultProfile, options.resolvedProfile,
  316. )
  317. const appReady = createAppReady()
  318. const shutdown = createProcessShutdown(dispose)
  319. const signalShutdown = new AbortController()
  320. const interrupt = (code: number): void => {
  321. signalShutdown.abort()
  322. shutdown.interrupt(code)
  323. }
  324. // Signals own teardown throughout the startup window, not only after boot()
  325. // settles: an inserted provider can publish before sibling rows finish mounting.
  326. // SIGTERM is a supervisor's ordinary stop request and exits 0 on every
  327. // surface — the launcher does not know whether the app considered its work
  328. // complete; SIGINT is a user interrupt and reports 130.
  329. process.on('SIGTERM', () => { interrupt(0) })
  330. process.on('SIGINT', () => { interrupt(130) })
  331. installFailLoud(NAME, process, async () => {
  332. await app.current?.fiber.dispose()
  333. })
  334. const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
  335. // Recomposition for the live user layers: bundle layers below, overlays
  336. // above, so a user edit can never displace them. Parsed app arguments are
  337. // not in here at all — they live in app-provided services that survive a
  338. // recomposition. BOTH
  339. // user files are re-read per generation (the HMR watcher hands us only the
  340. // changed file's patches, which one of the reads duplicates — fresh reads
  341. // keep the two watchers from stitching in each other's stale copy).
  342. // Fresh clones per generation: the include pushes `insert` rows into the
  343. // mounted tree BY REFERENCE and later id-targeted patches mutate those
  344. // objects in place. Reusing one parsed patch object across applications
  345. // would bake a user override into the bundle's in-memory insert row, so
  346. // removing the override could never revert the row to the bundle default.
  347. const composeLive = (): PatchOptions[] => structuredClone([
  348. ...composed.bundlePatches,
  349. ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
  350. ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
  351. ...composed.overlays,
  352. ])
  353. // Cloned for the same insert-aliasing reason as composeLive: the boot
  354. // application must not mutate the objects later reloads recompose from.
  355. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
  356. app.current = hostCtx
  357. // Before any config-tree entry mounts, so plugins resolve all launch-time
  358. // environment values from the same immutable launch snapshot.
  359. hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
  360. await hostCtx.plugin(PluginPackages, resolutionMode === 'link' ? {} : {
  361. generation: composed.resolution,
  362. behavior: resolutionMode === 'dual' ? 'verify' : 'enforce',
  363. })
  364. // The command line and bounded exit request are launcher facts available
  365. // to every app plugin that injects the argument snapshot.
  366. provideCmdline(hostCtx, {
  367. args: options.args,
  368. exit: code => void shutdown.shutdown(code),
  369. ready: appReady.service,
  370. })
  371. })
  372. app.current = ctx
  373. // A live-reload profile can dispose the whole tree while post-boot watcher
  374. // setup is in flight — a signal or appExit. Loader presence and fiber state
  375. // own liveness; the initial check skips a tree that already exited, and the
  376. // catch below re-checks for an exit that landed mid-setup. Startup-frozen
  377. // profiles apply every user layer above but install no HMR fallback or watcher.
  378. if (composed.profile.patchReload === 'live'
  379. && !signalShutdown.signal.aborted
  380. && ctx.fiber.state === FiberState.ACTIVE
  381. && ctx.get('loader') !== undefined) {
  382. try {
  383. // Config-only HMR for the live profile patch layer: dsh-base disables
  384. // module reload by default, so when no profile explicitly enabled that
  385. // service, mount a watch-only instance with no module roots —
  386. // cordis.patch.yml edits stay live without replacing source modules. A
  387. // silent skip would break the documented reload contract. HMR injects
  388. // the timer service, which a bare custom profile may not mount either.
  389. if (ctx.get('hmr') === undefined) {
  390. if (ctx.get('timer') === undefined) {
  391. await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
  392. }
  393. await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
  394. await ctx.loader.await()
  395. }
  396. await watchUserPatches(ctx, {
  397. binName: NAME,
  398. filename: composed.profile.patchPath,
  399. compose: composeLive,
  400. })
  401. await watchUserPatches(ctx, {
  402. binName: NAME,
  403. filename: homePatchPath(),
  404. compose: composeLive,
  405. })
  406. } catch (error) {
  407. suppressShutdownError(ctx, signalShutdown.signal, error)
  408. }
  409. }
  410. if (!signalShutdown.signal.aborted
  411. && ctx.fiber.state === FiberState.ACTIVE
  412. && ctx.get('loader') !== undefined) {
  413. appReady.commit()
  414. }
  415. return { ctx, shutdown }
  416. } catch (error) {
  417. try { await dispose() } catch (cleanupError) {
  418. throw new AggregateError([error, cleanupError], 'dsh: profile startup and cleanup failed')
  419. }
  420. throw error
  421. }
  422. }