|
|
@@ -1,3 +1,442 @@
|
|
|
-/** Shared profile lifecycle available to application hosts. */
|
|
|
-export { runProfile } from './profile-boot-internal.ts'
|
|
|
-export type { ResolvedProfileRuntime, RunProfileOptions } from './profile-boot-internal.ts'
|
|
|
+/**
|
|
|
+ * Shared profile boot for every `dsh` surface: resolve the profile, stack its
|
|
|
+ * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's
|
|
|
+ * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the
|
|
|
+ * tree over the profile's empty root config, apply its selected patch-reload
|
|
|
+ * lifecycle, and wire fail-loud plus bounded shutdown.
|
|
|
+ *
|
|
|
+ * App flags are not the launcher's business: the invocation's inner arguments
|
|
|
+ * are provided to the tree through `ctx.cmdlineArgs`, where any injected app
|
|
|
+ * plugin may read the same immutable snapshot.
|
|
|
+ * @module @deepseek-ai/dsh/profile-boot
|
|
|
+ */
|
|
|
+
|
|
|
+import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
|
+import { dirname, join, resolve } from 'node:path'
|
|
|
+import { fileURLToPath } from 'node:url'
|
|
|
+import { FiberState, type Context } from '@deepseek-ai/cordis'
|
|
|
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
|
|
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
|
|
+import {
|
|
|
+ boot,
|
|
|
+ type ProfileResolutionMode,
|
|
|
+ type ProfileResolutionGeneration,
|
|
|
+ PluginPackages,
|
|
|
+ createProfileResolutionGeneration,
|
|
|
+ composeEntries,
|
|
|
+ healProfilesModuleFallback,
|
|
|
+ healIsolatedProfileModuleFallback,
|
|
|
+ initProfile,
|
|
|
+ installFailLoud,
|
|
|
+ loadOptionalPatches,
|
|
|
+ loadOverlayPatches,
|
|
|
+ loadProfile,
|
|
|
+ PROFILE_PATCH_FILENAME,
|
|
|
+ PROFILE_TEMPLATES,
|
|
|
+ resolveProfileDir,
|
|
|
+ watchUserPatches,
|
|
|
+ type Profile,
|
|
|
+} from '@deepseek-ai/dsh-app-boot'
|
|
|
+import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
|
+import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
|
|
|
+import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
|
|
+import { provideCmdline, type AppReady } from '@deepseek-ai/dsh-cmdline'
|
|
|
+import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
|
|
|
+
|
|
|
+const NAME = 'dsh'
|
|
|
+
|
|
|
+/** Launcher-owned readiness signal committed only after boot and host setup succeed. */
|
|
|
+function createAppReady(): { service: AppReady; commit(): void } {
|
|
|
+ let ready = false
|
|
|
+ const listeners = new Set<() => void>()
|
|
|
+ return {
|
|
|
+ service: {
|
|
|
+ onReady(listener) {
|
|
|
+ if (ready) {
|
|
|
+ listener()
|
|
|
+ return () => {}
|
|
|
+ }
|
|
|
+ listeners.add(listener)
|
|
|
+ return () => { listeners.delete(listener) }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ commit() {
|
|
|
+ if (ready) return
|
|
|
+ ready = true
|
|
|
+ for (const listener of [...listeners]) listener()
|
|
|
+ listeners.clear()
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied
|
|
|
+ * over every profile's own layer. Resolved per call, not at module load:
|
|
|
+ * `$DSH_HOME` may be set by the test or launcher after import.
|
|
|
+ * @returns the absolute patch-file path.
|
|
|
+ */
|
|
|
+export function homePatchPath(): string {
|
|
|
+ return join(resolveDshHome(), PROFILE_PATCH_FILENAME)
|
|
|
+}
|
|
|
+
|
|
|
+/** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */
|
|
|
+export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url))
|
|
|
+
|
|
|
+/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
|
|
|
+const TELEMETRY_ROW_ID = 'session-telemetry-otel'
|
|
|
+
|
|
|
+/** The empty root entry list every profile tree patches over. */
|
|
|
+const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
|
|
|
+# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
|
|
|
+# --patch overlays. Edit cordis.patch.yml, not this file.
|
|
|
+[]
|
|
|
+`
|
|
|
+
|
|
|
+/** Root config filename inside a profile directory. */
|
|
|
+export const PROFILE_ROOT_FILENAME = 'cordis.yml'
|
|
|
+
|
|
|
+/**
|
|
|
+ * Initialize a missing profile from one shipped template. This copies only
|
|
|
+ * the template's bundle list and patch-reload policy; local state from the
|
|
|
+ * same-named shipped profile is not read, and no inheritance metadata is
|
|
|
+ * persisted. Shipped profile names are reserved, and the target directory is
|
|
|
+ * claimed exclusively so existing or concurrent state is never reused.
|
|
|
+ * @param name - the new profile name.
|
|
|
+ * @param fromDefaultProfile - shipped profile template to copy.
|
|
|
+ * @param home - Harness home containing the profile directory.
|
|
|
+ * @throws when the template is unknown, the target name is shipped, or the target directory exists.
|
|
|
+ */
|
|
|
+export function initializeProfileFromDefault(
|
|
|
+ name: string,
|
|
|
+ fromDefaultProfile: string,
|
|
|
+ home: string = resolveDshHome(),
|
|
|
+): void {
|
|
|
+ const dir = resolveProfileDir(name, home)
|
|
|
+ const template = Object.hasOwn(PROFILE_TEMPLATES, fromDefaultProfile)
|
|
|
+ ? PROFILE_TEMPLATES[fromDefaultProfile]
|
|
|
+ : undefined
|
|
|
+ if (template === undefined) {
|
|
|
+ const expected = Object.keys(PROFILE_TEMPLATES).sort().map(value => JSON.stringify(value)).join(', ')
|
|
|
+ throw new Error(
|
|
|
+ `${NAME}: unknown default profile ${JSON.stringify(fromDefaultProfile)}; expected one of ${expected}`,
|
|
|
+ )
|
|
|
+ }
|
|
|
+ if (Object.hasOwn(PROFILE_TEMPLATES, name)) {
|
|
|
+ throw new Error(
|
|
|
+ `${NAME}: profile ${JSON.stringify(name)} is shipped and cannot be a custom profile target; `
|
|
|
+ + 'omit --from-default-profile to use it',
|
|
|
+ )
|
|
|
+ }
|
|
|
+ mkdirSync(dirname(dir), { recursive: true })
|
|
|
+ try {
|
|
|
+ mkdirSync(dir)
|
|
|
+ } catch (error) {
|
|
|
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
|
+ const manifestPath = join(dir, 'package.json')
|
|
|
+ if (existsSync(manifestPath)) {
|
|
|
+ throw new Error(
|
|
|
+ `${NAME}: profile ${JSON.stringify(name)} already exists at ${manifestPath}; `
|
|
|
+ + 'omit --from-default-profile to use it',
|
|
|
+ )
|
|
|
+ }
|
|
|
+ throw new Error(
|
|
|
+ `${NAME}: profile directory ${dir} already exists; choose an unused profile name`,
|
|
|
+ )
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ initProfile(dir, template.bundles, template.patchReload)
|
|
|
+ } catch (error) {
|
|
|
+ try {
|
|
|
+ rmSync(dir, { recursive: true, force: true })
|
|
|
+ } catch (cleanupError) {
|
|
|
+ throw new AggregateError(
|
|
|
+ [error, cleanupError],
|
|
|
+ `${NAME}: profile initialization failed and ${dir} could not be removed`,
|
|
|
+ )
|
|
|
+ }
|
|
|
+ throw error
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
|
|
|
+ * value (including `'0'`/`'false'`) disables: a privacy switch prefers
|
|
|
+ * off-by-mistake over on-by-mistake. A composition without the telemetry row
|
|
|
+ * exports nothing, so the switch is then trivially satisfied and no patch is
|
|
|
+ * generated — custom profiles need not mount telemetry to run with the
|
|
|
+ * switch set.
|
|
|
+ * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
|
|
|
+ * @param hasRow - whether the composition carries the telemetry row.
|
|
|
+ * @returns the disable patch, or `undefined` when no hard-disable patch is required.
|
|
|
+ */
|
|
|
+export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
|
|
|
+ if ((disabledEnv ?? '') === '' || !hasRow) return undefined
|
|
|
+ return { id: TELEMETRY_ROW_ID, disabled: true }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Load a resolved profile for `name` and (re)write the empty root config. The
|
|
|
+ * root is always rewritten: the whole composition is patch layers, and the
|
|
|
+ * vendored Loader's tree write-back (a plugin self-disposing persists the
|
|
|
+ * current tree) can bake composed rows into this file — which would duplicate
|
|
|
+ * every bundle insert on the next boot. The file exists on disk only because
|
|
|
+ * the Loader needs a real include root to anchor `baseUrl` at the profile
|
|
|
+ * directory (the config dump anchors on the same file, so both compose over
|
|
|
+ * the identical base).
|
|
|
+ * @param name - the profile name.
|
|
|
+ * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
|
|
|
+ * @param fromDefaultProfile - shipped template used once to initialize a missing profile.
|
|
|
+ * @returns the loaded profile.
|
|
|
+ * @throws when explicit initialization names an unknown template or an existing profile.
|
|
|
+ */
|
|
|
+export function prepareProfile(name: string, userLayer = true, fromDefaultProfile?: string): Profile {
|
|
|
+ if (fromDefaultProfile !== undefined) initializeProfileFromDefault(name, fromDefaultProfile)
|
|
|
+ const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
|
|
|
+ writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
|
|
|
+ return profile
|
|
|
+}
|
|
|
+
|
|
|
+/** One profile's patch layers, in application order. */
|
|
|
+interface ComposedProfile {
|
|
|
+ resolution: ProfileResolutionGeneration
|
|
|
+ profile: Profile
|
|
|
+ /** Bundle layers concatenated — the part below the user layers on a live reload. */
|
|
|
+ bundlePatches: PatchOptions[]
|
|
|
+ /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
|
|
|
+ homePatches: PatchOptions[]
|
|
|
+ /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
|
|
|
+ overlays: PatchOptions[]
|
|
|
+}
|
|
|
+
|
|
|
+/** The full patch stack of one composed profile, in application order. */
|
|
|
+function allPatches(composed: ComposedProfile): PatchOptions[] {
|
|
|
+ return [
|
|
|
+ ...composed.bundlePatches,
|
|
|
+ ...composed.profile.patches,
|
|
|
+ ...composed.homePatches,
|
|
|
+ ...composed.overlays,
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Load `name` and compose its effective patch stack: bundle layers in
|
|
|
+ * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's
|
|
|
+ * platform-gated shell rows), the profile's user layer, the home-level user
|
|
|
+ * layer (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply
|
|
|
+ * to every profile, so it outranks the per-profile layer), `--patch` overlays,
|
|
|
+ * then the telemetry switch.
|
|
|
+ * @param name - the profile name.
|
|
|
+ * @param patchFiles - `--patch` overlay paths, in argv order.
|
|
|
+ * @param fromDefaultProfile - shipped template for a missing named profile.
|
|
|
+ * @param resolvedProfile - application-owned profile and installation; bypasses named discovery.
|
|
|
+ * @returns the profile and its patch layers.
|
|
|
+ */
|
|
|
+async function composeProfile(
|
|
|
+ name: string,
|
|
|
+ patchFiles: readonly string[],
|
|
|
+ resolutionMode: ProfileResolutionMode,
|
|
|
+ fromDefaultProfile?: string,
|
|
|
+ resolvedProfile?: ResolvedProfileRuntime,
|
|
|
+): Promise<ComposedProfile> {
|
|
|
+ const profile = resolvedProfile?.profile ?? prepareProfile(name, true, fromDefaultProfile)
|
|
|
+ if (resolvedProfile !== undefined) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
|
|
|
+ const resolutionOptions = { installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR, profile }
|
|
|
+ if (resolvedProfile !== undefined && resolutionMode !== 'runtime') healIsolatedProfileModuleFallback(resolvedProfile)
|
|
|
+ const resolution = resolutionMode === 'runtime' || resolvedProfile !== undefined
|
|
|
+ ? await createProfileResolutionGeneration(resolutionOptions)
|
|
|
+ : await healProfilesModuleFallback(resolutionOptions)
|
|
|
+ const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
|
|
|
+ const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
|
|
|
+ const bundlePatches = profile.layers.flatMap(layer => layer.patches)
|
|
|
+ const rows = new Map<string, EntryOptions>()
|
|
|
+ for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
|
|
|
+ if (typeof row.id === 'string') rows.set(row.id, row)
|
|
|
+ }
|
|
|
+ const composedOverlays = [...overlays]
|
|
|
+ const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
|
|
|
+ if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
|
|
|
+ return { profile, resolution, bundlePatches, homePatches, overlays: composedOverlays }
|
|
|
+}
|
|
|
+
|
|
|
+/** An application-owned profile and its independent installation fallback. */
|
|
|
+export interface ResolvedProfileRuntime {
|
|
|
+ /** Profile already loaded from the application's own directory. */
|
|
|
+ profile: Profile
|
|
|
+ /** Absolute package.json path of the application's dsh installation. */
|
|
|
+ installAnchor: string
|
|
|
+}
|
|
|
+
|
|
|
+/** Options for {@link runProfile}. */
|
|
|
+export interface RunProfileOptions {
|
|
|
+ /** Package lookup strategy; packaged executables always use runtime resolution. */
|
|
|
+ resolutionMode?: ProfileResolutionMode
|
|
|
+ /** This run's frozen environment snapshot, provided before any entry mounts. */
|
|
|
+ environment: LaunchEnvironmentSnapshot
|
|
|
+ /** The profile name to boot. */
|
|
|
+ profile: string
|
|
|
+ /** Loaded application profile; bypasses named profile initialization when supplied. */
|
|
|
+ resolvedProfile?: ResolvedProfileRuntime | undefined
|
|
|
+ /** Shipped template used once to initialize a missing profile. */
|
|
|
+ fromDefaultProfile?: string | undefined
|
|
|
+ /** `--patch` overlay paths, in argv order. */
|
|
|
+ patchFiles: readonly string[]
|
|
|
+ /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
|
|
|
+ args: readonly string[]
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Re-throw a watcher-setup failure unless a shutdown already owns the tree:
|
|
|
+ * a signal aborted this invocation, or an app requested exit (`ctx.appExit`
|
|
|
+ * from a fast one-shot) and the root's disposal rejected the in-flight setup
|
|
|
+ * await. Either way the failure describes a tree that is exiting as asked,
|
|
|
+ * not a broken watch.
|
|
|
+ * @param ctx - the booted root context.
|
|
|
+ * @param signal - this invocation's signal-shutdown fact.
|
|
|
+ * @param error - the setup failure.
|
|
|
+ */
|
|
|
+function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown): void {
|
|
|
+ if (signal.aborted) return
|
|
|
+ if (ctx.fiber.state !== FiberState.ACTIVE || ctx.get('loader') === undefined) return
|
|
|
+ throw error
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Boot one profile invocation end to end and leave process lifetime to the
|
|
|
+ * mounted plugins (or to a one-shot runner the composition mounts).
|
|
|
+ * @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
|
|
|
+ * @returns the settled root context and the shutdown controller.
|
|
|
+ * @throws after disposing startup resources; a cleanup failure retains both errors in an AggregateError.
|
|
|
+ */
|
|
|
+export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
|
|
|
+ // Before the first plugin mounts and before anything can issue a request: Node's fetch ignores the
|
|
|
+ // proxy environment on its own, so every profile would otherwise connect directly. Resolving from
|
|
|
+ // the launcher's snapshot — not `process.env` — is what lets a proxy declared in a `.env` layer
|
|
|
+ // work, which the NODE_USE_ENV_PROXY flag cannot do because Node samples the environment at start.
|
|
|
+ const disposeProxy = await installProxyFromEnvironment(
|
|
|
+ options.environment,
|
|
|
+ (message) => { process.stderr.write(`${NAME}: ${message}\n`) },
|
|
|
+ )
|
|
|
+
|
|
|
+ const packaged = (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined
|
|
|
+ const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'link'
|
|
|
+ const app: { current?: Context } = {}
|
|
|
+ let disposal: Promise<void> | undefined
|
|
|
+ const dispose = (): Promise<void> => disposal ??= (async () => {
|
|
|
+ const failures: unknown[] = []
|
|
|
+ for (const release of [() => app.current?.fiber.dispose(), disposeProxy]) {
|
|
|
+ try { await release() } catch (error) { failures.push(error) }
|
|
|
+ }
|
|
|
+ if (failures.length === 1) throw failures[0]
|
|
|
+ if (failures.length > 1) throw new AggregateError(failures, 'dsh: profile cleanup failed')
|
|
|
+ })()
|
|
|
+ try {
|
|
|
+ const composed = await composeProfile(
|
|
|
+ options.profile, options.patchFiles, resolutionMode, options.fromDefaultProfile, options.resolvedProfile,
|
|
|
+ )
|
|
|
+ const appReady = createAppReady()
|
|
|
+ const shutdown = createProcessShutdown(dispose)
|
|
|
+ const signalShutdown = new AbortController()
|
|
|
+ const interrupt = (code: number): void => {
|
|
|
+ signalShutdown.abort()
|
|
|
+ shutdown.interrupt(code)
|
|
|
+ }
|
|
|
+ // Signals own teardown throughout the startup window, not only after boot()
|
|
|
+ // settles: an inserted provider can publish before sibling rows finish mounting.
|
|
|
+ // SIGTERM is a supervisor's ordinary stop request and exits 0 on every
|
|
|
+ // surface — the launcher does not know whether the app considered its work
|
|
|
+ // complete; SIGINT is a user interrupt and reports 130.
|
|
|
+ process.on('SIGTERM', () => { interrupt(0) })
|
|
|
+ process.on('SIGINT', () => { interrupt(130) })
|
|
|
+ installFailLoud(NAME, process, async () => {
|
|
|
+ await app.current?.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
|
|
|
+ // Recomposition for the live user layers: bundle layers below, overlays
|
|
|
+ // above, so a user edit can never displace them. Parsed app arguments are
|
|
|
+ // not in here at all — they live in app-provided services that survive a
|
|
|
+ // recomposition. BOTH
|
|
|
+ // user files are re-read per generation (the HMR watcher hands us only the
|
|
|
+ // changed file's patches, which one of the reads duplicates — fresh reads
|
|
|
+ // keep the two watchers from stitching in each other's stale copy).
|
|
|
+ // Fresh clones per generation: the include pushes `insert` rows into the
|
|
|
+ // mounted tree BY REFERENCE and later id-targeted patches mutate those
|
|
|
+ // objects in place. Reusing one parsed patch object across applications
|
|
|
+ // would bake a user override into the bundle's in-memory insert row, so
|
|
|
+ // removing the override could never revert the row to the bundle default.
|
|
|
+ const composeLive = (): PatchOptions[] => structuredClone([
|
|
|
+ ...composed.bundlePatches,
|
|
|
+ ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
|
|
|
+ ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
|
|
|
+ ...composed.overlays,
|
|
|
+ ])
|
|
|
+ // Cloned for the same insert-aliasing reason as composeLive: the boot
|
|
|
+ // application must not mutate the objects later reloads recompose from.
|
|
|
+ const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
|
|
|
+ app.current = hostCtx
|
|
|
+ // Before any config-tree entry mounts, so plugins resolve all launch-time
|
|
|
+ // environment values from the same immutable launch snapshot.
|
|
|
+ hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
|
|
|
+ await hostCtx.plugin(PluginPackages, resolutionMode === 'link' ? {} : {
|
|
|
+ generation: composed.resolution,
|
|
|
+ behavior: resolutionMode === 'dual' ? 'verify' : 'enforce',
|
|
|
+ })
|
|
|
+ // The command line and bounded exit request are launcher facts available
|
|
|
+ // to every app plugin that injects the argument snapshot.
|
|
|
+ provideCmdline(hostCtx, {
|
|
|
+ args: options.args,
|
|
|
+ exit: code => void shutdown.shutdown(code),
|
|
|
+ ready: appReady.service,
|
|
|
+ })
|
|
|
+ })
|
|
|
+ app.current = ctx
|
|
|
+ // A live-reload profile can dispose the whole tree while post-boot watcher
|
|
|
+ // setup is in flight — a signal or appExit. Loader presence and fiber state
|
|
|
+ // own liveness; the initial check skips a tree that already exited, and the
|
|
|
+ // catch below re-checks for an exit that landed mid-setup. Startup-frozen
|
|
|
+ // profiles apply every user layer above but install no HMR fallback or watcher.
|
|
|
+ if (composed.profile.patchReload === 'live'
|
|
|
+ && !signalShutdown.signal.aborted
|
|
|
+ && ctx.fiber.state === FiberState.ACTIVE
|
|
|
+ && ctx.get('loader') !== undefined) {
|
|
|
+ try {
|
|
|
+ // Config-only HMR for the live profile patch layer: dsh-base disables
|
|
|
+ // module reload by default, so when no profile explicitly enabled that
|
|
|
+ // service, mount a watch-only instance with no module roots —
|
|
|
+ // cordis.patch.yml edits stay live without replacing source modules. A
|
|
|
+ // silent skip would break the documented reload contract. HMR injects
|
|
|
+ // the timer service, which a bare custom profile may not mount either.
|
|
|
+ if (ctx.get('hmr') === undefined) {
|
|
|
+ if (ctx.get('timer') === undefined) {
|
|
|
+ await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
|
|
|
+ }
|
|
|
+ await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
|
|
|
+ await ctx.loader.await()
|
|
|
+ }
|
|
|
+ await watchUserPatches(ctx, {
|
|
|
+ binName: NAME,
|
|
|
+ filename: composed.profile.patchPath,
|
|
|
+ compose: composeLive,
|
|
|
+ })
|
|
|
+ await watchUserPatches(ctx, {
|
|
|
+ binName: NAME,
|
|
|
+ filename: homePatchPath(),
|
|
|
+ compose: composeLive,
|
|
|
+ })
|
|
|
+ } catch (error) {
|
|
|
+ suppressShutdownError(ctx, signalShutdown.signal, error)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!signalShutdown.signal.aborted
|
|
|
+ && ctx.fiber.state === FiberState.ACTIVE
|
|
|
+ && ctx.get('loader') !== undefined) {
|
|
|
+ appReady.commit()
|
|
|
+ }
|
|
|
+ return { ctx, shutdown }
|
|
|
+ } catch (error) {
|
|
|
+ try { await dispose() } catch (cleanupError) {
|
|
|
+ throw new AggregateError([error, cleanupError], 'dsh: profile startup and cleanup failed')
|
|
|
+ }
|
|
|
+ throw error
|
|
|
+ }
|
|
|
+}
|