diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 697639683d..173c3bbb0b 100644 --- a/apps/cli/README.i18n.yaml +++ b/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 diff --git a/apps/cli/README.md b/apps/cli/README.md index 940d82048a..f2131121b4 100644 --- a/apps/cli/README.md +++ b/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 ` 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. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 97a00f305d..0fccc161a8 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -55,6 +55,6 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh ` 运行 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 的必需依赖与端口冲突。 diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 71bac0f8bb..1e406f1520 100644 --- a/apps/cli/src/dump-config.ts +++ b/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' diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index 0bc01fe953..f00d703808 100644 --- a/apps/cli/src/plugin.ts +++ b/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' diff --git a/apps/cli/src/profile-boot-internal.ts b/apps/cli/src/profile-boot-internal.ts deleted file mode 100644 index ab7a85def4..0000000000 --- a/apps/cli/src/profile-boot-internal.ts +++ /dev/null @@ -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 { - 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() - 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 | undefined - const dispose = (): Promise => 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 - } -} diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 8c23d9d1ba..f4c0999526 100644 --- a/apps/cli/src/profile-boot.ts +++ b/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 { + 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() + 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 | undefined + const dispose = (): Promise => 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 + } +} diff --git a/apps/cli/tests/fixtures/initialize-profile-from-default.ts b/apps/cli/tests/fixtures/initialize-profile-from-default.ts index 10a3120174..9f9e239337 100644 --- a/apps/cli/tests/fixtures/initialize-profile-from-default.ts +++ b/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) { diff --git a/apps/cli/tests/profile-initialization.spec.ts b/apps/cli/tests/profile-initialization.spec.ts index 05d593e29a..9c65a34683 100644 --- a/apps/cli/tests/profile-initialization.spec.ts +++ b/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') diff --git a/apps/cli/tests/resolved-profile-boot.spec.ts b/apps/cli/tests/resolved-profile-boot.spec.ts index a4b8fb6bdc..6a25712614 100644 --- a/apps/cli/tests/resolved-profile-boot.spec.ts +++ b/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(), @@ -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) diff --git a/apps/cli/tests/telemetry-switch.spec.ts b/apps/cli/tests/telemetry-switch.spec.ts index 921e3fc260..66362d2a3e 100644 --- a/apps/cli/tests/telemetry-switch.spec.ts +++ b/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', () => {