profile-boot.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  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, and wire fail-loud plus bounded shutdown.
  6. *
  7. * App flags are not the launcher's business: the invocation's inner arguments
  8. * are provided to the tree through `ctx.cmdlineArgs`, where any injected app
  9. * plugin may read the same immutable snapshot.
  10. * @module @deepseek-ai/dsh/profile-boot
  11. */
  12. import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
  13. import { dirname, join, resolve } from 'node:path'
  14. import { fileURLToPath } from 'node:url'
  15. import { FiberState, type Context } from '@deepseek-ai/cordis'
  16. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  17. import {
  18. boot,
  19. readProfilePatches,
  20. createProfileResolutionGeneration,
  21. healProfilesModuleFallback,
  22. healIsolatedProfileModuleFallback,
  23. initProfile,
  24. installFailLoud,
  25. loadOverlayPatches,
  26. loadProfile,
  27. PluginPackages,
  28. PROFILE_PATCH_FILENAME,
  29. PROFILE_TEMPLATES,
  30. resolveProfileDir,
  31. type ProfileContext,
  32. type Profile,
  33. type ProfileResolutionGeneration,
  34. type ProfileResolutionMode,
  35. } from '@deepseek-ai/dsh-app-boot'
  36. import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
  37. import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
  38. import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
  39. import { provideCmdline, type AppReady } from '@deepseek-ai/dsh-cmdline'
  40. import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
  41. const NAME = 'dsh'
  42. /** Launcher-owned readiness signal committed only after boot and host setup succeed. */
  43. function createAppReady(): { service: AppReady; commit(): void } {
  44. let ready = false
  45. const listeners = new Set<() => void>()
  46. return {
  47. service: {
  48. onReady(listener) {
  49. if (ready) {
  50. listener()
  51. return () => {}
  52. }
  53. listeners.add(listener)
  54. return () => { listeners.delete(listener) }
  55. },
  56. },
  57. commit() {
  58. if (ready) return
  59. ready = true
  60. for (const listener of [...listeners]) listener()
  61. listeners.clear()
  62. },
  63. }
  64. }
  65. /**
  66. * The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied
  67. * over every profile's own layer. Resolved per call, not at module load:
  68. * `$DSH_HOME` may be set by the test or launcher after import.
  69. * @returns the absolute patch-file path.
  70. */
  71. export function homePatchPath(): string {
  72. return join(resolveDshHome(), PROFILE_PATCH_FILENAME)
  73. }
  74. /** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */
  75. export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url))
  76. /** The empty root entry list every profile tree patches over. */
  77. const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
  78. # each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
  79. # --patch overlays. Edit cordis.patch.yml, not this file.
  80. []
  81. `
  82. /** Root config filename inside a profile directory. */
  83. export const PROFILE_ROOT_FILENAME = 'cordis.yml'
  84. /**
  85. * Initialize a missing profile from one shipped template. This copies only
  86. * the template's bundle list; local state from the
  87. * same-named shipped profile is not read, and no inheritance metadata is
  88. * persisted. Shipped profile names are reserved, and the target directory is
  89. * claimed exclusively so existing or concurrent state is never reused.
  90. * @param name - the new profile name.
  91. * @param fromDefaultProfile - shipped profile template to copy.
  92. * @param home - Harness home containing the profile directory.
  93. * @throws when the template is unknown, the target name is shipped, or the target directory exists.
  94. */
  95. export function initializeProfileFromDefault(
  96. name: string,
  97. fromDefaultProfile: string,
  98. home: string = resolveDshHome(),
  99. ): void {
  100. const dir = resolveProfileDir(name, home)
  101. const template = Object.hasOwn(PROFILE_TEMPLATES, fromDefaultProfile)
  102. ? PROFILE_TEMPLATES[fromDefaultProfile]
  103. : undefined
  104. if (template === undefined) {
  105. const expected = Object.keys(PROFILE_TEMPLATES).sort().map(value => JSON.stringify(value)).join(', ')
  106. throw new Error(
  107. `${NAME}: unknown default profile ${JSON.stringify(fromDefaultProfile)}; expected one of ${expected}`,
  108. )
  109. }
  110. if (Object.hasOwn(PROFILE_TEMPLATES, name)) {
  111. throw new Error(
  112. `${NAME}: profile ${JSON.stringify(name)} is shipped and cannot be a custom profile target; `
  113. + 'omit --from-default-profile to use it',
  114. )
  115. }
  116. mkdirSync(dirname(dir), { recursive: true })
  117. try {
  118. mkdirSync(dir)
  119. } catch (error) {
  120. if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
  121. const manifestPath = join(dir, 'package.json')
  122. if (existsSync(manifestPath)) {
  123. throw new Error(
  124. `${NAME}: profile ${JSON.stringify(name)} already exists at ${manifestPath}; `
  125. + 'omit --from-default-profile to use it',
  126. )
  127. }
  128. throw new Error(
  129. `${NAME}: profile directory ${dir} already exists; choose an unused profile name`,
  130. )
  131. }
  132. try {
  133. initProfile(dir, template.bundles)
  134. } catch (error) {
  135. try {
  136. rmSync(dir, { recursive: true, force: true })
  137. } catch (cleanupError) {
  138. throw new AggregateError(
  139. [error, cleanupError],
  140. `${NAME}: profile initialization failed and ${dir} could not be removed`,
  141. )
  142. }
  143. throw error
  144. }
  145. }
  146. /**
  147. * Load a resolved profile for `name` and (re)write the empty root config. The
  148. * root is always rewritten: the whole composition is patch layers, and the
  149. * vendored Loader's tree write-back (a plugin self-disposing persists the
  150. * current tree) can bake composed rows into this file — which would duplicate
  151. * every bundle insert on the next boot. The file exists on disk only because
  152. * the Loader needs a real include root to anchor `baseUrl` at the profile
  153. * directory (the config dump anchors on the same file, so both compose over
  154. * the identical base).
  155. * @param name - the profile name.
  156. * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
  157. * @param fromDefaultProfile - shipped template used once to initialize a missing profile.
  158. * @returns the loaded profile.
  159. * @throws when explicit initialization names an unknown template or an existing profile.
  160. */
  161. export function prepareProfile(name: string, userLayer = true, fromDefaultProfile?: string): Profile {
  162. if (fromDefaultProfile !== undefined) initializeProfileFromDefault(name, fromDefaultProfile)
  163. const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
  164. writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
  165. return profile
  166. }
  167. /** One profile's patch layers, in application order. */
  168. interface ComposedProfile {
  169. profile: Profile
  170. /** Immutable package fallback selected before any plugin imports. */
  171. resolution: ProfileResolutionGeneration
  172. /** Command-line overlay contents, frozen for this invocation. */
  173. overlays: PatchOptions[]
  174. }
  175. /**
  176. * Load `name` and compose its effective patch stack: bundle layers in
  177. * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's
  178. * platform-gated shell rows), the profile's user layer, the home-level user
  179. * layer (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply
  180. * to every profile, so it outranks the per-profile layer), `--patch` overlays,
  181. * then the telemetry switch.
  182. * @param name - the profile name.
  183. * @param patchFiles - `--patch` overlay paths, in argv order.
  184. * @param fromDefaultProfile - shipped template for a missing named profile.
  185. * @param resolvedProfile - application-owned profile and installation.
  186. * @returns the profile and its patch layers.
  187. */
  188. async function composeProfile(
  189. name: string,
  190. patchFiles: readonly string[],
  191. resolutionMode: ProfileResolutionMode,
  192. fromDefaultProfile?: string,
  193. resolvedProfile?: ResolvedProfileRuntime,
  194. ): Promise<ComposedProfile> {
  195. const profile = resolvedProfile?.profile ?? prepareProfile(name, true, fromDefaultProfile)
  196. if (resolvedProfile !== undefined) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
  197. const resolutionOptions = { installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR, profile }
  198. if (resolvedProfile !== undefined && resolutionMode !== 'runtime') healIsolatedProfileModuleFallback(resolvedProfile)
  199. const resolution = resolutionMode === 'runtime' || resolvedProfile !== undefined
  200. ? await createProfileResolutionGeneration(resolutionOptions)
  201. : await healProfilesModuleFallback(resolutionOptions)
  202. const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
  203. return { profile, resolution, overlays }
  204. }
  205. /** An application-owned profile and its independent installation fallback. */
  206. export interface ResolvedProfileRuntime {
  207. /** Profile already loaded from the application's own directory. */
  208. profile: Profile
  209. /** Absolute package.json path of the application's dsh installation. */
  210. installAnchor: string
  211. }
  212. /** Options for {@link runProfile}. */
  213. export interface RunProfileOptions {
  214. /** This run's frozen environment snapshot, provided before any entry mounts. */
  215. environment: LaunchEnvironmentSnapshot
  216. /** The profile name to boot. */
  217. profile: string
  218. /** Loaded application profile; bypasses named profile initialization when supplied. */
  219. resolvedProfile?: ResolvedProfileRuntime | undefined
  220. /** Shipped template used once to initialize a missing profile. */
  221. fromDefaultProfile?: string | undefined
  222. /** `--patch` overlay paths, in argv order. */
  223. patchFiles: readonly string[]
  224. /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
  225. args: readonly string[]
  226. /** Module fallback backend; pkg executables always use runtime resolution. */
  227. resolutionMode?: ProfileResolutionMode
  228. }
  229. /**
  230. * Boot one profile invocation end to end and leave process lifetime to the
  231. * mounted plugins (or to a one-shot runner the composition mounts).
  232. * @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
  233. * @returns the settled root context and the shutdown controller.
  234. * @throws after disposing startup resources; cleanup failures retain the original error.
  235. */
  236. export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
  237. // Before the first plugin mounts and before anything can issue a request: Node's fetch ignores the
  238. // proxy environment on its own, so every profile would otherwise connect directly. Resolving from
  239. // the launcher's snapshot — not `process.env` — is what lets a proxy declared in a `.env` layer
  240. // work, which the NODE_USE_ENV_PROXY flag cannot do because Node samples the environment at start.
  241. const disposeProxy = await installProxyFromEnvironment(
  242. options.environment,
  243. (message) => { process.stderr.write(`${NAME}: ${message}\n`) },
  244. )
  245. const packaged = (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined
  246. const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'link'
  247. const app: { current?: Context } = {}
  248. let disposal: Promise<void> | undefined
  249. const dispose = (): Promise<void> => disposal ??= (async () => {
  250. const failures: unknown[] = []
  251. for (const release of [() => app.current?.fiber.dispose(), disposeProxy]) {
  252. try { await release() } catch (error) { failures.push(error) }
  253. }
  254. if (failures.length === 1) throw failures[0]
  255. if (failures.length > 1) throw new AggregateError(failures, 'dsh: profile cleanup failed')
  256. })()
  257. try {
  258. const composed = await composeProfile(
  259. options.profile, options.patchFiles, resolutionMode, options.fromDefaultProfile, options.resolvedProfile,
  260. )
  261. const appReady = createAppReady()
  262. const shutdown = createProcessShutdown(dispose)
  263. const signalShutdown = new AbortController()
  264. const interrupt = (code: number): void => {
  265. signalShutdown.abort()
  266. shutdown.interrupt(code)
  267. }
  268. // Signals own teardown throughout the startup window, not only after boot()
  269. // settles: an inserted provider can publish before sibling rows finish mounting.
  270. // SIGTERM is a supervisor's ordinary stop request and exits 0 on every
  271. // surface — the launcher does not know whether the app considered its work
  272. // complete; SIGINT is a user interrupt and reports 130.
  273. process.on('SIGTERM', () => { interrupt(0) })
  274. process.on('SIGINT', () => { interrupt(130) })
  275. installFailLoud(NAME, process, async () => {
  276. await app.current?.fiber.dispose()
  277. })
  278. const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
  279. const profileContext: ProfileContext = {
  280. name: options.profile,
  281. dir: composed.profile.dir, patchPath: composed.profile.patchPath,
  282. installAnchor: options.resolvedProfile?.installAnchor ?? INSTALL_ANCHOR,
  283. startedBundles: composed.profile.layers.map(layer => layer.packageName),
  284. cwd: process.cwd(), home: resolveDshHome(),
  285. overlays: composed.overlays, telemetryDisabledEnv: process.env.DSH_TELEMETRY_DISABLED,
  286. }
  287. const ctx = await boot(NAME, rootConfig, readProfilePatches(NAME, profileContext, composed.profile), async (hostCtx) => {
  288. app.current = hostCtx
  289. hostCtx.provide('profileContext', profileContext)
  290. // Before any config-tree entry mounts, so plugins resolve all launch-time
  291. // environment values from the same immutable launch snapshot.
  292. hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
  293. await hostCtx.plugin(PluginPackages, resolutionMode === 'link' ? {} : {
  294. generation: composed.resolution,
  295. behavior: resolutionMode === 'dual' ? 'verify' : 'enforce',
  296. })
  297. // The command line and bounded exit request are launcher facts available
  298. // to every app plugin that injects the argument snapshot.
  299. provideCmdline(hostCtx, {
  300. args: options.args,
  301. exit: code => void shutdown.shutdown(code),
  302. ready: appReady.service,
  303. })
  304. })
  305. app.current = ctx
  306. if (!signalShutdown.signal.aborted
  307. && ctx.fiber.state === FiberState.ACTIVE
  308. && ctx.get('loader') !== undefined) {
  309. appReady.commit()
  310. }
  311. return { ctx, shutdown }
  312. } catch (error) {
  313. try { await dispose() } catch (cleanupError) {
  314. throw new AggregateError([error, cleanupError], 'dsh: profile startup and cleanup failed')
  315. }
  316. throw error
  317. }
  318. }