返回源码地图

apps/cli/src/profile-boot.ts

main snapshot · da00f7f5358f · 正文引用章节 04 / 04 / 04 / 04;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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