Explorar o código

revert: restore original CLI profile boot exports

07akioni hai 3 días
pai
achega
830c82687b

+ 2 - 2
apps/cli/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: 940d82048a923968efc7906b1fc3688a5759e3d4
-README.zh.md: 97a00f305ddbe908f40508f78500823664156643
+README.md: f2131121b46acc41e6f9a6db9751a8362a9e174a
+README.zh.md: 0fccc161a8bd0a6eaf07ef01fa7741dc15f88081

+ 1 - 1
apps/cli/README.md

@@ -55,6 +55,6 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f
 
 Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh <args...>` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.
 
-The `@deepseek-ai/dsh/profile-boot` export exposes only `runProfile`, `RunProfileOptions`, and `ResolvedProfileRuntime` for the shared profile lifecycle used by the Desktop host. Launcher helpers remain internal. A resolved application profile supplies its own installation anchor and profile-local module fallback while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown.
+The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor and profile-local module fallback while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown.
 
 The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts.

+ 1 - 1
apps/cli/README.zh.md

@@ -55,6 +55,6 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
 
 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh <args...>` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。
 
-`@deepseek-ai/dsh/profile-boot` 导出 `runProfile`、`RunProfileOptions` 和 `ResolvedProfileRuntime`,向 Desktop Host 提供共享 profile 生命周期;启动器辅助函数保留在内部。已解析的应用 profile 指定自己的安装锚点和 profile 内模块补全,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。
+`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 指定自己的安装锚点和 profile 内模块补全,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。
 
 [Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。

+ 1 - 1
apps/cli/src/dump-config.ts

@@ -14,7 +14,7 @@ import {
   renderConfigDump,
   type ConfigDumpLayer,
 } from '@deepseek-ai/dsh-app-boot'
-import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot-internal.ts'
+import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts'
 
 const NAME = 'dsh'
 

+ 1 - 1
apps/cli/src/plugin.ts

@@ -21,7 +21,7 @@ import {
   reconcileProfilePlugins,
   resolveProfileDir,
 } from '@deepseek-ai/dsh-app-boot'
-import { INSTALL_ANCHOR } from './profile-boot-internal.ts'
+import { INSTALL_ANCHOR } from './profile-boot.ts'
 
 const NAME = 'dsh'
 

+ 0 - 441
apps/cli/src/profile-boot-internal.ts

@@ -1,441 +0,0 @@
-/**
- * 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.
- */
-
-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
-  }
-}

+ 442 - 3
apps/cli/src/profile-boot.ts

@@ -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
+  }
+}

+ 1 - 1
apps/cli/tests/fixtures/initialize-profile-from-default.ts

@@ -2,7 +2,7 @@
 
 import { existsSync, writeFileSync } from 'node:fs'
 import { setTimeout as delay } from 'node:timers/promises'
-import { initializeProfileFromDefault } from '../../src/profile-boot-internal.ts'
+import { initializeProfileFromDefault } from '../../src/profile-boot.ts'
 
 const [home, name, source, ready, gate] = process.argv.slice(2)
 if (home === undefined || name === undefined || source === undefined || ready === undefined || gate === undefined) {

+ 1 - 1
apps/cli/tests/profile-initialization.spec.ts

@@ -14,7 +14,7 @@ import {
 } from '@deepseek-ai/dsh-app-boot'
 import { describe, expect, it } from 'vitest'
 import { execa } from 'execa'
-import { initializeProfileFromDefault } from '../src/profile-boot-internal.ts'
+import { initializeProfileFromDefault } from '../src/profile-boot.ts'
 
 const childEntry = fileURLToPath(new URL('./fixtures/initialize-profile-from-default.ts', import.meta.url))
 const tsxLoader = import.meta.resolve('tsx/esm')

+ 0 - 5
apps/cli/tests/resolved-profile-boot.spec.ts

@@ -8,7 +8,6 @@ import { boot, composeEntries, healIsolatedProfileModuleFallback, watchUserPatch
 import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { runProfile } from '../src/profile-boot.ts'
-import * as profileBoot from '../src/profile-boot.ts'
 
 vi.mock('@deepseek-ai/dsh-app-boot', async importOriginal => ({
   ...await importOriginal<typeof import('@deepseek-ai/dsh-app-boot')>(),
@@ -28,10 +27,6 @@ afterEach(() => {
 })
 
 describe('runProfile with an application-owned profile', () => {
-  it('exposes only the shared runner at the public runtime entry', () => {
-    expect(Object.keys(profileBoot)).toEqual(['runProfile'])
-  })
-
   it.each(['composition', 'boot', 'watch', 'cleanup', 'tree-cleanup', 'both-cleanups'] as const)('releases startup resources after a %s failure', async (stage) => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-failure-'))
     homes.push(home)

+ 1 - 1
apps/cli/tests/telemetry-switch.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { resolveTelemetryPatch } from '../src/profile-boot-internal.ts'
+import { resolveTelemetryPatch } from '../src/profile-boot.ts'
 
 describe('resolveTelemetryPatch', () => {
   it('preserves the configured telemetry mode when the hard-disable switch is unset or empty', () => {