feat(bundle): accept an ordered patch file list in dsh.bundle.patch (#4722)

* feat(bundle): accept an ordered patch file list in dsh.bundle.patch

`dsh.bundle.patch` now accepts `string | string[]`. A list names patch
files applied in order as one bundle layer; each file's relative plugin
paths still resolve beside that file. `bundlePatchPaths` in dsh-app-boot
is the one resolver from a declaration to absolute paths, and every
reader uses it: profile loading, plugin-manager reconcile and bundle
rows, the client-runtime roster, and the workspace, cordis-config, and
product-isolation gates. `ProfileLayer.patchPath` becomes `patchPaths`.

* fix(bundle): keep declared patch files package-relative in the workspace gate

path.join produced backslash entries on Windows, so the expected files
list did not match package.json files. bundlePatchFiles returns the
validated declaration as written; bundlePatchPaths joins it.
This commit is contained in:
Turtle authored and GitHub committed 2026-09-20 18:20:24 +08:00
1 parent d495b2e5f2
commit 654caa4bbd
25 files changed
+166 -73

No files matched your search

+1 -1
View File
@@ -125,7 +125,7 @@ describe('runProfile with an application-owned profile', () => {
name: 'desktop', dir: home, patchPath: profilePatch,
patches: [{ id: 'target', config: { profile: true, priority: 'profile' } }],
layers: [{
packageName: 'test-bundle', packageDir: home, patchPath: join(home, 'bundle.yml'),
packageName: 'test-bundle', packageDir: home, patchPaths: [join(home, 'bundle.yml')],
patches: [{ insert: [
{ id: 'target', name: 'target', config: { bundle: true, priority: 'bundle' } },
{ id: 'session-telemetry-otel', name: 'telemetry' },
+1 -1
View File
@@ -726,7 +726,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
return {
packageName: manifest.name,
packageDir,
patchPath: join(packageDir, 'cordis.patch.yml'),
patchPaths: [join(packageDir, 'cordis.patch.yml')],
patches: [],
}
}))
+2 -2
View File
@@ -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 docs/user/develop/basic/publish.md
publish.md: 17b0cb80f9cf385773ae9e1f4d7061ec1df9c1a2
publish.zh.md: 3cc65af58d34028a6bff03455451153e2c7f439b
publish.md: cde14e1ac25de709e738329f29bb68b124c32d25
publish.zh.md: e588c52ba12e0c9afe027be44a94c216ab4c5da2
+1 -1
View File
@@ -61,7 +61,7 @@ Create `hello-plugin/cordis.patch.yml`. The patch is a YAML array like the `--pa
name: dsh-hello-plugin
```
A package without the `dsh.bundle` declaration still installs, but only as a plain dependency: `dsh plugin` prints a warning and activates no layer. Use that package format for a library that plugin packages import rather than a plugin users enable.
`patch` also accepts an ordered list of files, for example `["./base.patch.yml", "./web.patch.yml"]`; the launcher applies them in that order as one layer, and each file's relative plugin paths resolve beside that file. A package without the `dsh.bundle` declaration still installs, but only as a plain dependency: `dsh plugin` prints a warning and activates no layer. Use that package format for a library that plugin packages import rather than a plugin users enable.
### The profile manifest
+1 -1
View File
@@ -61,7 +61,7 @@ export function apply() {
name: dsh-hello-plugin
```
没有 `dsh.bundle` 声明的包仍然可以安装,但只作为普通依赖:`dsh plugin` 会打印警告,且不激活任何层。如果一个库供插件包 import,而不是供用户启用,就使用这种包格式。
`patch` 也接受一个有序的文件列表,例如 `["./base.patch.yml", "./web.patch.yml"]`;launcher 按该顺序把它们作为同一层应用,每个文件中的相对插件路径相对于该文件解析。没有 `dsh.bundle` 声明的包仍然可以安装,但只作为普通依赖:`dsh plugin` 会打印警告,且不激活任何层。如果一个库供插件包 import,而不是供用户启用,就使用这种包格式。
### profile manifest
+2 -2
View File
@@ -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/boot/app-boot/README.md
README.md: 6f24c46485abe3761994ef7aacbea90840e01bfb
README.zh.md: b3a8aaa2746848bcc347291dad27f015e8a004e3
README.md: ef98b3fd98829c14dc6614de321cc02837c8eb0c
README.zh.md: 2e0ce42936701ddd94d82dae5fcfee5d33e326e0
+1 -1
View File
@@ -47,7 +47,7 @@ With that entry point, startup keeps every plugin that can activate. An enabled
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`. 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 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.
Your machine-local preferences also live in the Harness home:
+1 -1
View File
@@ -47,7 +47,7 @@ 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` 组成。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 加载失败时会输出诊断并跳过该组合包,不改变其选择状态。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
你的机器本地偏好同样位于 harness home 中:
+2
View File
@@ -39,6 +39,8 @@ export {
createRuntimeResolution,
DEFAULT_PROFILE_BUNDLES,
OPTIONAL_BUNDLES,
bundlePatchFiles,
bundlePatchPaths,
initProfile,
removeLinkProjections,
loadProfile,
+40 -12
View File
@@ -7,10 +7,11 @@
* `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml`
* (the user's own patch layer, applied after every bundle layer). Bundles are
* npm packages whose manifest declares
* `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the tree is
* composed by applying each bundle's patch list in `dsh.profile.bundles` order over
* an empty entry list, then the profile's own patches, then any launcher
* layers (`--patch` files and flag-derived patches).
* `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` (one file, or an
* ordered list of files); the tree is composed by applying each bundle's patch
* lists in `dsh.profile.bundles` order over an empty entry list, then the
* profile's own patches, then any launcher layers (`--patch` files and
* flag-derived patches).
*
* Module resolution is two-anchor by construction: a bundle name resolves
* first from the dsh installation (the launcher's own package), then from the
@@ -26,7 +27,7 @@ import { basename, dirname, join, resolve, sep } from 'node:path'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import type { DshPackageManifest } from '@deepseek-ai/dsh-package-manifest'
import type { DshBundleManifest, DshPackageManifest } from '@deepseek-ai/dsh-package-manifest'
import { loadOverlayPatches } from './index.ts'
import { realModuleDirectory } from './profile-resolution/legacy-links.ts'
@@ -45,15 +46,41 @@ export interface ProfileTemplate {
/** Package metadata accepted by the profile reader; local profiles need no published identity. */
export type ProfileManifest = Partial<DshPackageManifest>
/**
* The patch files a bundle declares, as written: one file for a string
* `patch`, the listed files in order for an array.
* @param bundle - the bundle's `dsh.bundle` declaration, as read from package.json.
* @returns the package-relative patch file paths in application order.
* @throws {Error} when `patch` is neither a string nor a list of strings.
*/
export function bundlePatchFiles(bundle: DshBundleManifest): string[] {
const declared = typeof bundle.patch === 'string' ? [bundle.patch] : bundle.patch
if (!Array.isArray(declared) || !declared.every(file => typeof file === 'string')) {
throw new Error('dsh.bundle.patch must be a file path or a list of file paths')
}
return declared
}
/**
* Resolve a bundle declaration to its ordered absolute patch files.
* @param packageDir - absolute directory of the bundle package.
* @param bundle - the bundle's `dsh.bundle` declaration, as read from package.json.
* @returns the absolute patch file paths in application order.
* @throws {Error} when `patch` is neither a string nor a list of strings.
*/
export function bundlePatchPaths(packageDir: string, bundle: DshBundleManifest): string[] {
return bundlePatchFiles(bundle).map(file => join(packageDir, file))
}
/** One resolved bundle layer of a profile. */
export interface ProfileLayer {
/** The bundle's package name, as listed in `dsh.profile.bundles`. */
packageName: string
/** Absolute directory of the resolved bundle package. */
packageDir: string
/** Absolute path of the bundle's patch file. */
patchPath: string
/** The parsed patch list. */
/** Absolute paths of the bundle's patch files, in application order. */
patchPaths: readonly string[]
/** The parsed patch lists of every file, concatenated in application order. */
patches: PatchOptions[]
}
@@ -567,12 +594,13 @@ export function loadProfileDirectory(
try {
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
const bundleManifest = readProfileManifest(binName, packageDir)
const declared = bundleManifest.dsh?.bundle?.patch
if (declared === undefined) {
const bundle = bundleManifest.dsh?.bundle
if (bundle === undefined) {
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
}
const patchPath = join(packageDir, declared)
layers.push({ packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) })
const patchPaths = bundlePatchPaths(packageDir, bundle)
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`)
}
@@ -287,7 +287,7 @@ describe('runtime resolution', { concurrent: false }, () => {
pkg(dirname(f.installAnchor), '@deepseek-ai/dsh', 0)
f.profile.layers.push({
packageName: 'bridge', packageDir: f.installed,
patchPath: join(f.installed, 'cordis.patch.yml'), patches: [],
patchPaths: [join(f.installed, 'cordis.patch.yml')], patches: [],
})
}
expect(createRequire(join(bridge, 'node_modules', 'middle', 'package.json'))('leaf')).toEqual({ marker: 1 })
@@ -325,12 +325,12 @@ describe('runtime resolution', { concurrent: false }, () => {
f.profile.layers.push({
packageName: 'test-bundle',
packageDir: bundleDir,
patchPath: join(bundleDir, 'cordis.patch.yml'),
patchPaths: [join(bundleDir, 'cordis.patch.yml')],
patches: [],
}, {
packageName: 'later-bundle',
packageDir: laterBundle,
patchPath: join(laterBundle, 'cordis.patch.yml'),
patchPaths: [join(laterBundle, 'cordis.patch.yml')],
patches: [],
})
@@ -954,7 +954,7 @@ describe('runtime resolution', { concurrent: false }, () => {
f.profile.layers.push({
packageName: 'test-bundle',
packageDir: bundleDir,
patchPath: join(bundleDir, 'cordis.patch.yml'),
patchPaths: [join(bundleDir, 'cordis.patch.yml')],
patches: [],
})
const registration = installRuntimeInterception(await resolutionOf(f))
@@ -1234,7 +1234,7 @@ describe('runtime resolution', { concurrent: false }, () => {
pkg(bundle, 'my-bundle', 1, { 'bridge': '*' })
f.profile.layers.push({
packageName: 'my-bundle', packageDir: bundle,
patchPath: join(bundle, 'cordis.patch.yml'), patches: [],
patchPaths: [join(bundle, 'cordis.patch.yml')], patches: [],
})
const target = join(f.root, 'old-dsh', 'node_modules', 'bridge')
const owned = join(f.profile.dir, '.dsh-module-fallback', 'node_modules', 'bridge')
@@ -1263,7 +1263,7 @@ describe('runtime resolution', { concurrent: false }, () => {
pkg(bundleLib, 'bridge', 2)
f.profile.layers.push({
packageName: 'my-bundle', packageDir: bundleDir,
patchPath: join(bundleDir, 'cordis.patch.yml'), patches: [],
patchPaths: [join(bundleDir, 'cordis.patch.yml')], patches: [],
})
file(join(f.profile.dir, 'package.json'), JSON.stringify({
name: 'dsh-profile-web', private: true, dependencies: { 'my-plugin': '*', 'my-bundle': '*' },
+37 -3
View File
@@ -84,7 +84,7 @@ function stageProfile(home: string, name: string, bundleAnchor: string): Profile
layers: [{
packageName,
packageDir: join(bundleAnchor, '..'),
patchPath: join(bundleAnchor, '..', 'cordis.patch.yml'),
patchPaths: [join(bundleAnchor, '..', 'cordis.patch.yml')],
patches: [],
}],
patchPath: join(dir, PROFILE_PATCH_FILENAME),
@@ -302,6 +302,40 @@ describe('loadProfile', () => {
expect(bare.layers).toEqual([])
})
it('applies a dsh.bundle.patch list in order, anchoring inserted paths beside each file', () => {
const anchor = stageInstallation({ 'multi': { patch: '[]\n' }, 'broken': { patch: '[]\n' } })
const bundleDir = join(anchor, '..', 'node_modules', 'multi')
mkdirSync(join(bundleDir, 'layers'), { recursive: true })
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
name: 'multi', version: '0.0.0', type: 'module', main: './index.js',
dsh: { bundle: { patch: ['./first.patch.yml', './layers/second.patch.yml'] } },
}))
writeFileSync(join(bundleDir, 'first.patch.yml'), '- insert:\n - id: a\n name: ./local.js\n config: { v: 1 }\n')
writeFileSync(join(bundleDir, 'layers', 'second.patch.yml'), '- id: a\n config: { v: 2 }\n- insert:\n - id: b\n name: ./local.js\n')
const brokenManifest = join(anchor, '..', 'node_modules', 'broken', 'package.json')
writeFileSync(brokenManifest, JSON.stringify({ name: 'broken', version: '0.0.0', dsh: { bundle: { patch: [1] } } }))
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',
packageDir: bundleDir,
patchPaths: [join(bundleDir, 'first.patch.yml'), join(bundleDir, 'layers', 'second.patch.yml')],
patches: 3,
}])
expect(composeEntries(profile.layers.map(layer => layer.patches))).toEqual([
{ 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',
))
})
it('auto-initializes only shipped templates and fails loud otherwise', () => {
const anchor = stageInstallation({})
const home = tmp()
@@ -502,7 +536,7 @@ describe('createRuntimeResolution', () => {
layers: [{
packageName: 'selected-bundle',
packageDir: bundleLink,
patchPath: join(bundleLink, 'cordis.patch.yml'),
patchPaths: [join(bundleLink, 'cordis.patch.yml')],
patches: [],
}],
patchPath: join(dir, PROFILE_PATCH_FILENAME),
@@ -552,7 +586,7 @@ describe('createRuntimeResolution', () => {
layers: ([['bundle-a', bundleA], ['bundle-b', bundleB]] as const).map(([packageName, packageDir]) => ({
packageName,
packageDir,
patchPath: join(packageDir, 'cordis.patch.yml'),
patchPaths: [join(packageDir, 'cordis.patch.yml')],
patches: [],
})),
patchPath: join(dir, PROFILE_PATCH_FILENAME),
+7 -7
View File
@@ -13,7 +13,7 @@ import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
import {
readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries,
reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES,
reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths,
} from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-hmr'
import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot'
@@ -475,8 +475,8 @@ export class PluginManager extends TypertRemoteService {
name = target
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
if (manifest?.dsh?.bundle?.patch === undefined) throw new ManagementFailure('not-bundle')
loadOverlayPatches('dsh', join(dir, manifest.dsh.bundle.patch))
if (manifest?.dsh?.bundle === undefined) throw new ManagementFailure('not-bundle')
for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) loadOverlayPatches('dsh', file)
} catch (error) {
// pnpm has exited by now, so the files it rewrote go back as they were.
await this.restoreFiles(files)
@@ -556,11 +556,11 @@ export class PluginManager extends TypertRemoteService {
/** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
private declaredRows(name: string, info: ProfileManifest): Pick<BundleInfo, 'rows' | 'overrides'> {
const patch = info.dsh?.bundle?.patch
const bundle = info.dsh?.bundle
/* v8 ignore next -- bundleManifest answers only manifests that declare a patch */
if (patch === undefined) return { rows: [], overrides: [] }
if (bundle === undefined) return { rows: [], overrides: [] }
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
const patches: PatchOptions[] = loadOverlayPatches('dsh', join(dir, patch))
const patches: PatchOptions[] = bundlePatchPaths(dir, bundle).flatMap(file => loadOverlayPatches('dsh', file))
// One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last.
const live = new Map<string, { entryId: PluginEntryId; baseUrl: string | undefined }>()
for (const entry of this.ctx.loader.entries()) {
@@ -656,7 +656,7 @@ export class PluginManager extends TypertRemoteService {
const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
if (info?.dsh?.bundle === undefined) return []
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
return flatten(composeEntries([loadOverlayPatches('dsh', join(dir, info.dsh.bundle.patch))]))
return flatten(composeEntries([bundlePatchPaths(dir, info.dsh.bundle).flatMap(file => loadOverlayPatches('dsh', file))]))
}
private protectsManager(name: string): boolean {
@@ -5,7 +5,7 @@ import { join, resolve } from 'node:path'
import { execa } from 'execa'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import {
DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileManifest,
DEFAULT_PROFILE_BUNDLES, bundlePatchPaths, initProfile, PROFILE_TEMPLATES, readProfileManifest,
resolveBundleDir, resolveProfileDir, loadOverlayPatches, type ProfileManifest,
} from '@deepseek-ai/dsh-app-boot'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
@@ -86,7 +86,7 @@ async function reconcile(before: ProfileManifest, dir: string, anchor: string, o
options.onOutput?.(`dsh: warning: ${name} declares no dsh.bundle — installed as a plain dependency, not a profile layer\n`, 'stderr')
continue
}
loadOverlayPatches('dsh', join(resolveBundleDir('dsh', name, anchor, dir), metadata.dsh.bundle.patch))
for (const file of bundlePatchPaths(resolveBundleDir('dsh', name, anchor, dir), metadata.dsh.bundle)) loadOverlayPatches('dsh', file)
if (!bundles.includes(name)) {
bundles.push(name)
}
@@ -1,6 +1,6 @@
/**
* The browser roster of a `dsh --profile`, read from its bundle patch files
* the way the launcher composes them: each bundle's `dsh.bundle.patch` list is
* the way the launcher composes them: each bundle's `dsh.bundle.patch` file list is
* parsed with the include plugin's YAML dialect (`entryListSchema`) and
* composed by its `applyEntryPatches`; every enabled row whose package
* declares `dsh.client.platform === 'web'` becomes a roster row carrying that
@@ -86,12 +86,18 @@ export function bundleRoster(
function readLayer(bundle: string, anchor: string): BundleLayer {
const manifestPath = locateManifest([anchor], bundle)
if (manifestPath === undefined) throw new Error(`client-test-runtime: cannot resolve bundle ${bundle} from ${anchor}`)
const patch = readManifest(manifestPath).dsh?.bundle?.patch
if (typeof patch !== 'string') throw new Error(`client-test-runtime: bundle ${bundle} declares no dsh.bundle.patch in ${manifestPath}`)
const file = join(dirname(manifestPath), patch)
const parsed: unknown = yaml.load(readFileSync(file, 'utf8'), { schema: entryListSchema })
if (!Array.isArray(parsed)) throw new Error(`client-test-runtime: ${file} must be a top-level list of patches`)
return { manifestPath, patches: parsed as PatchOptions[] }
const declared = readManifest(manifestPath).dsh?.bundle?.patch
const files = typeof declared === 'string' ? [declared] : declared
if (!Array.isArray(files) || !files.every(file => typeof file === 'string')) {
throw new Error(`client-test-runtime: bundle ${bundle} declares no dsh.bundle.patch file list in ${manifestPath}`)
}
const patches = files.flatMap((patch) => {
const file = join(dirname(manifestPath), patch)
const parsed: unknown = yaml.load(readFileSync(file, 'utf8'), { schema: entryListSchema })
if (!Array.isArray(parsed)) throw new Error(`client-test-runtime: ${file} must be a top-level list of patches`)
return parsed as PatchOptions[]
})
return { manifestPath, patches }
}
/** One Loader row with its ancestor and own disable conditions, in outer-to-inner order. */
@@ -122,6 +122,15 @@ describe('bundleRoster on a scratch installation', () => {
expect(scratch.roster(['@t/base'])).toEqual(['@t/a', '@t/c'])
})
it('concatenates a dsh.bundle.patch list in order', () => {
scratch.web('@t/listed')
scratch.pkg('@t/list', { dsh: { bundle: { patch: ['./first.yml', './second.yml'] } } }, {
'first.yml': '- insert:\n - id: listed\n name: \'@t/listed\'\n disabled: true\n',
'second.yml': '- id: listed\n disabled: false\n',
})
expect(scratch.roster(['@t/list'])).toEqual(['@t/listed'])
})
it('descends into Loader groups and lets a disabled group disable every row beneath it', () => {
scratch.web('@t/grouped')
scratch.web('@t/grouped-off')
@@ -243,7 +252,9 @@ describe('bundleRoster on a scratch installation', () => {
it('fails loud on a bundle that does not resolve, declares no patch, or whose patch is not a list', () => {
expect(() => scratch.roster(['@t/missing'])).toThrow('cannot resolve bundle @t/missing from')
scratch.pkg('@t/no-patch', { dsh: {} })
expect(() => scratch.roster(['@t/no-patch'])).toThrow('bundle @t/no-patch declares no dsh.bundle.patch in')
expect(() => scratch.roster(['@t/no-patch'])).toThrow('bundle @t/no-patch declares no dsh.bundle.patch file list in')
scratch.pkg('@t/bad-patch-list', { dsh: { bundle: { patch: [1] } } })
expect(() => scratch.roster(['@t/bad-patch-list'])).toThrow('bundle @t/bad-patch-list declares no dsh.bundle.patch file list in')
scratch.bundle('@t/not-a-list', 'insert: []\n')
expect(() => scratch.roster(['@t/not-a-list'])).toThrow('must be a top-level list of patches')
})
@@ -45,7 +45,7 @@ function overlayModuleLayers(path: string, patches: readonly PatchOptions[]): Pr
return [...packages].map(([name, packageDir], index) => ({
packageName: `test-overlay:${index}:${name}`,
packageDir,
patchPath: path,
patchPaths: [path],
patches: [],
}))
}
@@ -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/util/package-manifest/README.md
README.md: 95dbbb729792872a9d41716db4fb4f2e8ac763b5
README.zh.md: 02c21647f085af09472cca9f8c2e1a06be4e1124
README.md: 7c4c4935412ca481dc5db2f9c182abf471faf269
README.zh.md: 58a150ae32a7129c1f213f9268869b99ada99655
+1 -1
View File
@@ -43,7 +43,7 @@ const manifest: DshPackageManifest = {
}
```
`DshPackageManifest` describes the package.json fields used by DSH, with required `name` and `version`; it is not an exhaustive npm schema. Local profile readers use `Partial<DshPackageManifest>` because profiles need no published version. `DshManifest` describes only public author fields under `dsh`. TypeScript checks the example and erases `import type`; these interfaces do not parse JSON or write a file.
`DshPackageManifest` describes the package.json fields used by DSH, with required `name` and `version`; it is not an exhaustive npm schema. Local profile readers use `Partial<DshPackageManifest>` because profiles need no published version. `DshManifest` describes only public author fields under `dsh`. `DshBundleManifest.patch` is one patch file path or an ordered list of them, each relative to the package root; the launcher applies a list in order as one bundle layer. TypeScript checks the example and erases `import type`; these interfaces do not parse JSON or write a file.
The following metadata fields are optional. Omitting them leaves the format version or compatible host versions undeclared; readers do not infer defaults.
+1 -1
View File
@@ -43,7 +43,7 @@ const manifest: DshPackageManifest = {
}
```
`DshPackageManifest` 描述 DSH 使用的 package.json 字段,其中 `name` 和 `version` 必填;它不是完整的 npm schema(模式)。本地 profile 读取方使用 `Partial<DshPackageManifest>`,因为 profile 无需发布版本。`DshManifest` 仅描述 `dsh` 下的公共作者字段。TypeScript 检查示例并删除 `import type`;这些接口不解析 JSON,也不写入文件。
`DshPackageManifest` 描述 DSH 使用的 package.json 字段,其中 `name` 和 `version` 必填;它不是完整的 npm schema(模式)。本地 profile 读取方使用 `Partial<DshPackageManifest>`,因为 profile 无需发布版本。`DshManifest` 仅描述 `dsh` 下的公共作者字段。`DshBundleManifest.patch` 是一个 patch 文件路径,或一个有序的路径列表,均相对于包根目录;launcher 按列表顺序把它们作为同一个组合包层应用。TypeScript 检查示例并删除 `import type`;这些接口不解析 JSON,也不写入文件。
以下元数据字段均可选。省略时,格式版本或兼容的宿主版本保持未声明状态;读取方不推断默认值。
+2 -2
View File
@@ -67,8 +67,8 @@ export interface DshEnginesManifest {
/** The configuration layer exported by a bundle package. */
export interface DshBundleManifest {
/** Patch file path relative to the declaring package root. */
patch: string
/** One patch file path, or an ordered list applied in sequence, each relative to the declaring package root. */
patch: string | string[]
}
/** The bundle composition declared by a profile directory. */
@@ -232,6 +232,15 @@ describe('package payload constraints', () => {
'cordis.patch.yml',
'lib/types/**/*.d.ts',
])
expect(expectedDshPackageFiles({
name: '@deepseek-ai/dsh-private-profile',
dsh: { bundle: { patch: ['./cordis.patch.yml', './layers/web.patch.yml'] } },
})).toEqual([
'lib/index.js',
'cordis.patch.yml',
'layers/web.patch.yml',
'lib/types/**/*.d.ts',
])
})
it.each([
+5 -6
View File
@@ -13,7 +13,8 @@ import {
PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES,
} from './experimental-package-policy.ts'
import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts'
import { OPTIONAL_BUNDLES } from '../packages/boot/app-boot/src/profile.ts'
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
import { OPTIONAL_BUNDLES, bundlePatchFiles } from '../packages/boot/app-boot/src/profile.ts'
import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts'
const root = resolve(import.meta.dirname, '..')
@@ -98,9 +99,7 @@ export interface PackageManifest {
dependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
dsh?: {
bundle?: {
patch?: string
}
bundle?: DshBundleManifest
}
}
@@ -222,8 +221,8 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
const target = exportDefault(manifest, resource)
if (target?.startsWith('./') && target.endsWith('.json')) localeFiles.add(target.slice(2))
}
const declaredPatch = manifest.dsh?.bundle?.patch
const bundleFiles = declaredPatch === undefined ? [] : [declaredPatch.replace(/^\.\//, '')]
const bundle = manifest.dsh?.bundle
const bundleFiles = bundle === undefined ? [] : bundlePatchFiles(bundle).map(file => file.replace(/^\.\//, ''))
const extras = [
...bundleFiles,
...(manifest.name ? packageFileExtras[manifest.name] ?? [] : []),
+9 -6
View File
@@ -14,6 +14,8 @@ import { globSync, readFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import { Script } from 'node:vm'
import ts from 'typescript'
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
import { bundlePatchPaths } from '../packages/boot/app-boot/src/profile.ts'
import { cordisConfigFiles } from './cordis-config-files.ts'
import { isCordisGroupEntry, isJsExpr, loadCordisYaml } from './cordis-yaml.ts'
@@ -22,7 +24,7 @@ export interface PackageManifest {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
dsh?: { bundle?: { patch?: string } }
dsh?: { bundle?: DshBundleManifest }
}
export interface PluginReference {
@@ -257,10 +259,11 @@ function validateAppResolution(): string[] {
for (const manifestPath of bundleManifests) {
const bundleDir = manifestPath.replace(/\/package\.json$/, '')
const manifest = readManifest(manifestPath)
const patch = manifest.dsh?.bundle?.patch
if (typeof patch !== 'string') continue
const patchFile = relative(root, resolve(root, bundleDir, patch)).replaceAll('\\', '/')
const references = pluginReferences.filter(reference => reference.file === patchFile)
const bundle = manifest.dsh?.bundle
if (bundle === undefined) continue
const patchFiles = new Set(bundlePatchPaths(resolve(root, bundleDir), bundle)
.map(file => relative(root, file).replaceAll('\\', '/')))
const references = pluginReferences.filter(reference => patchFiles.has(reference.file))
violations.push(...bundlePluginDependencyErrors(manifestPath, manifest, references))
}
return violations
@@ -362,7 +365,7 @@ function packageTestManifestPath(file: string): string | undefined {
*/
export function bundleManifestPaths(repoRoot: string = root): string[] {
return globSync('packages/*/*/package.json', { cwd: repoRoot })
.filter(path => typeof readManifest(path, repoRoot).dsh?.bundle?.patch === 'string')
.filter(path => readManifest(path, repoRoot).dsh?.bundle?.patch !== undefined)
.map(path => path.replaceAll('\\', '/'))
.sort()
}
+7 -6
View File
@@ -12,7 +12,8 @@ import ts from 'typescript'
import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { loadOverlayPatches } from '../packages/boot/app-boot/src/index.ts'
import { composeEntries } from '../packages/boot/app-boot/src/profile.ts'
import { bundlePatchPaths, composeEntries } from '../packages/boot/app-boot/src/profile.ts'
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
import { isCordisGroupEntry, loadCordisYaml } from './cordis-yaml.ts'
import {
collectRuntimeLocalSourceSpecifiers,
@@ -32,7 +33,7 @@ interface Manifest {
optionalDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
devDependencies?: Record<string, string>
dsh?: { bundle?: { patch?: string }; configTrees?: Array<{ path: string }> }
dsh?: { bundle?: DshBundleManifest; configTrees?: Array<{ path: string }> }
}
interface Package {
@@ -238,9 +239,9 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
}
const webLayers = selection.webBundles.flatMap((name) => {
const pkg = packages.get(name)
const patch = pkg?.manifest.dsh?.bundle?.patch
if (pkg === undefined || patch === undefined) return []
return [loadOverlayPatches('verify-default-product-isolation', resolve(pkg.directory, patch))]
const bundle = pkg?.manifest.dsh?.bundle
if (pkg === undefined || bundle === undefined) return []
return [bundlePatchPaths(pkg.directory, bundle).flatMap(file => loadOverlayPatches('verify-default-product-isolation', file))]
})
if (webLayers.length !== selection.webBundles.length) {
failures.push(`${PROFILE_SOURCE}: default Web bundle layers are incomplete`)
@@ -285,7 +286,7 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
dependency(name, range, pkg, `${manifest.name} ${section}`)
}
}
if (manifest.dsh?.bundle?.patch !== undefined) scanConfig(resolve(pkg.directory, manifest.dsh.bundle.patch))
for (const file of manifest.dsh?.bundle === undefined ? [] : bundlePatchPaths(pkg.directory, manifest.dsh.bundle)) scanConfig(file)
for (const tree of manifest.dsh?.configTrees ?? []) {
const treePath = resolve(pkg.directory, tree.path)
const files = existsSync(treePath) && statSync(treePath).isDirectory()