From 0071862d489eacb7607ea167b954d336098987af Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 6 Aug 2026 09:53:49 +0800 Subject: [PATCH] refactor(cli): simplify profile composition and dump paths - composeProfile keeps layers as bundle/user/overlay+flags segments instead of one flat list later re-sliced by index arithmetic; the row index drops the group-walk (profile trees are flat patch compositions) and the double composition. - The config dump anchors on the profile's real empty root (written by the shared prepareProfile) instead of materializing a temp file, so dump and boot compose over the identical base by construction. - dsh-base drops its patchPath export: the dsh.patch manifest field is the one contract; the package carries no runtime API. - packageDirFromAnchor is paths-probe only (the require.resolve fast path duplicated the probe's outcome); basename() replaces hand-rolled path splitting; verify-cordis-config stops re-reading bundle manifests in-loop. --- apps/cli/src/dump-config.ts | 29 ++------ apps/cli/src/profile-boot.ts | 99 ++++++++++++------------- packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/base/src/index.ts | 13 +--- packages/bundle/base/tests/base.spec.ts | 14 ++-- packages/ui/app-boot/src/profile.ts | 28 +++---- scripts/verify-cordis-config.ts | 6 +- 9 files changed, 84 insertions(+), 113 deletions(-) diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index d93cb48138..20b54ffeb1 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -6,17 +6,14 @@ * @module @deepseek-ai/dsh/dump-config */ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { existsSync } from 'node:fs' import { join, resolve } from 'node:path' import { - healProfilesModuleFallback, loadOverlayPatches, - loadProfile, renderConfigDump, type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' -import { INSTALL_ANCHOR } from './profile-boot.ts' +import { prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts' const NAME = 'dsh' @@ -24,15 +21,13 @@ const NAME = 'dsh' /** * Print a profile composition with provenance comments. * @param profile - the profile name. - * @param defaultOnly - omit the profile's user layer and `--patch` overlays. + * @param defaultOnly - omit the profile's user layer and `--patch` overlays + * (the recovery diagnostic for a broken `cordis.patch.yml`, which is then + * never parsed). * @param patches - `--patch` overlay paths, in argv order. */ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void { - healProfilesModuleFallback(INSTALL_ANCHOR) - // The default dump never reads the user layer: it doubles as the recovery - // diagnostic for a broken cordis.patch.yml, so parsing that file here would - // defeat its purpose. - const loaded = loadProfile(NAME, profile, INSTALL_ANCHOR, undefined, { userLayer: !defaultOnly }) + const loaded = prepareProfile(profile, !defaultOnly) const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({ label: layer.packageName, patches: layer.patches, @@ -46,15 +41,7 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re layers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) }) } } - // renderConfigDump anchors on a base entry-list file; a profile's base is - // the empty list, materialized as a temp document. - const emptyRoot = mkdtempSync(join(tmpdir(), 'dsh-dump-')) - const emptyRootFile = join(emptyRoot, 'profile-root.yml') - writeFileSync(emptyRootFile, '[]\n') - try { - process.stdout.write(renderConfigDump(NAME, emptyRootFile, layers)) - } finally { - rmSync(emptyRoot, { recursive: true, force: true }) - } + // The dump anchors on the same empty root file the boot includes. + process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers)) } /* v8 ignore stop */ diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index facaa3ab01..549dbc5409 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -44,7 +44,7 @@ const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tre ` /** Root config filename inside a profile directory. */ -const PROFILE_ROOT_FILENAME = 'cordis.yml' +export const PROFILE_ROOT_FILENAME = 'cordis.yml' /** * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty @@ -62,39 +62,54 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b return { id: TELEMETRY_ROW_ID, disabled: true } } -/** Load a resolved profile for `name`, healing the shared module fallback first. */ -function prepareProfile(name: string): Profile { +/** + * Load a resolved profile for `name`: heal the shared module fallback, then + * (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). + * @returns the loaded profile. + */ +export function prepareProfile(name: string, userLayer = true): Profile { healProfilesModuleFallback(INSTALL_ANCHOR) - const profile = loadProfile(NAME, name, INSTALL_ANCHOR) - const rootConfig = join(profile.dir, PROFILE_ROOT_FILENAME) - // The root is always rewritten to the empty list: 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 stays a real on-disk include root only because the Loader needs - // one to anchor `baseUrl` at the profile directory. - writeFileSync(rootConfig, PROFILE_ROOT_CONFIG) + const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer }) + writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG) return profile } -/** One profile's full patch stack and the row index of its composed tree. */ +/** One profile's patch layers (application order) and the row index of its pre-flag composition. */ interface ComposedProfile { profile: Profile - /** Bundle + profile + --patch + flag layers, in application order. */ - patches: PatchOptions[] - /** id → composed row (post-composition), for flag merges and row checks. */ + /** Bundle layers concatenated — the part below the user layer on a live reload. */ + bundlePatches: PatchOptions[] + /** Layers above the user layer on a live reload: --patch overlays, flag patches, the telemetry switch. */ + overlayAndFlags: PatchOptions[] + /** + * id → row of the pre-flag composition (bundles + user layer + overlays), + * for flag merges and row checks. Flag patches must not insert rows the + * launcher consults here (they only override values and insert dev glue). + */ rows: Map } +/** The full patch stack of one composed profile, in application order. */ +function allPatches(composed: ComposedProfile): PatchOptions[] { + return [...composed.bundlePatches, ...composed.profile.patches, ...composed.overlayAndFlags] +} + /** - * Load `name` and compose its effective patch stack. Flag patches derive from - * the pre-flag composition (`deriveFlagPatches` receives the row index of - * bundle + profile + overlay layers), then apply last, then the telemetry - * switch. + * Load `name` and compose its effective patch stack: bundle layers in + * `dsh.plugins` order, the profile's user layer, `--patch` overlays, then + * flag patches derived from the composed rows, then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. * @param deriveFlagPatches - launcher hook turning composed rows into flag patches. - * @returns the profile, its patch stack, and the composed row index (flags included). + * @returns the profile, its patch layers, and the composed row index. */ function composeProfile( name: string, @@ -102,30 +117,16 @@ function composeProfile( deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [], ): ComposedProfile { const profile = prepareProfile(name) - const overlayLayers = patchFiles.map(file => loadOverlayPatches(NAME, resolve(file))) - const layers = [ - ...profile.layers.map(layer => layer.patches), - profile.patches, - ...overlayLayers, - ] - const indexRows = (composedEntries: { id?: string; name?: string; config?: unknown; group?: unknown }[]): ComposedProfile['rows'] => { - const rows = new Map() - const walk = (entries: typeof composedEntries): void => { - for (const row of entries) { - if (typeof row.id === 'string') rows.set(row.id, row) - if (row.group === true && Array.isArray(row.config)) walk(row.config as typeof composedEntries) - } - } - walk(composedEntries) - return rows + 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, overlays])) { + if (typeof row.id === 'string') rows.set(row.id, row) } - const flagPatches = deriveFlagPatches(indexRows(composeEntries(layers))) - layers.push(flagPatches) - const rows = indexRows(composeEntries(layers)) - const patches = layers.flat() + const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)] const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) - if (telemetryPatch !== undefined) patches.push(telemetryPatch) - return { profile, patches, rows } + if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch) + return { profile, bundlePatches, overlayAndFlags, rows } } /** Options for {@link runProfile}. */ @@ -157,7 +158,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con + '(the headless profile does)', ) } - composed.patches.push({ id: HEADLESS_ROW_ID, config: { task: options.task } }) + composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } }) } else if (composed.rows.has(HEADLESS_ROW_ID)) { // The inverse misuse: a one-shot composition booted without its task // would otherwise die in the runner row's schema with a raw "required" @@ -182,26 +183,22 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME) // Recomposition for the live profile layer: bundle layers below, overlays // and flag patches above, so a profile edit can never displace them. - const overlayAndFlags = composed.patches.slice( - composed.profile.layers.reduce((n, layer) => n + layer.patches.length, 0) - + composed.profile.patches.length, - ) // 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 = (profilePatches: PatchOptions[]): PatchOptions[] => structuredClone([ - ...composed.profile.layers.flatMap(layer => layer.patches), + ...composed.bundlePatches, ...profilePatches, - ...overlayAndFlags, + ...composed.overlayAndFlags, ]) // One-shot runs exit through the runner; watching would only hold the // process open after its exit request. const watchProfilePatch = options.task === undefined // 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(composed.patches), async (hostCtx) => { + const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => { app.current = hostCtx if (options.task !== undefined) { const io: HeadlessIo = { diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 9da684b13a..bbc2e0f681 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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 packages/bundle/base/README.md -README.md: dd44e825f9a62c8b5e49a6af31c17b242a1927d7 -README.zh.md: 7227345591b5ddf6d27a88038074ed3541b01102 +README.md: 627dddc3808f67a2624e6e5b4d7f71c1617f227a +README.zh.md: 84f48357d7b66df334d9f78eff64b0c7de3080e1 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index dd44e825f9..627dddc380 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.plugins` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package's TypeScript surface is a single `patchPath` convenience export; the profile composer resolves the patch through the `dsh.patch` manifest field, never through code. +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.plugins` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.patch` manifest field, never through code. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 7227345591..84f48357d7 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.plugins` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包的 TypeScript 表层只有一个便利导出 `patchPath`;profile 组合器通过 manifest(元数据清单)的 `dsh.patch` 字段解析 patch,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.plugins` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.patch` 字段解析 patch,绝不通过代码。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 diff --git a/packages/bundle/base/src/index.ts b/packages/bundle/base/src/index.ts index 70265ac6a2..88c1a2140d 100644 --- a/packages/bundle/base/src/index.ts +++ b/packages/bundle/base/src/index.ts @@ -1,14 +1,9 @@ /** * @deepseek-ai/dsh-base — the shared dsh core as a profile bundle. The - * package's substance is `cordis.patch.yml` (declared by the `dsh.patch` - * manifest field): every profile's first patch layer, inserting the base - * plugin rows over the empty profile root. This module only names the patch - * for consumers that need the path programmatically (the profile composer - * resolves it through the manifest field, not through this export). + * package's substance is `cordis.patch.yml`, declared by the `dsh.patch` + * manifest field and resolved by the profile composer through that field; + * this module carries no runtime API. * @module @deepseek-ai/dsh-base */ -import { fileURLToPath } from 'node:url' - -/** Absolute path of this bundle's profile patch. */ -export const patchPath: string = fileURLToPath(new URL('../cordis.patch.yml', import.meta.url)) +export {} diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index e85a119d46..7784530bd9 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -1,19 +1,21 @@ /** - * The bundle's substance is its patch file: the convenience export must point - * at the real, parseable patch list the `dsh.patch` manifest field declares. + * The bundle's substance is its patch file: the `dsh.patch` manifest field + * must name a real, parseable patch list. */ import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import * as yaml from 'js-yaml' import { entryListSchema } from '@cordisjs/plugin-include' -import { patchPath } from '../src/index.ts' describe('dsh-base bundle', () => { - it('exports the path of a parseable patch list matching the manifest declaration', () => { - const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { dsh?: { patch?: string } } + it('declares a parseable patch list through the dsh.patch manifest field', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dsh?: { patch?: string } } expect(manifest.dsh?.patch).toBe('./cordis.patch.yml') - const parsed = yaml.load(readFileSync(patchPath, 'utf8'), { schema: entryListSchema }) + const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.patch!), 'utf8'), { schema: entryListSchema }) expect(Array.isArray(parsed)).toBe(true) // The base layer is one insert list over the empty profile root. const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(patch => patch.insert ?? []) diff --git a/packages/ui/app-boot/src/profile.ts b/packages/ui/app-boot/src/profile.ts index 89f00e3da6..47840871bc 100644 --- a/packages/ui/app-boot/src/profile.ts +++ b/packages/ui/app-boot/src/profile.ts @@ -25,7 +25,7 @@ import { createRequire } from 'node:module' import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import type { EntryOptions } from '@cordisjs/plugin-loader' import { applyEntryPatches, type PatchOptions } from '@cordisjs/plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-paths' @@ -133,10 +133,7 @@ export function initProfile(dir: string, plugins: readonly string[]): void { const manifestPath = join(dir, 'package.json') if (!existsSync(manifestPath)) { const manifest: ProfileManifest & { private: boolean } = { - // `dir` always carries at least one segment, so at(-1) cannot miss; - // the fallback only satisfies the type. - /* v8 ignore next */ - name: `dsh-profile-${join(dir).split(/[/\\]/).at(-1) ?? 'profile'}`, + name: `dsh-profile-${basename(dir)}`, private: true, dependencies: {}, dsh: { plugins: [...plugins] }, @@ -267,21 +264,16 @@ export function writeProfileManifest(dir: string, manifest: ProfileManifest): vo /** * Resolve a package's root directory from one anchor without depending on the - * package exporting `./package.json`: probe the require resolution paths for - * a directory holding the named manifest. This is Node's own lookup order, so - * the result matches what the Loader would import from the same anchor. + * package exporting `./package.json` (`require.resolve` would need that): + * probe the require resolution paths for a directory holding the named + * manifest. This is Node's own node_modules lookup order, so the result + * matches what the Loader would import from the same anchor, and + * `existsSync` follows the symlinks pnpm's isolated layout uses. */ function packageDirFromAnchor(anchor: string, packageName: string): string | undefined { - const require = createRequire(anchor) - // Fast path: the package exports its manifest (every in-box package does). - try { - return dirname(require.resolve(`${packageName}/package.json`)) - } catch { - // Exports-encapsulated package — fall through to the paths probe. - } // resolve.paths returns null only for builtins, which no bundle name is. /* v8 ignore next */ - for (const searchPath of require.resolve.paths(packageName) ?? []) { + for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) { const candidate = join(searchPath, packageName) if (existsSync(join(candidate, 'package.json'))) return candidate } @@ -307,11 +299,9 @@ export function resolveBundleDir( const dir = packageDirFromAnchor(anchor, packageName) if (dir !== undefined) return dir } - // profileDir always carries at least one segment; String() only satisfies the type. - const profileName = String(join(profileDir).split(/[/\\]/).at(-1)) throw new Error( `${binName}: cannot resolve profile bundle ${JSON.stringify(packageName)} from the dsh installation or ${profileDir}; ` - + `run 'dsh plugin --profile ${profileName} install' if its dependency is not installed`, + + `run 'dsh plugin --profile ${basename(profileDir)} install' if its dependency is not installed`, ) } diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index eb7d7a7ac0..4c4d83ead6 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -166,12 +166,12 @@ function validateAppResolution(): string[] { // per-layer resolution anchors on the bundle package directory. for (const manifestPath of globSync('packages/bundle/*/package.json', { cwd: root })) { const bundleDir = manifestPath.replace(/\/package\.json$/, '') - const dependencies = readManifest(manifestPath).dependencies ?? {} + const manifest = readManifest(manifestPath) const references = pluginReferences.filter(reference => reference.file.startsWith(`${bundleDir}/`)) violations.push(...missingPluginDependencies( // A bundle may mount its own package (the web-app runtime row). - references.filter(reference => packageNameFromSpecifier(reference.name) !== readManifest(manifestPath).name), - dependencies, + references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name), + manifest.dependencies ?? {}, manifestPath, )) }