Merge pull request #5159 from deepseek-harness/fix/profile-bundle-skip-once

fix(boot): report skipped profile bundles once per start
This commit is contained in:
Turtle authored and GitHub committed 2026-09-24 19:41:11 +08:00
commit fb018e3e7a
17 files changed
+73 -69

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ export async function runDumpConfigSchema(
// Trusted module diagnostics must not precede the JSON document on stdout.
process.stdout.write = process.stderr.write.bind(process.stderr)
try {
dump = await generateConfigSchema(NAME, loaded, layers.map(layer => layer.patches), INSTALL_ANCHOR)
dump = await generateConfigSchema(loaded, layers.map(layer => layer.patches), INSTALL_ANCHOR)
} finally {
process.stdout.write = stdoutWrite
}
+2
View File
@@ -23,6 +23,7 @@ import {
installFailLoud,
loadOverlayPatches,
loadProfile,
reportSkippedBundles,
PluginPackages,
PROFILE_PATCH_FILENAME,
PROFILE_TEMPLATES,
@@ -166,6 +167,7 @@ export function initializeProfileFromDefault(
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 })
reportSkippedBundles(NAME, profile)
writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
return profile
}
+1 -1
View File
@@ -28,7 +28,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
}
writeFileSync(join(root, 'package.json'), '{"type":"module"}')
writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}')
writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({}); export const loadLayeredEnv = () => ({})')
writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({})')
writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
import { writeFileSync } from 'node:fs';
+2 -2
View File
@@ -60,7 +60,7 @@ describe('runProfile with an application-owned profile', () => {
throw failure
})
if (stage === 'composition') vi.mocked(createRuntimeResolution).mockRejectedValueOnce(failure)
const profile: Profile = {
const profile: Profile = { skippedBundles: [],
name: 'desktop', dir: home, patchPath: join(home, 'cordis.patch.yml'),
patches: [], layers: [],
}
@@ -121,7 +121,7 @@ describe('runProfile with an application-owned profile', () => {
writeFileSync(profilePatch, '- id: target\n config: { profile: true, priority: profile }\n')
writeFileSync(overlay, '- id: target\n config: { overlay: true, priority: overlay }\n')
writeFileSync(join(home, 'cordis.yml'), '- id: stale\n')
const profile: Profile = {
const profile: Profile = { skippedBundles: [],
name: 'desktop', dir: home, patchPath: profilePatch,
patches: [{ id: 'target', config: { profile: true, priority: 'profile' } }],
layers: [{
+1 -1
View File
@@ -136,7 +136,7 @@ async function bootWeb(
await mkdir(dirname(link), { recursive: true })
await symlink(packageDir, link, 'junction')
}
let profile: Profile = {
let profile: Profile = { skippedBundles: [],
name: 'spec',
dir: profileDir,
layers: [],
+2 -1
View File
@@ -2,7 +2,7 @@
import { delimiter, join } from 'node:path'
import { inspect } from 'node:util'
import { loadLayeredEnv, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot'
import { loadLayeredEnv, loadProfileDirectory, reportSkippedBundles } from '@deepseek-ai/dsh-app-boot'
import { runProfile } from '@deepseek-ai/dsh/profile-boot'
import type {} from '@deepseek-ai/dsh-client-connection'
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -21,6 +21,7 @@ async function main(): Promise<void> {
installOfficeEngineResolution(runtimeDir)
const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
const profile = loadProfileDirectory('dsh', projectDir, installAnchor)
reportSkippedBundles('dsh', profile)
const application = runProfile({
environment: loadLayeredEnv('dsh'),
profile: 'desktop',
+1 -1
View File
@@ -734,7 +734,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
patches: [],
}
}))
const profile: Profile = {
const profile: Profile = { skippedBundles: [],
name: 'scaffold',
dir: profileDir,
layers: extraLayers,
+2 -2
View File
@@ -24,8 +24,8 @@
en: 1099528bde0e2aee
zh: b68772bdb9bb7856
/deepseek-ai-dsh-app-boot/use-this-package/profiles:
en: faa66e7b3b641fa1
zh: c54b742abb967726
en: d50b0fdaf62353dd
zh: 0fccb6968602e5de
/deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration:
en: 646a0ccdd7881ad1
zh: e3696acb1573dd7b
+2 -2
View File
@@ -47,11 +47,11 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot adapts `DshPackageManifest` to `ProfileManifest` with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data.
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. A bundle's `dsh.bundle.patch` names one patch file or an ordered list of files; `bundlePatchFiles` validates the declaration and `bundlePatchPaths` resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures print a diagnostic and skip that bundle without changing its selection. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. A bundle's `dsh.bundle.patch` names one patch file or an ordered list of files; `bundlePatchFiles` validates the declaration and `bundlePatchPaths` resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures skip that bundle without changing its selection; the loaded profile lists each skip in `skippedBundles`, and the launcher prints them once per start with `reportSkippedBundles`. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
Before a profile imports a plugin, DSH checks its `peerDependencies` on `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` against the single runtime version returned by `getDshRuntimeVersion()`. Every declared range must match; prereleases participate in range matching. Source-workspace `workspace:^`, `workspace:~`, and `workspace:*` refer to that same runtime. Missing DSH peers impose no constraint; invalid ranges are incompatible. These checks use peer declarations, not `engines.dsh`, and are not a sandbox against malicious package code.
Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. A bundle is not a row, so `loadProfileDirectory` checks each bundle's own DSH peers when it loads the profile's bundle layers, at startup and on every recomposition; an incompatible bundle without an exemption is skipped like an unreadable one. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` peer declaration, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin row still appears there while a denied bundle contributes no rows, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust.
Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. A bundle is not a row, so `loadProfileDirectory` checks each bundle's own DSH peers when it loads the profile's bundle layers, at startup and on every recomposition; an incompatible bundle without an exemption is skipped like an unreadable one and listed in `skippedBundles`. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` peer declaration, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin row still appears there while a denied bundle contributes no rows, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust.
Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires.
+2 -2
View File
@@ -47,11 +47,11 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
Profile 与组合包的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 将 `DshPackageManifest` 适配为包身份可选的 `ProfileManifest`,因为本地 profile 无需发布版本。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。组合包的 `dsh.bundle.patch` 指定一个 patch 文件或一个有序的文件列表;`bundlePatchFiles` 校验该声明,`bundlePatchPaths` 把它解析为绝对路径;该层按此顺序拼接各文件的 patch 列表。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。组合包解析、manifest 读取或 patch 加载失败时会输出诊断并跳过该组合包,不改变其选择状态。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。组合包的 `dsh.bundle.patch` 指定一个 patch 文件或一个有序的文件列表;`bundlePatchFiles` 校验该声明,`bundlePatchPaths` 把它解析为绝对路径;该层按此顺序拼接各文件的 patch 列表。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。组合包解析、manifest 读取或 patch 加载失败时会跳过该组合包,不改变其选择状态;加载结果在 `skippedBundles` 中列出每个被跳过的组合包,启动器每次启动时通过 `reportSkippedBundles` 输出一次。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek-ai/dsh` 和 `@deepseek-ai/dsh-*` 的依赖,与 `getDshRuntimeVersion()` 返回的唯一运行时版本比较。每个声明的版本范围都必须匹配;预发布版本参与范围匹配。源码工作区的 `workspace:^`、`workspace:~` 和 `workspace:*` 指向同一个运行时。未声明 DSH peer 时不施加版本约束;无效范围视为不兼容。这些检查使用 peer 声明,而不是 `engines.dsh`,也不是防范恶意包代码的沙箱。
检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。组合包本身不是行,因此 `loadProfileDirectory` 在启动和每次重新组合加载 profile 的组合包层时,检查每个组合包自己声明的 DSH peer;没有豁免的不兼容组合包会像无法读取的组合包一样被跳过。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` peer 声明,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件行仍会出现在其中,而被拒绝的组合包不提供任何行;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。
检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。组合包本身不是行,因此 `loadProfileDirectory` 在启动和每次重新组合加载 profile 的组合包层时,检查每个组合包自己声明的 DSH peer;没有豁免的不兼容组合包会像无法读取的组合包一样被跳过,并列入 `skippedBundles`。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` peer 声明,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件行仍会出现在其中,而被拒绝的组合包不提供任何行;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。
精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。
@@ -1,33 +1,30 @@
/** Profile schema generation: composition diagnostics, runtime resolution, and boot-free discovery. */
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { composeEntries, createRuntimeResolution, readProfileManifest, skippedProfileBundles, type Profile } from '../profile.ts'
import { composeEntries, createRuntimeResolution, type Profile } from '../profile.ts'
import { collectConfigSchemas } from './collect.ts'
import type { ConfigSchemaDiagnostic, ConfigSchemaDump } from './types.ts'
export type { ConfigSchemaDump, NativeConfigSchema } from './types.ts'
/**
* Generate JSON Schema for a prepared profile's ordered patch layers without mounting plugins or evaluating expressions.
* Reads the profile manifest; imports, Config getters, and lazy builders execute trusted code. Native validators and
* Imports, Config getters, and lazy builders execute trusted code. Native validators and
* transform callbacks are not executed. Calls must not overlap another profile-resolution interception; collection
* releases its interception on success or rejection, while Node retains imported modules. Supplied layers are not mutated.
* Profile preparation, layer selection, process streams, and exit policy belong to the caller.
* @param binName - the diagnostic prefix on thrown manifest errors.
* @param profile - prepared on-disk profile whose directory anchors root module and include resolution.
* @param layers - already parsed patch lists in application order, including caller-selected home and argv overlays.
* @param installAnchor - package manifest anchoring the installation's runtime dependencies.
* @returns a JSON Schema document with declaration references, partial results, and diagnostics under `x-cordis`.
* @throws when profile metadata, composition, or runtime resolution cannot be prepared.
* @throws when composition or runtime resolution cannot be prepared.
*/
export async function generateConfigSchema(
binName: string,
profile: Profile,
layers: readonly PatchOptions[][],
installAnchor: string,
): Promise<ConfigSchemaDump> {
const diagnostics: ConfigSchemaDiagnostic[] = []
const manifest = readProfileManifest(binName, profile.dir)
for (const packageName of skippedProfileBundles(profile, manifest)) {
for (const { packageName } of profile.skippedBundles) {
diagnostics.push({
level: 'error',
message: `Selected profile bundle ${JSON.stringify(packageName)} could not be loaded; repair or remove its bundle selection.`,
+2
View File
@@ -68,11 +68,13 @@ export {
PROFILE_TEMPLATES,
PROFILES_DIR,
readProfileManifest,
reportSkippedBundles,
resolveBundleDir,
resolveProfileDir,
writeProfileManifest,
type Profile,
type ProfileLayer,
type SkippedBundle,
type ProfileManifest,
type LinkedRoot,
type RuntimeResolutionOptions,
+28 -18
View File
@@ -98,6 +98,27 @@ export interface Profile {
patchPath: string
/** The profile's own patches; empty when the file is absent. */
patches: PatchOptions[]
/** Selected bundles that contributed no layer, in `dsh.profile.bundles` order, with why. */
skippedBundles: SkippedBundle[]
}
/** A selected bundle the profile could not load, or whose own DSH peers the profile does not exempt. */
export interface SkippedBundle {
/** The bundle's package name from `dsh.profile.bundles`. */
packageName: string
/** The resolution, manifest, compatibility, or patch-loading failure. */
reason: string
}
/**
* Print each skipped bundle once; loading never prints, so launchers call this once per start.
* @param binName - the diagnostic prefix.
* @param profile - the loaded profile.
*/
export function reportSkippedBundles(binName: string, profile: Pick<Profile, 'skippedBundles'>): void {
for (const { packageName, reason } of profile.skippedBundles) {
process.stderr.write(`${binName}: skipping profile bundle ${JSON.stringify(packageName)}: ${reason}\n`)
}
}
/** One package the runtime resolution supplies at the interception layer. */
@@ -413,7 +434,7 @@ export async function createRuntimeResolution(
const profilesDir = join(home, PROFILES_DIR)
const manifest = readOptionalProfileManifest(profile)
const { packageNames, packageDirs, declarers, versions } = collectInstallationScopePackages(
installAnchor, skippedProfileBundles(profile, manifest),
installAnchor, new Set(profile?.skippedBundles.map(skipped => skipped.packageName)),
)
const profileDeclarers = new Map<string, string>()
const profileVersions = new Map<string, string | undefined>()
@@ -452,18 +473,6 @@ function readOptionalProfileManifest(profile: Profile | undefined): ProfileManif
}
}
/**
* Identify selected bundles that did not produce a loaded layer.
* @param profile - loaded profile, when present.
* @param manifest - its parsed manifest, when present.
* @returns selected bundle names missing from the loaded layers, for resolution and diagnostics.
*/
export function skippedProfileBundles(profile: Profile | undefined, manifest: ProfileManifest | undefined): ReadonlySet<string> {
const selected = manifest?.dsh?.profile?.bundles ?? []
const loaded = new Set(profile?.layers.map(layer => layer.packageName))
return new Set(selected.filter(name => !loaded.has(name)))
}
/** Return installed direct dependencies that Node resolves before profile fallback. */
function installedProfilePackageNames(profile: Profile, manifest: ProfileManifest | undefined): string[] {
if (manifest === undefined) return []
@@ -634,8 +643,8 @@ export function resolveBundleDir(
* Load an already initialized profile directory without resolving it through
* the shared Harness home. This is used by application-owned profiles whose
* package project and lifecycle belong to that application.
* Unreadable bundles, and bundles whose own dsh peers the profile does not exempt, are reported
* on stderr and skipped without changing the manifest.
* Unreadable bundles, and bundles whose own dsh peers the profile does not exempt, are skipped
* without changing the manifest and listed in `skippedBundles`; nothing is printed.
* @param binName - the diagnostic prefix on thrown errors.
* @param dir - absolute profile package directory.
* @param installAnchor - absolute path of the owning dsh app's package.json.
@@ -651,6 +660,7 @@ export function loadProfileDirectory(
const manifest = readProfileManifest(binName, dir)
const bundles = manifest.dsh?.profile?.bundles ?? []
const layers: ProfileLayer[] = []
const skippedBundles: SkippedBundle[] = []
const exemptions = bundles.length === 0 ? {} : readProfileVersionExemptions(dir)
for (const packageName of bundles) {
try {
@@ -667,20 +677,20 @@ export function loadProfileDirectory(
const patches = patchPaths.flatMap(patchPath => loadOverlayPatches(binName, patchPath))
layers.push({ packageName, packageDir, patchPaths, patches })
} catch (error) {
process.stderr.write(`${binName}: skipping profile bundle ${JSON.stringify(packageName)}: ${String(error)}\n`)
skippedBundles.push({ packageName, reason: String(error) })
}
}
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
const patches = options.userLayer !== false && existsSync(patchPath)
? loadOverlayPatches(binName, patchPath)
: []
return { name: basename(dir), dir, layers, patchPath, patches }
return { name: basename(dir), dir, layers, patchPath, patches, skippedBundles }
}
/**
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch
* layer and parse the profile's own patch file. Unreadable or incompatible bundles
* are reported on stderr and skipped; profile manifest and user patch errors still throw.
* are skipped and listed in `skippedBundles`; profile manifest and user patch errors still throw.
* @param binName - the diagnostic prefix on thrown errors.
* @param name - the profile name.
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
@@ -36,7 +36,7 @@ const importModule = vi.fn(async (name: string): Promise<unknown> => {
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'dsh-config-schema-'))
profile = { name: 'test', dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [], layers: [] }
profile = { skippedBundles: [], name: 'test', dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [], layers: [] }
resolution = { profilesDir: dir, profileDir: dir, localPackageNames: [], entries: [], linkedRoots: [] }
const loader = ModuleLoader.fromInternal()
if (loader === undefined) throw new Error('test requires supported Node internals')
@@ -70,7 +70,7 @@ function validates(dump: ConfigSchemaDump, value: unknown, definition = 'entryLi
describe('generateConfigSchema', () => {
it('owns ordered composition, skipped-bundle diagnostics, and runtime resolution without mutating layers', async () => {
profile.layers = [{ packageName: 'loaded', packageDir: dir, patchPaths: [join(dir, 'bundle.yml')], patches: [] }]
writeFileSync(join(dir, 'package.json'), JSON.stringify({ dsh: { profile: { bundles: ['loaded', 'missing'] } } }))
profile.skippedBundles = [{ packageName: 'missing', reason: 'Error: cannot resolve profile bundle "missing"' }]
modules.set('server', { Config: Schema.string() })
const layers: PatchOptions[][] = [
[{ insert: [{ ...row('cordis:group', []), id: 'group', group: true }] }],
@@ -80,7 +80,7 @@ describe('generateConfigSchema', () => {
const original = structuredClone(layers)
const installAnchor = join(dir, 'installation.json')
const resolve = vi.spyOn(profileOperations, 'createRuntimeResolution').mockResolvedValue(resolution)
const result = await generateConfigSchema('dsh', profile, layers, installAnchor)
const result = await generateConfigSchema(profile, layers, installAnchor)
expect(resolve).toHaveBeenCalledExactlyOnceWith({ installAnchor, profile })
expect(result['x-cordis'].entries.map(entry => [entry.path, entry.name])).toEqual([
['/0', 'cordis:group'], ['/0/config/0', 'server'],
@@ -94,17 +94,9 @@ describe('generateConfigSchema', () => {
expect(dispose).toHaveBeenCalledOnce()
})
it('requires prepared profile metadata before creating an interception', async () => {
const resolve = vi.spyOn(profileOperations, 'createRuntimeResolution').mockResolvedValue(resolution)
await expect(generateConfigSchema('dsh', profile, [], join(dir, 'installation.json'))).rejects.toThrow()
expect(resolve).not.toHaveBeenCalled()
expect(installRuntimeInterception).not.toHaveBeenCalled()
})
it('propagates resolution setup failure without starting collection', async () => {
writeFileSync(join(dir, 'package.json'), '{}')
vi.spyOn(profileOperations, 'createRuntimeResolution').mockRejectedValue(new Error('resolution setup failed'))
await expect(generateConfigSchema('dsh', profile, [], join(dir, 'installation.json'))).rejects.toThrow('resolution setup failed')
await expect(generateConfigSchema(profile, [], join(dir, 'installation.json'))).rejects.toThrow('resolution setup failed')
expect(installRuntimeInterception).not.toHaveBeenCalled()
})
})
@@ -470,7 +470,7 @@ class MatrixFixture {
}
async observe(mode: 'current' | 'native' = 'current'): Promise<MatrixRow> {
const profile: Profile = { name: 'web', dir: this.profileDir, layers: [],
const profile: Profile = { skippedBundles: [], name: 'web', dir: this.profileDir, layers: [],
patchPath: join(this.profileDir, 'cordis.patch.yml'), patches: [] }
const resolution = mode === 'current'
? await createRuntimeResolution({ installAnchor: join(this.installDir, 'package.json'), profile, home: this.home }) : undefined
@@ -157,7 +157,7 @@ function fixture(name = '@deepseek-ai/dsh-core'): {
root,
installAnchor,
installed,
profile: {
profile: { skippedBundles: [],
name: 'web',
dir: profileDir,
layers: [],
+17 -17
View File
@@ -25,6 +25,7 @@ import {
readProfileManifest,
readProfilePatches,
removeLinkProjections,
reportSkippedBundles,
resolveBundleDir,
resolveProfileDir,
writeProfileManifest,
@@ -80,7 +81,7 @@ function stageProfile(home: string, name: string, bundleAnchor: string): Profile
const dir = resolveProfileDir(name, home)
mkdirSync(dir, { recursive: true })
const packageName = (JSON.parse(readFileSync(bundleAnchor, 'utf8')) as { name: string }).name
return {
return { skippedBundles: [],
name,
dir,
layers: [{
@@ -319,9 +320,6 @@ describe('loadProfile', () => {
const home = tmp()
const dir = resolveProfileDir('demo', home)
initProfile(dir, ['multi', 'broken'])
const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
onTestFinished(() => { warn.mockRestore() })
const profile = loadProfile('t', 'demo', anchor, home)
expect(profile.layers.map(layer => ({ ...layer, patches: layer.patches.length }))).toEqual([{
packageName: 'multi',
@@ -333,9 +331,9 @@ describe('loadProfile', () => {
{ id: 'a', name: pathToFileURL(join(bundleDir, 'local.js')).href, config: { v: 2 } },
{ id: 'b', name: pathToFileURL(join(bundleDir, 'layers', 'local.js')).href },
])
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
'skipping profile bundle "broken": Error: dsh.bundle.patch must be a file path or a list of file paths',
))
expect(profile.skippedBundles).toEqual([
{ packageName: 'broken', reason: 'Error: dsh.bundle.patch must be a file path or a list of file paths' },
])
})
it('auto-initializes only shipped templates and fails loud otherwise', () => {
@@ -412,10 +410,14 @@ describe('loadProfile', () => {
onTestFinished(() => { warn.mockRestore() })
const profile = loadProfile('t', 'demo', anchor, home)
// Loading only records the skip; the launcher reports it once per start.
expect(warn).not.toHaveBeenCalled()
expect(profile.layers.map(layer => layer.packageName)).toEqual(['before', 'after'])
expect(composeEntries(profile.layers.map(layer => layer.patches)))
.toEqual([{ id: 'a', name: 'pkg-a', config: { value: 'after' } }])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipping profile bundle "broken":'))
expect(profile.skippedBundles.map(skipped => skipped.packageName)).toEqual(['broken'])
reportSkippedBundles('t', profile)
expect(warn.mock.calls).toEqual([[`t: skipping profile bundle "broken": ${profile.skippedBundles[0]?.reason}\n`]])
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(saved)
const resolution = await createRuntimeResolution({ installAnchor: anchor, profile, home })
const unavailable = failure === 'missing package' || failure === 'invalid manifest'
@@ -440,13 +442,11 @@ describe('loadProfile', () => {
}))
const dir = resolveProfileDir('demo', tmp())
initProfile(dir, ['guarded', 'kept'])
const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
onTestFinished(() => { warn.mockRestore() })
expect(loadProfileDirectory('dsh', dir, anchor).layers.map(layer => layer.packageName)).toEqual(['kept'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
`skipping profile bundle "guarded": Error: Plugin [email protected] is incompatible with dsh ${getDshRuntimeVersion()}`,
))
const denied = loadProfileDirectory('dsh', dir, anchor)
expect(denied.layers.map(layer => layer.packageName)).toEqual(['kept'])
expect(denied.skippedBundles).toEqual([{ packageName: 'guarded', reason: expect.stringContaining(
`Error: Plugin [email protected] is incompatible with dsh ${getDshRuntimeVersion()}`,
) as string }])
writeFileSync(join(dir, PROFILE_COMPATIBILITY_FILENAME), JSON.stringify({ '[email protected]': [getDshRuntimeVersion()] }))
expect(loadProfileDirectory('dsh', dir, anchor).layers.map(layer => layer.packageName)).toEqual(['guarded', 'kept'])
})
@@ -554,7 +554,7 @@ describe('createRuntimeResolution', () => {
mkdirSync(profileModules, { recursive: true })
const bundleLink = join(profileModules, 'selected-bundle')
symlinkSync(realBundle, bundleLink, 'junction')
const profile: Profile = {
const profile: Profile = { skippedBundles: [],
name: 'symlinked',
dir,
layers: [{
@@ -604,7 +604,7 @@ describe('createRuntimeResolution', () => {
}))
writeFileSync(join(explicitOnly, 'package.json'), JSON.stringify({ name: 'explicit-only' }))
const dir = resolveProfileDir('explicit-roots', home)
const profile: Profile = {
const profile: Profile = { skippedBundles: [],
name: 'explicit-roots',
dir,
layers: ([['bundle-a', bundleA], ['bundle-b', bundleB]] as const).map(([packageName, packageDir]) => ({