feat(plugins): support platform-aware bundle activation
This commit is contained in:
1 parent
a9f7ea7d52
commit
eb8efe84e4
43 files changed
+662
-70
No files matched your search
@@ -24,8 +24,8 @@
|
||||
en: 4a424cd401556f20
|
||||
zh: 0b34dd18e0db6cea
|
||||
/deepseek-ai-dsh-app-boot/use-this-package/profiles:
|
||||
en: d2d520668668292c
|
||||
zh: b7deeab27032a3ad
|
||||
en: db1d1b9474a58bef
|
||||
zh: 14ad991227af6695
|
||||
/deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration:
|
||||
en: 646a0ccdd7881ad1
|
||||
zh: e3696acb1573dd7b
|
||||
|
||||
@@ -55,6 +55,8 @@ Admission happens only at composition boundaries DSH owns, and a denied row is d
|
||||
|
||||
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.
|
||||
|
||||
A bundle may declare `dsh.bundle.platforms` as a nonempty list of Node Host platform names, such as `["darwin"]`. The launcher validates this declaration before reading the bundle patch. An unsupported Host skips the entire layer, retains its saved selection, and reports the reason through `skippedBundles`; other layers remain effective. Omission permits every platform. Browser clients do not determine Host support.
|
||||
|
||||
Your machine-local preferences also live in the Harness home:
|
||||
|
||||
- **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
|
||||
|
||||
@@ -55,6 +55,8 @@ profile 导入插件前,DSH 会将其声明的 DSH 版本要求与 `getDshRunt
|
||||
|
||||
精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。
|
||||
|
||||
组合包可以用 `dsh.bundle.platforms` 声明非空的 Node Host 平台名称列表,例如 `["darwin"]`。启动器在读取组合包 patch 前验证此声明。Host 不受支持时会跳过整个配置层,保留已保存的选择,并通过 `skippedBundles` 报告原因;其他配置层继续生效。省略此字段时允许所有平台。浏览器所在机器的平台不决定 Host 是否受支持。
|
||||
|
||||
你的机器本地偏好同样位于 harness home 中:
|
||||
|
||||
- **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Host operating-system admission for optional bundle layers. */
|
||||
|
||||
/** A valid bundle declaration excludes the operating system running DSH. */
|
||||
export class UnsupportedBundlePlatformError extends Error {
|
||||
/**
|
||||
* @param platforms Node platform names supported by the bundle.
|
||||
* @param platform Operating system of the DSH Host.
|
||||
*/
|
||||
constructor(platforms: readonly string[], platform: string) {
|
||||
super(`Bundle supports Host platforms ${platforms.join(', ')}; the DSH Host is running ${platform}`)
|
||||
this.name = 'UnsupportedBundlePlatformError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate package metadata and reject an unsupported Host before selecting or composing a bundle.
|
||||
* @param platforms The optional `dsh.bundle.platforms` field read from package.json.
|
||||
* @param platform Operating system of the DSH Host, from Node's `process.platform`.
|
||||
* @throws When the declaration is malformed or excludes the Host; omission allows every platform.
|
||||
*/
|
||||
export function assertBundlePlatform(platforms: unknown, platform: string): void {
|
||||
if (platforms === undefined) return
|
||||
if (!Array.isArray(platforms) || platforms.length === 0
|
||||
|| !platforms.every((value): value is string => typeof value === 'string' && /^[a-z][a-z0-9]*$/.test(value))) {
|
||||
throw new Error('dsh.bundle.platforms must be a nonempty list of Node platform names')
|
||||
}
|
||||
if (!platforms.includes(platform)) throw new UnsupportedBundlePlatformError(platforms, platform)
|
||||
}
|
||||
@@ -55,6 +55,7 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
export { assertBundlePlatform, UnsupportedBundlePlatformError } from './bundle-platform.ts'
|
||||
export {
|
||||
composeEntries,
|
||||
createRuntimeResolution,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Installed profile dependencies and their bundle activation after package-manager operations. */
|
||||
|
||||
import { join } from 'node:path'
|
||||
import { assertBundlePlatform } from './bundle-platform.ts'
|
||||
import { readProfileManifest, resolveBundleDir, writeProfileManifest, type ProfileManifest } from './profile.ts'
|
||||
|
||||
/** Profile directory and installation used by the shared bundle resolver. */
|
||||
@@ -112,7 +113,10 @@ export function reconcileProfilePlugins(options: ProfilePluginLocation & {
|
||||
const previous = after.manifest.dsh?.profile?.bundles ?? []
|
||||
const bundles = previous.filter(name => !(beforeNames.has(name) || afterNames.has(name)) || bundleNames.has(name))
|
||||
for (const dependency of after.dependencies) {
|
||||
if (dependency.bundle && !disabled.has(dependency.name) && !bundles.includes(dependency.name)) bundles.push(dependency.name)
|
||||
if (dependency.bundle && !disabled.has(dependency.name) && !bundles.includes(dependency.name)) {
|
||||
assertBundlePlatform(bundleManifest(options, dependency.name)?.dsh?.bundle?.platforms, process.platform)
|
||||
bundles.push(dependency.name)
|
||||
}
|
||||
}
|
||||
const changed = bundles.length !== previous.length || bundles.some((name, index) => name !== previous[index])
|
||||
const manifest = changed ? writeProfileBundles(options.profileDir, after.manifest, bundles) : after.manifest
|
||||
|
||||
@@ -28,6 +28,7 @@ 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 { DshBundleManifest, DshPackageManifest } from '@deepseek-ai/dsh-package-manifest'
|
||||
import { assertBundlePlatform } from './bundle-platform.ts'
|
||||
import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts'
|
||||
import { readProfileVersionExemptions } from './profile-compatibility.ts'
|
||||
import { loadOverlayPatches } from './index.ts'
|
||||
@@ -221,6 +222,7 @@ export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-bas
|
||||
* [admission](../../../../.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md)).
|
||||
*/
|
||||
export const OPTIONAL_BUNDLES: readonly string[] = [
|
||||
'@deepseek-ai/dsh-cua-bundle',
|
||||
'@deepseek-ai/dsh-experimental-session-search',
|
||||
'@deepseek-ai/dsh-experimental-ralph-bundle',
|
||||
'@deepseek-ai/dsh-experimental-terminal-bundle',
|
||||
@@ -729,7 +731,8 @@ export function resolveBundleDir(
|
||||
* the shared Harness home. This is used by application-owned profiles whose
|
||||
* package project and lifecycle belong to that application.
|
||||
* Retired bundles are removed from the stored bundle list first, rewriting the
|
||||
* manifest when it listed one. Unreadable bundles, and bundles whose own dsh requirements the profile does not exempt, are skipped
|
||||
* manifest when it listed one. Unreadable bundles, unsupported Host platforms, and bundles whose own dsh requirements
|
||||
* 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.
|
||||
@@ -756,6 +759,7 @@ export function loadProfileDirectory(
|
||||
if (bundle === undefined) {
|
||||
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
|
||||
}
|
||||
assertBundlePlatform(bundle.platforms, process.platform)
|
||||
// A bundle is not a plugin row, so row admission never reads its own dsh requirements.
|
||||
const issue = evaluatePluginCompatibility(bundleManifest, exemptions)
|
||||
if (issue !== undefined && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue))
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Bundle declarations admit the Host operating system independently of browser clients. */
|
||||
import { expect, it } from 'vitest'
|
||||
import { assertBundlePlatform, UnsupportedBundlePlatformError } from '../src/bundle-platform.ts'
|
||||
|
||||
it.each(['darwin', 'win32', 'linux'])('allows undeclared platforms and an explicit %s Host', (platform) => {
|
||||
expect(() => { assertBundlePlatform(undefined, platform) }).not.toThrow()
|
||||
expect(() => { assertBundlePlatform([platform], platform) }).not.toThrow()
|
||||
expect(() => { assertBundlePlatform(['darwin', 'win32', 'linux'], platform) }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each(['win32', 'linux'])('rejects macOS-only bundles on %s Hosts', (platform) => {
|
||||
expect(() => { assertBundlePlatform(['darwin'], platform) }).toThrow(UnsupportedBundlePlatformError)
|
||||
expect(() => { assertBundlePlatform(['darwin'], platform) }).toThrow(`the DSH Host is running ${platform}`)
|
||||
})
|
||||
|
||||
it.each([null, true, 'darwin', [], [1], [''], ['Darwin'], [' darwin'], ['darwin', false]].map(platforms => ({ platforms })))('rejects malformed platform metadata $platforms', ({ platforms }) => {
|
||||
expect(() => { assertBundlePlatform(platforms, 'darwin') }).toThrow('dsh.bundle.platforms must be a nonempty list')
|
||||
})
|
||||
@@ -31,6 +31,18 @@ function installed(location: ProfilePluginLocation, name: string, bundle = true)
|
||||
})
|
||||
}
|
||||
|
||||
it('refuses automatic activation on an unsupported Host without changing the bundle selection', () => {
|
||||
const location = fixture()
|
||||
const before = readProfilePlugins(location)
|
||||
writeManifest(location.profileDir, { dependencies: { restricted: '1.0.0' }, dsh: { profile: { bundles: [] } } })
|
||||
writeManifest(join(location.profileDir, 'node_modules/restricted'), { name: 'restricted', dsh: { bundle: {
|
||||
patch: 'unused.yml', platforms: [process.platform === 'darwin' ? 'win32' : 'darwin'],
|
||||
} } })
|
||||
const saved = readFileSync(join(location.profileDir, 'package.json'), 'utf8')
|
||||
expect(() => reconcileProfilePlugins({ ...location, before, preserveDisabled: true })).toThrow('Host is running')
|
||||
expect(readFileSync(join(location.profileDir, 'package.json'), 'utf8')).toBe(saved)
|
||||
})
|
||||
|
||||
describe('profile plugin inventory', () => {
|
||||
it('uses dependency alias keys, installed versions, and the loader installation precedence', () => {
|
||||
const location = fixture()
|
||||
|
||||
@@ -47,7 +47,7 @@ const tmp = (): string => {
|
||||
|
||||
/** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */
|
||||
function stageInstallation(
|
||||
bundles: Record<string, { patch?: string; deps?: Record<string, string> }>,
|
||||
bundles: Record<string, { patch?: string; deps?: Record<string, string>; platforms?: string[] }>,
|
||||
appName = 'dsh-app',
|
||||
): string {
|
||||
const root = tmp()
|
||||
@@ -64,7 +64,7 @@ function stageInstallation(
|
||||
type: 'module',
|
||||
main: './index.js',
|
||||
dependencies: spec.deps ?? {},
|
||||
...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } },
|
||||
...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml', ...spec.platforms === undefined ? {} : { platforms: spec.platforms } } } },
|
||||
}))
|
||||
writeFileSync(join(dir, 'index.js'), `export const packageName = ${JSON.stringify(name)}\n`)
|
||||
if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch)
|
||||
@@ -76,6 +76,24 @@ function stageInstallation(
|
||||
return join(appDir, 'package.json')
|
||||
}
|
||||
|
||||
it('skips an unsupported saved bundle before composing any runtime overrides and retains the selection', () => {
|
||||
const unsupported = process.platform === 'darwin' ? 'win32' : 'darwin'
|
||||
const anchor = stageInstallation({
|
||||
base: { patch: '- insert:\n - id: ptc-runtime\n name: runtime\n config: { executionMode: isolated }\n' },
|
||||
restricted: { platforms: [unsupported], patch: '- id: ptc-runtime\n config: { executionMode: persistent-in-memory }\n' },
|
||||
})
|
||||
const dir = join(tmp(), 'profile')
|
||||
initProfile(dir, ['base', 'restricted'])
|
||||
const before = readFileSync(join(dir, 'package.json'), 'utf8')
|
||||
const profile = loadProfileDirectory('test', dir, anchor)
|
||||
expect(profile.layers.map(layer => layer.packageName)).toEqual(['base'])
|
||||
expect(profile.skippedBundles).toHaveLength(1)
|
||||
expect(profile.skippedBundles[0]?.packageName).toBe('restricted')
|
||||
expect(profile.skippedBundles[0]?.reason).toContain(`Host is running ${process.platform}`)
|
||||
expect(composeEntries(profile.layers.map(layer => layer.patches))).toMatchObject([{ id: 'ptc-runtime', config: { executionMode: 'isolated' } }])
|
||||
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
|
||||
})
|
||||
|
||||
/** Represent one resolved external bundle as a loaded profile layer. */
|
||||
function stageProfile(home: string, name: string, bundleAnchor: string): Profile {
|
||||
const dir = resolveProfileDir(name, home)
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 87679c51aa3693f5
|
||||
zh: 11684a48a5806afb
|
||||
/deepseek-ai-dsh-plugin-manager/use-this-package:
|
||||
en: 95c1b18f8432090a
|
||||
zh: 213e54a1547a1da1
|
||||
en: 8e8a34186b091682
|
||||
zh: c94654f370a1baa1
|
||||
/deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions:
|
||||
en: 483285658e267dd5
|
||||
zh: 846bec2daac59a96
|
||||
|
||||
@@ -39,6 +39,10 @@ For a deployment without agent presets, enable the tool in the profile patch. Pr
|
||||
|
||||
A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. An override holding only `id` and `disabled` is removed, and none is appended, when the bundle layers and the remaining profile patches already give the entry the requested state, so the profile patch records only departures from the bundles. Home and invocation patches are left out of that comparison; they apply after the profile patch and do not decide what it records. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority.
|
||||
|
||||
Bundle platform declarations are checked before saving an enabled selection. Unsupported Hosts return `unsupported-platform` without applying runtime overrides. The bundle remains visible with its display metadata and can still be deselected. Automatic activation after a package operation uses the same check. The agent-facing next step asks the user to run the bundle on a supported Host.
|
||||
|
||||
Bundles may declare `dsh.bundle.configureOnEnable`, mapping existing profile entry ids to top-level config fields. Explicit enablement merges changed fields into the user patch before the ordinary HMR reconciliation, preserving unrelated settings. Missing or ambiguous entries, whole-config `!!js` expressions, and conflicting home or launch overlays reject the operation before saving. Express the target config as a literal mapping to allow enablement edits; expressions on fields being set are replaced by the requested literals, while unrelated field expressions are preserved. Configuration anchors remain available to other entries; anchored overrides are retained and followed by the activation override. Matching values cause no rewrite. Disablement retains these settings. The CLI and manual bundle-list edits do not apply these enablement settings.
|
||||
|
||||
A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable.
|
||||
|
||||
`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results.
|
||||
|
||||
@@ -39,6 +39,10 @@ kind: "package-reference"
|
||||
|
||||
插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。如果组合包层和其余 profile patch 已使条目处于所请求的状态,仅含 `id` 与 `disabled` 的覆盖项会被移除,也不会追加覆盖项,因此 profile patch 只记录与组合包不同的设置。该比较不计入 home 和单次启动 patch;它们在 profile patch 之后应用,不决定 profile patch 记录什么。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。
|
||||
|
||||
组合包的平台声明会在保存启用选择前检查。Host 不受支持时返回 `unsupported-platform`,不应用运行时配置覆盖。组合包仍保留显示信息,并允许取消选择。包操作后的自动启用执行同一检查。给模型的下一步说明会提示用户在受支持的 Host 上运行该组合包。
|
||||
|
||||
组合包可声明 `dsh.bundle.configureOnEnable`,将现有 profile 条目 id 映射到配置顶层字段。显式启用先将变化的字段合并到用户 patch,再通过正常 HMR 重组应用,保留无关设置;条目缺失、存在歧义、整段 config 使用 `!!js` 表达式,或 home、启动覆盖层冲突时,写入前拒绝操作。启用设置需要将目标 config 写成普通映射,目标字段上的表达式会被指定值替换,无关字段的表达式保留。配置中的锚点继续供其他条目引用;含锚点的覆盖项保留,启用覆盖项追加在后。值已匹配时不改写。关闭保留这些设置。CLI 和手动修改组合包列表不应用这些启用设置。
|
||||
|
||||
已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。
|
||||
|
||||
`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions,
|
||||
setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME,
|
||||
OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, ResolutionRestartRequired,
|
||||
assertBundlePlatform, UnsupportedBundlePlatformError,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import type {} from '@deepseek-ai/dsh-hmr'
|
||||
import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot'
|
||||
@@ -25,7 +26,7 @@ import { classifyInstallFailure } from './install-failure.ts'
|
||||
import { officialBundleInstallTarget } from './official-install-target.ts'
|
||||
import { dependencySpec, InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts'
|
||||
import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from './registry.ts'
|
||||
import { flatten, writePluginEnabled } from './patch.ts'
|
||||
import { flatten, prepareBundleConfiguration, writePluginEnabled } from './patch.ts'
|
||||
import { incompatiblePlugin, ManagementFailure } from './failure.ts'
|
||||
import { approveBuilds, recordPendingBuilds } from './build-approval.ts'
|
||||
import { checkGithubConnection } from './github-connection.ts'
|
||||
@@ -88,6 +89,7 @@ function messageOf(error: unknown): string { return error instanceof Error ? err
|
||||
|
||||
/** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */
|
||||
function managementError(error: unknown): ManagementError {
|
||||
if (error instanceof UnsupportedBundlePlatformError) return { code: 'unsupported-platform' }
|
||||
if (!(error instanceof ManagementFailure)) return { code: 'operation-error', diagnostic: messageOf(error) }
|
||||
return { code: error.code, ...error.incompatible === undefined ? {} : { incompatible: error.incompatible } }
|
||||
}
|
||||
@@ -361,18 +363,25 @@ export class PluginManager extends TypertRemoteService {
|
||||
...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: targetError ?? { code: 'not-bundle' }, rows: [], overrides: [] })
|
||||
continue
|
||||
}
|
||||
const experimental = info.dsh?.bundle?.experimental
|
||||
if (experimental !== undefined && typeof experimental !== 'boolean') throw new Error('bundle experimental must be a boolean')
|
||||
version = info.version
|
||||
const compatibility = evaluatePluginCompatibility(info, exemptions)
|
||||
if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)])
|
||||
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
|
||||
const meta = offered?.meta ?? readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href)
|
||||
bundles.push({ name, official, availability, ...target, ...(version === undefined ? {} : { version }),
|
||||
let platformError: ManagementError | undefined
|
||||
try { assertBundlePlatform(info.dsh?.bundle?.platforms, process.platform) }
|
||||
catch (error) { platformError = managementError(error) }
|
||||
const error = targetError ?? platformError
|
||||
bundles.push({ name, official, availability, ...target, ...experimental === undefined ? {} : { experimental },
|
||||
...(version === undefined ? {} : { version }),
|
||||
...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
|
||||
...meta === undefined ? {} : { meta },
|
||||
...sourceOf(info.name), enabled, installed, optional, removable: removable && readOnlyReason === undefined,
|
||||
...(readOnlyReason === undefined ? {} : { readOnlyReason }),
|
||||
...this.declaredRows(name, info),
|
||||
...targetError === undefined ? {} : { error: targetError } })
|
||||
...error === undefined ? {} : { error } })
|
||||
} catch (error) {
|
||||
if (enabled || installed || optional || offered !== undefined) {
|
||||
bundles.push({ name, official, availability, ...catalogMeta, ...target, ...(version === undefined ? {} : { version }),
|
||||
@@ -808,19 +817,27 @@ export class PluginManager extends TypertRemoteService {
|
||||
private async selectBundle(name: string, enabled: boolean): Promise<void> {
|
||||
const manifest = readProfileManifest('dsh', this.profile.dir)
|
||||
const previous = bundleSelection(manifest)
|
||||
let enableConfiguration: string | undefined
|
||||
if (enabled || !previous.includes(name)) {
|
||||
const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
|
||||
if (metadata === undefined) throw new ManagementFailure('not-bundle')
|
||||
if (enabled) {
|
||||
assertBundlePlatform(metadata.dsh?.bundle?.platforms, process.platform)
|
||||
const compatibility = evaluatePluginCompatibility(metadata, readProfileVersionExemptions(this.profile.dir))
|
||||
if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)])
|
||||
this.bundleRows(name)
|
||||
if (metadata.dsh?.bundle?.configureOnEnable !== undefined) {
|
||||
enableConfiguration = await prepareBundleConfiguration(this.profile, metadata.dsh.bundle.configureOnEnable)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!enabled && previous.includes(name)) {
|
||||
if (this.protectsManager(name)) throw new ManagementFailure('management-required')
|
||||
}
|
||||
const bundles = enabled ? [...previous, ...previous.includes(name) ? [] : [name]] : previous.filter(item => item !== name)
|
||||
if (enableConfiguration !== undefined) {
|
||||
await writeFileAtomic(this.profile.patchPath, enableConfiguration, { mode: 0o600 })
|
||||
}
|
||||
if (JSON.stringify(previous) === JSON.stringify(bundles)) return
|
||||
manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } }
|
||||
await saveManifest(this.profile.dir, manifest)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { execa } from 'execa'
|
||||
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import {
|
||||
DEFAULT_PROFILE_BUNDLES, bundlePatchPaths, initProfile, PROFILE_TEMPLATES, readProfileManifest,
|
||||
DEFAULT_PROFILE_BUNDLES, assertBundlePlatform, bundlePatchPaths, initProfile, PROFILE_TEMPLATES, readProfileManifest,
|
||||
resolveBundleDir, resolveProfileDir, loadOverlayPatches, composeEntries, readProfileVersionExemptions,
|
||||
evaluatePluginCompatibility, pluginCompatibilityWarning, type ProfileManifest,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
@@ -102,6 +102,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
|
||||
}
|
||||
assertBundlePlatform(metadata.dsh.bundle.platforms, process.platform)
|
||||
for (const file of bundlePatchPaths(resolveBundleDir('dsh', name, anchor, dir), metadata.dsh.bundle)) loadOverlayPatches('dsh', file)
|
||||
if (!bundles.includes(name)) {
|
||||
bundles.push(name)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/** Comment-preserving profile plugin enablement edits and the entry flattening they compare against. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { isMap, isNode, isScalar, isSeq, parseDocument } from 'yaml'
|
||||
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { composeEntries, loadOptionalPatches, readBundlePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
|
||||
import { isMap, isNode, isScalar, isSeq, parseDocument, Scalar, visit } from 'yaml'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { z } from 'zod'
|
||||
import { isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { composeEntries, loadOptionalPatches, loadProfileDirectory, readBundlePatches, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
|
||||
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
|
||||
/** Flatten only the groups addressable by the profile's patch composer.
|
||||
@@ -13,17 +15,8 @@ export function flatten(rows: EntryOptions[]): EntryOptions[] {
|
||||
return rows.flatMap(row => [row, ...(row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : [])])
|
||||
}
|
||||
|
||||
/** Replace the last matching override or append one after existing insertions. An override that holds only `id` and
|
||||
* `disabled`, without anchors, is removed, or not appended, when the bundle layers and the remaining profile patches
|
||||
* already give the entry the requested literal `disabled` value; home patches and launch overlays are not consulted.
|
||||
* @param profile Profile whose patch file is edited and whose selected bundles supply the compared composition.
|
||||
* @param id Unique composition entry id.
|
||||
* @param name Module name used to match name-qualified overrides.
|
||||
* @param enabled Desired entry enablement.
|
||||
* @returns Whether the file changed.
|
||||
*/
|
||||
export async function writePluginEnabled(profile: ProfileContext, id: string, name: string, enabled: boolean): Promise<boolean> {
|
||||
const filename = profile.patchPath
|
||||
/** Read the shared profile patch dialect while retaining comments and expression tags. */
|
||||
async function readPatchDocument(filename: string) {
|
||||
let text: string
|
||||
try {
|
||||
text = await readFile(filename, 'utf8')
|
||||
@@ -37,9 +30,85 @@ export async function writePluginEnabled(profile: ProfileContext, id: string, na
|
||||
const error = document.errors[0]
|
||||
if (error !== undefined) throw error
|
||||
if (!isSeq(document.contents)) throw new Error('Profile patch must be a YAML sequence')
|
||||
return { document, sequence: document.contents }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare persistent settings for explicit bundle enablement.
|
||||
* @param profile Profile whose existing entries receive top-level field overrides.
|
||||
* @param input Untrusted bundle manifest declaration, keyed by profile entry id.
|
||||
* @returns Complete profile patch text, or undefined when the requested settings already match.
|
||||
* @throws Invalid declarations, missing or ambiguous entries, whole-config expressions, and higher-layer overrides.
|
||||
*/
|
||||
export async function prepareBundleConfiguration(
|
||||
profile: ProfileContext, input: unknown,
|
||||
): Promise<string | undefined> {
|
||||
const configuration = z.record(z.string().min(1), z.record(z.string(), z.json())).parse(input)
|
||||
const loaded = loadProfileDirectory('dsh', profile.dir, profile.installAnchor)
|
||||
const rows = flatten(composeEntries([...loaded.layers.map(layer => layer.patches), loaded.patches]))
|
||||
const patches = Object.entries(configuration).map(([id, fields]) => {
|
||||
const matches = rows.filter(row => row.id === id)
|
||||
const entry = matches[0]
|
||||
const current: unknown = entry?.config
|
||||
if (isJsExpr(current)) {
|
||||
throw new Error(`Bundle enablement cannot edit the whole-config expression for "${id}"; use a literal config mapping`)
|
||||
}
|
||||
if (matches.length !== 1 || entry === undefined || entry.group
|
||||
|| (current !== undefined && (current === null || typeof current !== 'object' || Array.isArray(current)))) {
|
||||
throw new Error(`Bundle enablement requires one configurable profile entry "${id}"`)
|
||||
}
|
||||
const config = { ...current, ...fields }
|
||||
return { id, name: entry.name, config }
|
||||
})
|
||||
const effective = flatten(composeEntries([readProfilePatches('dsh', profile, {
|
||||
...loaded, patches: [...loaded.patches, ...patches],
|
||||
})]))
|
||||
for (const [id, fields] of Object.entries(configuration)) {
|
||||
const config: unknown = effective.find(row => row.id === id)?.config
|
||||
if (config === null || typeof config !== 'object' || Array.isArray(config)
|
||||
|| Object.entries(fields).some(([key, value]) => !isDeepStrictEqual((config as Record<string, unknown>)[key], value))) {
|
||||
throw new Error(`Bundle enablement configuration for "${id}" is overridden by a home patch or command-line overlay`)
|
||||
}
|
||||
}
|
||||
if (patches.every(patch => isDeepStrictEqual(rows.find(row => row.id === patch.id)?.config, patch.config))) return undefined
|
||||
const { document, sequence } = await readPatchDocument(profile.patchPath)
|
||||
sequence.flow = false
|
||||
for (const { id, name, config } of patches) {
|
||||
const index = sequence.items.findLastIndex((item, index) => isMap(item)
|
||||
&& document.getIn([index, 'id']) === id && !item.has('insert')
|
||||
&& (!item.has('name') || document.getIn([index, 'name']) === name))
|
||||
const previous = sequence.items[index]
|
||||
let anchored = false
|
||||
if (isNode(previous)) visit(previous, { Node(_key, node) {
|
||||
if ('anchor' in node && node.anchor) anchored = true
|
||||
} })
|
||||
if (index < 0 || anchored) document.add(document.createNode({ id, name, config }))
|
||||
else document.setIn([index, 'config'], document.createNode(config))
|
||||
}
|
||||
visit(document, { Map(_key, node) {
|
||||
if (node.items.length !== 1 || typeof node.get('__jsExpr') !== 'string') return
|
||||
const expression = new Scalar(node.get('__jsExpr'))
|
||||
expression.tag = 'tag:yaml.org,2002:js'
|
||||
return expression
|
||||
} })
|
||||
return String(document)
|
||||
}
|
||||
|
||||
/** Replace the last matching override or append one after existing insertions. An override that holds only `id` and
|
||||
* `disabled`, without anchors, is removed, or not appended, when the bundle layers and the remaining profile patches
|
||||
* already give the entry the requested literal `disabled` value; home patches and launch overlays are not consulted.
|
||||
* @param profile Profile whose patch file is edited and whose selected bundles supply the compared composition.
|
||||
* @param id Unique composition entry id.
|
||||
* @param name Module name used to match name-qualified overrides.
|
||||
* @param enabled Desired entry enablement.
|
||||
* @returns Whether the file changed.
|
||||
*/
|
||||
export async function writePluginEnabled(profile: ProfileContext, id: string, name: string, enabled: boolean): Promise<boolean> {
|
||||
const filename = profile.patchPath
|
||||
const { document, sequence } = await readPatchDocument(filename)
|
||||
const patches = loadOptionalPatches('dsh', filename) ?? []
|
||||
const bundlePatches = readBundlePatches('dsh', profile)
|
||||
const items = document.contents.items
|
||||
const items = sequence.items
|
||||
const target = items.findLast((item, index) => {
|
||||
if (!isMap(item) || document.getIn([index, 'id']) !== id || item.has('insert')) return false
|
||||
const expectedName = document.getIn([index, 'name'])
|
||||
@@ -62,7 +131,7 @@ export async function writePluginEnabled(profile: ProfileContext, id: string, na
|
||||
const comment = target.commentBefore
|
||||
const next = items[index]
|
||||
if (comment && isNode(next)) next.commentBefore = next.commentBefore ? `${comment}\n${next.commentBefore}` : comment
|
||||
else if (comment && items.length === 0) document.contents.commentBefore = comment
|
||||
else if (comment && items.length === 0) sequence.commentBefore = comment
|
||||
else if (comment) document.comment = document.comment ? `${comment}\n${document.comment}` : comment
|
||||
} else if (document.getIn([index, 'disabled']) === !enabled) {
|
||||
return false
|
||||
|
||||
@@ -23,6 +23,7 @@ const ERROR_STEPS: Record<ManagementError['code'], string> = {
|
||||
'bundle-in-use': 'Call list_plugins, disable the entries from other layers that still use this bundle, then retry.',
|
||||
'stale-approval': 'Call install_bundle again without approvedBuilds to read the current pendingBuilds.',
|
||||
'incompatible-version': 'Install a version compatible with the running DSH. A version exemption needs the user\'s explicit acceptance of crash and data-loss risk.',
|
||||
'unsupported-platform': 'This bundle cannot run on the current Host platform; ask the user to use a supported Host.',
|
||||
'operation-error': 'Report the diagnostic to the user; retry only after its cause is addressed.',
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface IncompatiblePlugin {
|
||||
|
||||
/** Localizable management failure and optional external diagnostic. */
|
||||
export interface ManagementError {
|
||||
code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'already-installed' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'operation-error'
|
||||
code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'already-installed' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'unsupported-platform' | 'operation-error'
|
||||
diagnostic?: string
|
||||
/** Present with `incompatible-version`: the packages the running DSH version rejects. */
|
||||
incompatible?: IncompatiblePlugin[]
|
||||
@@ -55,6 +55,8 @@ export interface BundleRowInfo {
|
||||
/** One shipped, profile-installed, or discoverable on-demand bundle. */
|
||||
export interface BundleInfo {
|
||||
name: string
|
||||
/** Explicit experimental feature status declared by the bundle. */
|
||||
experimental?: boolean
|
||||
/** Project-maintained shipped optional bundle or on-demand catalog entry, independent of installation and selection. */
|
||||
official: boolean
|
||||
/** Readable package location; on-demand catalog entries exclude undeclared transitive copies, and declared files may be missing. */
|
||||
|
||||
@@ -89,6 +89,70 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
|
||||
return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays, connection }
|
||||
}
|
||||
|
||||
it.each(['live', 'startup'] as const)('persists enablement settings, applies them with %s reload and retains them after disablement', async (reload) => {
|
||||
const { manager, dir, ctx } = await fixture(reload, false, undefined, {}, undefined, (dir) => {
|
||||
const core = join(dir, 'node_modules/core/cordis.patch.yml')
|
||||
const patches = JSON.parse(readFileSync(core, 'utf8')) as Array<{ insert: object[] }>
|
||||
patches[0]!.insert.push({ id: 'runtime', name: join(dir, 'node_modules/extra/plugin.mjs'),
|
||||
config: { service: 'activationProbe', executionMode: 'per-call', timeout: 42 } })
|
||||
writeFileSync(core, JSON.stringify(patches))
|
||||
const path = join(dir, 'node_modules/extra/package.json')
|
||||
const meta = JSON.parse(readFileSync(path, 'utf8')) as { dsh: { bundle: object } }
|
||||
meta.dsh.bundle = { ...meta.dsh.bundle, configureOnEnable: { runtime: { executionMode: 'persistent-in-memory' } } }
|
||||
writeFileSync(path, JSON.stringify(meta))
|
||||
})
|
||||
await manager.setBundleEnabled('extra', false)
|
||||
const runtime = [...ctx.loader.entries()].find(entry => entry.options.id === 'runtime')!
|
||||
expect(await manager.setBundleEnabled('extra', true)).toMatchObject({
|
||||
application: reload === 'live' ? 'applied' : 'restart-required', changed: true,
|
||||
})
|
||||
expect(runtime.options.config).toEqual({ service: 'activationProbe', executionMode: reload === 'live' ? 'persistent-in-memory' : 'per-call', timeout: 42 })
|
||||
const appliedFiber = runtime.fiber
|
||||
const saved = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')
|
||||
expect(parse(saved)).toContainEqual({ id: 'runtime', name: pathToFileURL(join(dir, 'node_modules/extra/plugin.mjs')).href,
|
||||
config: { service: 'activationProbe', executionMode: 'persistent-in-memory', timeout: 42 } })
|
||||
await manager.setBundleEnabled('extra', false)
|
||||
expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toBe(saved)
|
||||
expect(runtime.fiber === appliedFiber).toBe(true)
|
||||
await manager.setBundleEnabled('extra', true)
|
||||
expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toBe(saved)
|
||||
expect(runtime.fiber === appliedFiber).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['live', 'startup'] as const)('refuses an unsupported bundle before saving or applying it with %s reload', async (reload) => {
|
||||
const { manager, dir, ctx } = await fixture(reload, false, undefined, {}, undefined, (dir) => {
|
||||
const path = join(dir, 'node_modules/extra/package.json')
|
||||
writeFileSync(path, JSON.stringify({ name: 'extra', version: '1.0.0', description: 'Platform-limited bundle',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml', experimental: true, configureOnEnable: { absent: { mode: 'new' } }, platforms: [process.platform === 'darwin' ? 'win32' : 'darwin'] } } }))
|
||||
const manifest = readProfileManifest('test', dir)
|
||||
manifest.dsh = { profile: { bundles: ['core'] } }
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
||||
})
|
||||
const before = readFileSync(join(dir, 'package.json'), 'utf8')
|
||||
const patchBefore = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')
|
||||
expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({
|
||||
enabled: false, experimental: true, description: 'Platform-limited bundle', error: { code: 'unsupported-platform' },
|
||||
})
|
||||
expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed', error: { code: 'unsupported-platform' } })
|
||||
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
|
||||
expect(ctx.get('managedProbe')).toBeUndefined()
|
||||
expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toBe(patchBefore)
|
||||
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: false })
|
||||
})
|
||||
|
||||
it('allows supported bundle activation and permits deselecting an unsupported saved bundle', async () => {
|
||||
const { manager, dir } = await fixture()
|
||||
const path = join(dir, 'node_modules/extra/package.json')
|
||||
const metadata = readProfileManifest('test', join(dir, 'node_modules/extra'))
|
||||
metadata.dsh = { bundle: { patch: './cordis.patch.yml', platforms: [process.platform] } }
|
||||
writeFileSync(path, JSON.stringify(metadata))
|
||||
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' })
|
||||
expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: true, application: 'applied' })
|
||||
metadata.dsh.bundle!.platforms = [process.platform === 'darwin' ? 'win32' : 'darwin']
|
||||
writeFileSync(path, JSON.stringify(metadata))
|
||||
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' })
|
||||
})
|
||||
|
||||
it.each(['network', 'timeout'] as const)('stops a GitHub %s before pnpm and attributes it to the repository', async (kind) => {
|
||||
const { manager, dir, connection } = await fixture()
|
||||
const before = readFileSync(join(dir, 'package.json'), 'utf8')
|
||||
@@ -1515,6 +1579,20 @@ it.each([false, true])('rechecks installed bundle peers before accepting a disab
|
||||
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
|
||||
})
|
||||
|
||||
it.each([true, false])('projects the explicit experimental flag %s independently of package naming', async (experimental) => {
|
||||
const { manager, dir } = await fixture()
|
||||
const path = join(dir, 'node_modules/extra/package.json')
|
||||
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dsh: { bundle: { experimental?: boolean | string } } }
|
||||
manifest.dsh.bundle.experimental = experimental
|
||||
writeFileSync(path, JSON.stringify(manifest))
|
||||
expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ experimental })
|
||||
manifest.dsh.bundle.experimental = 'yes'
|
||||
writeFileSync(path, JSON.stringify(manifest))
|
||||
const error = (await manager.listBundles()).find(row => row.name === 'extra')?.error
|
||||
expect(error?.code).toBe('operation-error')
|
||||
expect(error?.diagnostic).toContain('must be a boolean')
|
||||
})
|
||||
|
||||
|
||||
it('lists absent Official entries offline without resolving their packages or consulting registries', async () => {
|
||||
const { manager } = await fixture()
|
||||
|
||||
@@ -169,6 +169,17 @@ function install(dir: string, name: string) {
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
||||
}
|
||||
|
||||
it('keeps a newly installed unsupported bundle out of the saved selection', async () => {
|
||||
const { dir, context, pnpm } = fixture()
|
||||
pnpm.mutate = (target) => {
|
||||
install(target, 'restricted')
|
||||
writeFileSync(join(target, 'node_modules/restricted/package.json'), JSON.stringify({ name: 'restricted', version: '1',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml', platforms: [process.platform === 'darwin' ? 'win32' : 'darwin'] } } }))
|
||||
}
|
||||
await expect(runProfilePnpm(context, ['add', 'restricted'], { execution: 'service', outputBytes: 8192 })).rejects.toThrow('Host is running')
|
||||
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual([])
|
||||
})
|
||||
|
||||
function installGuarded(dir: string, dependency: string, name = dependency, version = '1.0.0', peer = '>=999.0.0') {
|
||||
install(dir, dependency)
|
||||
writeFileSync(join(dir, 'node_modules', dependency, 'package.json'), JSON.stringify({
|
||||
|
||||
@@ -5,10 +5,102 @@ import { tmpdir } from 'node:os'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { initProfile, loadOptionalPatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
|
||||
import { writePluginEnabled } from '../src/patch.ts'
|
||||
import { prepareBundleConfiguration, writePluginEnabled } from '../src/patch.ts'
|
||||
|
||||
const TOOL = { id: 'tool', name: 'package' }
|
||||
|
||||
it('prepares durable activation settings while preserving prior fields, expressions and comments', async () => {
|
||||
const profile = await fixture('# personal\n- id: runtime\n config:\n timeout: 42\n command: !!js process.execPath\n- id: locale\n config: { preference: zh }\n', [
|
||||
{ id: 'runtime', name: 'runtime-package', config: { executionMode: 'per-call' } },
|
||||
{ id: 'presets', name: 'preset-package', config: { default: 'standard' } },
|
||||
{ id: 'locale', name: 'locale-package' },
|
||||
])
|
||||
const before = await readFile(profile.patchPath, 'utf8')
|
||||
const input = { runtime: { executionMode: 'persistent-in-memory' }, presets: { selectedDefault: 'ptc' } }
|
||||
const prepared = await prepareBundleConfiguration(profile, input)
|
||||
expect(await readFile(profile.patchPath, 'utf8')).toBe(before)
|
||||
expect(prepared).toContain('# personal')
|
||||
expect(prepared).toContain('!!js process.execPath')
|
||||
if (prepared === undefined) throw new Error('Expected changed enablement settings')
|
||||
await writeFile(profile.patchPath, prepared)
|
||||
expect(loadOptionalPatches('test', profile.patchPath)).toEqual([
|
||||
{ id: 'runtime', config: { timeout: 42, command: { __jsExpr: 'process.execPath' }, executionMode: 'persistent-in-memory' } },
|
||||
{ id: 'locale', config: { preference: 'zh' } },
|
||||
{ id: 'presets', name: 'preset-package', config: { default: 'standard', selectedDefault: 'ptc' } },
|
||||
])
|
||||
expect(await prepareBundleConfiguration(profile, input)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts unrelated launch overrides when the requested field has the intended effective value', async () => {
|
||||
const profile = await fixture('[]\n', [{ id: 'runtime', name: 'runtime-package', config: { timeout: 42 } }])
|
||||
const launch = { ...profile, overlays: [{ id: 'runtime', config: { executionMode: 'persistent-in-memory', timeout: 10 } }] }
|
||||
const prepared = await prepareBundleConfiguration(launch, { runtime: { executionMode: 'persistent-in-memory' } })
|
||||
if (prepared === undefined) throw new Error('Expected saved runtime configuration')
|
||||
await writeFile(profile.patchPath, prepared)
|
||||
expect(loadOptionalPatches('test', profile.patchPath)).toEqual([
|
||||
{ id: 'runtime', name: 'runtime-package', config: { timeout: 42, executionMode: 'persistent-in-memory' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses a whole-config expression without evaluating it or replacing the user patch', async () => {
|
||||
const before = '- id: runtime\n config: !!js "(() => { throw new Error(\'must not evaluate\') })()"\n'
|
||||
const profile = await fixture(before, [{ id: 'runtime', name: 'runtime-package' }])
|
||||
await expect(prepareBundleConfiguration(profile, { runtime: { executionMode: 'persistent-in-memory' } }))
|
||||
.rejects.toThrow('Bundle enablement cannot edit the whole-config expression for "runtime"; use a literal config mapping')
|
||||
expect(await readFile(profile.patchPath, 'utf8')).toBe(before)
|
||||
})
|
||||
|
||||
it('replaces a target field expression while preserving unrelated expressions', async () => {
|
||||
const profile = await fixture('- id: runtime\n config:\n executionMode: !!js process.env.MODE\n timeout: !!js process.env.TIMEOUT\n', [
|
||||
{ id: 'runtime', name: 'runtime-package' },
|
||||
])
|
||||
const prepared = await prepareBundleConfiguration(profile, { runtime: { executionMode: 'persistent-in-memory' } })
|
||||
expect(prepared).toContain('executionMode: persistent-in-memory')
|
||||
expect(prepared).toContain('timeout: !!js process.env.TIMEOUT')
|
||||
expect(prepared).not.toContain('process.env.MODE')
|
||||
})
|
||||
|
||||
it('retains configuration anchors referenced by other entries', async () => {
|
||||
const profile = await fixture('- id: runtime\n config: &runtimeConfig\n timeout: &timeout 30000\n- id: other\n config:\n timeout: *timeout\n inherited: *runtimeConfig\n', [
|
||||
{ id: 'runtime', name: 'runtime-package' }, { id: 'other', name: 'other-package' },
|
||||
])
|
||||
const prepared = await prepareBundleConfiguration(profile, { runtime: { executionMode: 'persistent-in-memory' } })
|
||||
if (prepared === undefined) throw new Error('Expected activation settings')
|
||||
await writeFile(profile.patchPath, prepared)
|
||||
expect(loadOptionalPatches('test', profile.patchPath)).toEqual([
|
||||
{ id: 'runtime', config: { timeout: 30000 } },
|
||||
{ id: 'other', config: { timeout: 30000, inherited: { timeout: 30000 } } },
|
||||
{ id: 'runtime', name: 'runtime-package', config: { timeout: 30000, executionMode: 'persistent-in-memory' } },
|
||||
])
|
||||
expect(await prepareBundleConfiguration(profile, { runtime: { executionMode: 'persistent-in-memory' } })).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['runtime-package', 'retired-runtime'])('keeps name-qualified activation overrides for %s', async (name) => {
|
||||
const profile = await fixture(`- id: runtime\n name: ${name}\n config: { timeout: 42 }\n`, [
|
||||
{ id: 'runtime', name: 'runtime-package', config: { timeout: 10 } },
|
||||
])
|
||||
const prepared = await prepareBundleConfiguration(profile, { runtime: { executionMode: 'persistent-in-memory' } })
|
||||
if (prepared === undefined) throw new Error('Expected activation settings')
|
||||
await writeFile(profile.patchPath, prepared)
|
||||
const patch = { id: 'runtime', name: 'runtime-package', config: {
|
||||
timeout: name === 'runtime-package' ? 42 : 10, executionMode: 'persistent-in-memory',
|
||||
} }
|
||||
expect(loadOptionalPatches('test', profile.patchPath)).toEqual(name === 'runtime-package' ? [patch] : [
|
||||
{ id: 'runtime', name, config: { timeout: 42 } }, patch,
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses invalid activation settings, absent entries and higher-layer overrides before writing', async () => {
|
||||
const profile = await fixture('[]\n', [{ id: 'runtime', name: 'runtime-package', config: {} }])
|
||||
for (const input of [[], { runtime: false }, { absent: { mode: 'new' } }]) {
|
||||
await expect(prepareBundleConfiguration(profile, input)).rejects.toThrow()
|
||||
expect(await readFile(profile.patchPath, 'utf8')).toBe('[]\n')
|
||||
}
|
||||
await writeFile(join(profile.home, 'cordis.patch.yml'), '- id: runtime\n config: { executionMode: per-call }\n')
|
||||
await expect(prepareBundleConfiguration(profile, { runtime: { executionMode: 'persistent-in-memory' } })).rejects.toThrow('overridden')
|
||||
expect(await readFile(profile.patchPath, 'utf8')).toBe('[]\n')
|
||||
})
|
||||
|
||||
/** Create a profile whose patch file holds `text` (none when omitted) and whose one selected bundle inserts `rows`. */
|
||||
async function fixture(text?: string, rows: object[] = []): Promise<ProfileContext> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'manager-patch-'))
|
||||
|
||||
@@ -303,4 +303,6 @@ it('tells the agent the next step for each change outcome it can act on', async
|
||||
expect(await outcome({ changed: false, application: 'failed', packageResult: { kind: 'unknown' }, error: { code: 'operation-error' } }))
|
||||
.toContain('Report the diagnostic')
|
||||
expect(await outcome({ changed: false, application: 'failed' })).toContain('Report the diagnostic')
|
||||
expect(await outcome({ changed: false, application: 'failed', error: { code: 'unsupported-platform' } }))
|
||||
.toBe('This bundle cannot run on the current Host platform; ask the user to use a supported Host.')
|
||||
})
|
||||
@@ -15,14 +15,14 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
|
||||
en: 8d8c0fcd9320761a
|
||||
zh: 087473d318680403
|
||||
en: 44e8986a8b52a1ef
|
||||
zh: fd28da304d11ea52
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
|
||||
en: fcb2e69c8b028c0a
|
||||
zh: d505a075679974eb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle:
|
||||
en: 59e12480cf0c3e70
|
||||
zh: f230ea8c752475d5
|
||||
en: 997455148b3f60e2
|
||||
zh: 37c2be69e6dc7c13
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-one-row-of-a-bundle:
|
||||
en: d3627d4dba5e6cc2
|
||||
zh: e0d0eada4c20331f
|
||||
@@ -30,8 +30,8 @@
|
||||
en: e9da90f03691101b
|
||||
zh: 902cda935a4ce3c8
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/detail-page-extension-points:
|
||||
en: 5266d86638cc5cd7
|
||||
zh: 398d710df9806cea
|
||||
en: 1c98fe13ab6109fc
|
||||
zh: 4c382a353e8de820
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs:
|
||||
en: 76e0908903ab8fde
|
||||
zh: ce6ae1cdd6aea971
|
||||
@@ -45,8 +45,8 @@
|
||||
en: 21a0bbe8eb92229c
|
||||
zh: 096ff63db61992bb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/configuration-slots:
|
||||
en: c1338ea1bf004954
|
||||
zh: 8dba2a39e619d3c3
|
||||
en: 4406ecf602ebad0e
|
||||
zh: c0f3d5c34ac82bb3
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/further-exploration:
|
||||
en: f9a42dfed0360d6a
|
||||
zh: 455a70bcb6c69624
|
||||
|
||||
@@ -27,7 +27,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Bundle details can show a feature preview below the description, also while the bundle is disabled; **DSH badge skill** shows one in English or Chinese following the UI language, and a decode failure shows a short notice without blocking management. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
|
||||
Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Switching pages or tabs starts at the top; refreshing the current page preserves its scroll position. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Bundle details can show a feature preview below the description, also while the bundle is disabled; **DSH badge skill** shows one in English or Chinese following the UI language, and a decode failure shows a short notice without blocking management. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
|
||||
|
||||
The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text.
|
||||
|
||||
@@ -37,6 +37,8 @@ On-demand Official cards are visible offline before installation. Turning one On
|
||||
|
||||
The first list read shows a short group-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states.
|
||||
|
||||
A bundle unavailable on the DSH Host operating system shows the reason on its card and details page. Its disabled switch prevents enabling it; an already selected bundle can still be switched off. [Plugin Manager](../../boot/plugin-manager/README.md#use-this-package) enforces the same restriction for direct requests.
|
||||
|
||||
The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. During a manual refresh, the button shows a spinner and stays disabled until all pending reads settle and at least 400 ms have elapsed, preventing a brief spinner flash; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select **Refresh** again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner.
|
||||
|
||||
### Installing a bundle
|
||||
@@ -62,7 +64,9 @@ When the Host attributes a network failure or timeout to a GitHub address and of
|
||||
|
||||
### Switching a bundle
|
||||
|
||||
A bundle's page shows its full package name under the title. Its **Source** section, after the rows, names the spec that installs it elsewhere, as `pnpm add` accepts it, or **Built in** for an official bundle the installation ships, then its current version. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins.
|
||||
A bundle can declare `dsh.bundle.experimental: true` to display the experimental tag independently of its directory or package name; existing experimental package names retain that tag.
|
||||
|
||||
A bundle's page shows its full package name under the title. Its **Source** section, after the rows, names the spec that installs it elsewhere, as `pnpm add` accepts it, or **Built in** for an official bundle the installation ships, then its current version. A bundle's switch changes its layer selection. While the Host applies the change, the switch stays disabled and a spinner appears beside it; the reserved space keeps the controls from shifting. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins.
|
||||
|
||||
### Switching one row of a bundle
|
||||
|
||||
@@ -96,6 +100,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
|
||||
|
||||
A row's page exists only while a `plugins.row.config` entry names the row, so a contribution meant for a row renders on the page that configuration opens.
|
||||
|
||||
Component lists and detail pages show current startup diagnostics from the Host inventory. The diagnostic remains visible after reopening the page and disappears when the component recovers.
|
||||
|
||||
### Page tabs
|
||||
|
||||
Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here.
|
||||
@@ -130,7 +136,7 @@ Custom item pages use the Host entry id as their registration id; row pages use
|
||||
|
||||
The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Experimental marker. Feature previews are SVG files under `src/client/assets/`, keyed by package name in the page and embedded in the Client bundle; packages cannot declare their own ([rationale](../../../.agents/notes/implemented/feature/2026-10-14-built-in-plugin-feature-previews.md)).
|
||||
|
||||
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed.
|
||||
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name, and receives `busy` while the owning bundle is being changed. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from either the list or detail page, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。组合包详情可在说明下方展示功能配图,未启用时也可查看;**DSH 徽章技能**按界面语言展示中文或英文配图,解码失败时显示简短提示,不影响管理操作。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。
|
||||
在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。切换页面或标签时从顶部开始,同一页面刷新数据时保留滚动位置。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。组合包详情可在说明下方展示功能配图,未启用时也可查看;**DSH 徽章技能**按界面语言展示中文或英文配图,解码失败时显示简短提示,不影响管理操作。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。
|
||||
|
||||
空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。
|
||||
|
||||
@@ -37,6 +37,8 @@ kind: "package-reference"
|
||||
|
||||
首次读取列表时,页面显示一条较短的分组标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。
|
||||
|
||||
组合包不支持 DSH Host 的操作系统时,卡片和详情页会显示原因,关闭状态的开关不可启用;已选中的组合包仍可关闭。[插件管理器](../../boot/plugin-manager/README.zh.md#use-this-package)对直接请求执行同一限制。
|
||||
|
||||
**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择**刷新**即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。
|
||||
|
||||
### 安装一个组合包
|
||||
@@ -62,7 +64,9 @@ Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时
|
||||
|
||||
### 切换一个组合包
|
||||
|
||||
组合包页面在标题下方显示完整包名。组件列表之后的「来源信息」给出在别处安装它所需的 spec(`pnpm add` 接受的形式),安装随附的官方组合包显示「内置」,随后是当前版本。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。
|
||||
组合包可通过 `dsh.bundle.experimental: true` 显示实验性标签,代码目录和包名不必包含 experimental;已有实验性包名也保留标签。
|
||||
|
||||
组合包页面在标题下方显示完整包名。组件列表之后的「来源信息」给出在别处安装它所需的 spec(`pnpm add` 接受的形式),安装随附的官方组合包显示「内置」,随后是当前版本。组合包开关改变其层选择。Host 应用更改期间,开关保持禁用,旁边显示加载动画;动画位置预留空间,避免控件移动。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。
|
||||
|
||||
### 切换组合包里的一行
|
||||
|
||||
@@ -96,6 +100,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
|
||||
|
||||
行页只在某个 `plugins.row.config` 条目点名这一行时存在,因此给行的贡献渲染在该配置打开的页面上。
|
||||
|
||||
组件列表和详情页显示 Host 清单中的当前启动诊断。重新打开页面后仍能看到原因,组件恢复后诊断消失。
|
||||
|
||||
### 页面标签页
|
||||
|
||||
其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。
|
||||
@@ -130,7 +136,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()`
|
||||
|
||||
页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示实验性标记。功能配图是 `src/client/assets/` 下的 SVG 文件,由页面按包名指定并嵌入 Client bundle;包不能自行声明配图([决策依据](../../../.agents/notes/implemented/feature/2026-10-14-built-in-plugin-feature-previews.zh.md))。
|
||||
|
||||
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。
|
||||
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置,并通过 `busy` 接收所属 Bundle 正在变更的状态。`plugins.bundle.activation` 在用户从列表或详情页显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -283,6 +283,7 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -345,12 +346,13 @@
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
min-height: 2lh;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -378,9 +380,9 @@
|
||||
.skeletonDescription { width: min(280px, 85%); }
|
||||
.skeletonIcon { border-color: transparent; }
|
||||
|
||||
/* Keep text-column metrics while leaving the action area blank. */
|
||||
/* Reserve the switch and its pending indicator without painting controls. */
|
||||
.skeletonActions {
|
||||
width: 36px;
|
||||
width: calc(36px + 8px + 14px);
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
@@ -403,6 +405,17 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bundleToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toggleProgress {
|
||||
display: inline-flex;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
/* The toolbar's icon action, round like the message-row actions. */
|
||||
.iconButton {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* the page declares.
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { PluginInstallFailureKind, Registry } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
@@ -293,6 +293,11 @@ function MetadataError({ error, t }: { readonly error: string | undefined; reado
|
||||
return error === undefined ? null : <p className={css.reason} role="status" data-package-meta-error>{t('metadataError', { error })}</p>
|
||||
}
|
||||
|
||||
/** A failed component keeps its current Host diagnostic beside its controls. */
|
||||
function RuntimeError({ error, t }: { readonly error: string | undefined; readonly t: Translate }): ReactNode {
|
||||
return error === undefined ? null : <p className={css.reason} role="status" data-plugin-runtime-error>{t('reasonLabel')}: {error}</p>
|
||||
}
|
||||
|
||||
/**
|
||||
* A pack's rows as a list in the order the pack declares them: a state dot,
|
||||
* the row id, one line saying its state, a configure control for a row that
|
||||
@@ -369,6 +374,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: {
|
||||
/>}
|
||||
</div>
|
||||
<MetadataError error={row.meta?.error} t={t} />
|
||||
<RuntimeError error={row.diagnostic} t={t} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -426,19 +432,25 @@ function EnableSwitch({ pkg, title, t, busy, onSetEnabled }: {
|
||||
readonly onSetEnabled: (enabled: boolean) => void
|
||||
}): ReactNode {
|
||||
return (
|
||||
<Switch
|
||||
checked={pkg.enabled}
|
||||
label={t('enableToggle', { name: title })}
|
||||
disabled={busy || pkg.readOnlyReason !== undefined || (!pkg.enabled && pkg.error !== undefined && !(pkg.availability === 'missing' && pkg.installTarget !== undefined))}
|
||||
{...pkg.readOnlyReason === undefined ? {} : { title: managementText({ code: pkg.readOnlyReason }, t) }}
|
||||
onChange={onSetEnabled}
|
||||
/>
|
||||
<span className={css.bundleToggle}>
|
||||
<span className={css.toggleProgress} role={busy ? 'status' : undefined}
|
||||
aria-label={busy ? t('applyingChange', { name: title }) : undefined}>
|
||||
{busy ? <StateDot state="ongoing" /> : null}
|
||||
</span>
|
||||
<Switch
|
||||
checked={pkg.enabled}
|
||||
label={t('enableToggle', { name: title })}
|
||||
disabled={busy || pkg.readOnlyReason !== undefined || (!pkg.enabled && pkg.error !== undefined && !(pkg.availability === 'missing' && pkg.installTarget !== undefined))}
|
||||
{...pkg.readOnlyReason === undefined ? {} : { title: managementText({ code: pkg.readOnlyReason }, t) }}
|
||||
onChange={onSetEnabled}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The status one card carries: running, off, or a problem the Host reported. */
|
||||
function packageStatus(pkg: PackageView): 'running' | 'disabled' | 'problem' {
|
||||
if (pkg.error !== undefined) return 'problem'
|
||||
if (pkg.error !== undefined || (pkg.enabled && pkg.rows.some(row => row.phase === 'failed'))) return 'problem'
|
||||
return pkg.enabled ? 'running' : 'disabled'
|
||||
}
|
||||
|
||||
@@ -567,6 +579,7 @@ function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnab
|
||||
end={<EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />}
|
||||
/>
|
||||
<MetadataError error={pkg.meta?.error} t={t} />
|
||||
{pkg.error?.code === 'unsupported-platform' && <p className={css.reason} role="status">{managementText(pkg.error, t)}</p>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -681,6 +694,7 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: {
|
||||
<p className={css.detailDesc}>{description ?? renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}</p>
|
||||
</div>
|
||||
<MetadataError error={row.meta?.error} t={t} />
|
||||
<RuntimeError error={row.diagnostic} t={t} />
|
||||
<div className={css.detailSections} data-plugin-config>
|
||||
{renderSlot('plugins.row.config', { view: 'page', form }, { entryKey: key })}
|
||||
{renderSlot('plugins.detail.section', { subject })}
|
||||
@@ -779,7 +793,7 @@ function PackageDetail({
|
||||
{configured
|
||||
? (
|
||||
<section className={css.detailSection} data-plugin-config>
|
||||
{renderSlot('plugins.bundle.config', { view: 'page' }, { entryKey: pkg.name })}
|
||||
{renderSlot('plugins.bundle.config', { view: 'page', busy }, { entryKey: pkg.name })}
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
@@ -1515,6 +1529,11 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
const selectedTab = props.useStore(state => state.tab)
|
||||
// A contributed tab that left the ledger falls back to Installed, and stays there when it returns.
|
||||
const tab = tabs.some(item => item.id === selectedTab) ? selectedTab : INSTALLED_TAB
|
||||
const pageRef = useRef<HTMLElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
/* v8 ignore else -- The unconditional section attaches its ref before this layout effect. */
|
||||
if (pageRef.current !== null) pageRef.current.scrollTop = 0
|
||||
}, [view, tab])
|
||||
useEffect(() => { if (tab !== selectedTab) setTab(tab) }, [tab, selectedTab, setTab])
|
||||
// A bundle an install just enabled is shown where its card is.
|
||||
useEffect(() => { if (state.highlight !== null) setTab(INSTALLED_TAB) }, [state.highlight, setTab])
|
||||
@@ -1564,6 +1583,10 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
? {}
|
||||
: { role: 'tabpanel', id: `${tabsId}-${INSTALLED_TAB}-panel`, 'aria-labelledby': `${tabsId}-${INSTALLED_TAB}` }
|
||||
const activated = listed.find(pkg => pkg.name === activation && pkg.enabled && !state.busy.includes(pkg.name))
|
||||
const setBundleEnabled = (name: string, enabled: boolean): void => {
|
||||
setActivation(enabled ? name : null)
|
||||
props.setEnabled(name, enabled)
|
||||
}
|
||||
const setRowEnabled = (row: PackageRow, enabled: boolean): void => {
|
||||
/* v8 ignore next -- a row without a live entry has its switch disabled */
|
||||
if (row.entryId !== undefined) props.setRowEnabled(row.entryId, enabled)
|
||||
@@ -1583,7 +1606,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
busy={packageBusy(pkg)}
|
||||
highlighted={state.highlight === pkg.name}
|
||||
onOpen={() => { setActivation(null); setView({ kind: 'package', name: pkg.name, ...showsMoreList ? { from: 'more' } : {} }) }}
|
||||
onSetEnabled={(enabled) => { setActivation(enabled ? pkg.name : null); props.setEnabled(pkg.name, enabled) }}
|
||||
onSetEnabled={(enabled) => { setBundleEnabled(pkg.name, enabled) }}
|
||||
/>
|
||||
)
|
||||
const basicCards = ledger.items.map(item => (
|
||||
@@ -1608,7 +1631,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={css.page} data-plugin-panel aria-busy={state.status === 'loading' || refreshing}>
|
||||
<section ref={pageRef} className={css.page} data-plugin-panel aria-busy={state.status === 'loading' || refreshing}>
|
||||
{showsMainList
|
||||
? (
|
||||
<header className={css.pageHead} data-window-drag>
|
||||
@@ -1731,7 +1754,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
renderSlot={renderSlot}
|
||||
inMore={openPkgInMore}
|
||||
onBack={() => { setView({ kind: openPkgInMore ? 'more' : 'list' }) }}
|
||||
onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }}
|
||||
onSetEnabled={(enabled) => { setBundleEnabled(openPkg.name, enabled) }}
|
||||
onUninstall={() => { props.uninstall(openPkg.name) }}
|
||||
onUpdate={() => { props.update(openPkg.name) }}
|
||||
onSetRowEnabled={setRowEnabled}
|
||||
@@ -1772,7 +1795,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{showsCards && activated !== undefined && !state.install.open
|
||||
{onInstalled && activated !== undefined && !state.install.open
|
||||
? renderSlot('plugins.bundle.activation', {
|
||||
packageName: activated.name,
|
||||
onDismiss: () => { setActivation(null) },
|
||||
|
||||
@@ -64,6 +64,7 @@ export const zh = {
|
||||
rowPhaseFailed: '异常',
|
||||
rowPhaseUnloading: '卸载中',
|
||||
enableToggle: '启用 {name}',
|
||||
applyingChange: '正在应用 {name} 的更改',
|
||||
openDetail: '查看 {name}',
|
||||
backToList: '返回插件',
|
||||
crumbRoot: '插件',
|
||||
@@ -225,6 +226,7 @@ export const zh = {
|
||||
reasonStaleApproval: '待允许的安装脚本列表已变化,请重新安装以刷新',
|
||||
reasonIncompatibleVersion: '{plugin} 与 DSH {runtime} 不兼容(要求 {requirements}),运行它可能导致崩溃或数据丢失。',
|
||||
reasonIncompatibleVersionUnnamed: '这个插件与当前 DSH 版本不兼容,运行它可能导致崩溃或数据丢失。',
|
||||
reasonUnsupportedPlatform: '此插件不支持运行 DSH 的主机操作系统',
|
||||
reasonIncompatibleInstall: '请安装与当前 DSH 兼容的插件版本。',
|
||||
reasonIncompatibleInstalled: '请卸载后重新安装与当前 DSH 兼容的版本。',
|
||||
reasonOperationError: 'Host 报告了一个错误',
|
||||
@@ -292,6 +294,7 @@ export const en = {
|
||||
rowPhaseFailed: 'Problem',
|
||||
rowPhaseUnloading: 'Unloading',
|
||||
enableToggle: 'Enable {name}',
|
||||
applyingChange: 'Applying changes to {name}',
|
||||
openDetail: 'View {name}',
|
||||
backToList: 'Back to plugins',
|
||||
crumbRoot: 'Plugins',
|
||||
@@ -453,6 +456,7 @@ export const en = {
|
||||
reasonStaleApproval: 'The pending script approvals changed; install again to refresh them.',
|
||||
reasonIncompatibleVersion: '{plugin} is incompatible with DSH {runtime} (requires {requirements}); running it may cause crashes or data loss.',
|
||||
reasonIncompatibleVersionUnnamed: 'This plugin is incompatible with the running DSH version; running it may cause crashes or data loss.',
|
||||
reasonUnsupportedPlatform: 'This plugin does not support the operating system running DSH.',
|
||||
reasonIncompatibleInstall: 'Install a plugin version compatible with this DSH.',
|
||||
reasonIncompatibleInstalled: 'Uninstall it and install a version compatible with this DSH.',
|
||||
reasonOperationError: 'The Host reported an error.',
|
||||
|
||||
@@ -75,6 +75,8 @@ export interface PackageRow {
|
||||
readonly enabled: boolean
|
||||
/** The entry's fiber phase, null without a live fiber. */
|
||||
readonly phase: PluginInfo['fiberPhase']
|
||||
/** Current startup failure projected by the Host inventory. */
|
||||
readonly diagnostic?: string
|
||||
/** Why the Host refuses to switch the row, when it does. */
|
||||
readonly readOnlyReason?: ReadOnlyReason
|
||||
}
|
||||
@@ -82,6 +84,8 @@ export interface PackageRow {
|
||||
/** One bundle as the page shows it: the Host's bundle joined with the entries its rows run as. */
|
||||
export interface PackageView {
|
||||
readonly name: string
|
||||
/** Experimental feature marker supplied by the bundle manifest. */
|
||||
readonly experimental?: boolean
|
||||
/** Official catalog identity is independent of installation and selection. */
|
||||
readonly official: boolean
|
||||
/** Readable package location, separately from the profile dependency declaration. */
|
||||
@@ -453,6 +457,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]):
|
||||
moduleName: row.moduleName,
|
||||
enabled: live?.enabled ?? false,
|
||||
phase: live?.fiberPhase ?? null,
|
||||
...live?.diagnostic === undefined ? {} : { diagnostic: live.diagnostic },
|
||||
...row.meta === undefined ? {} : { meta: row.meta },
|
||||
...row.entryId === undefined ? {} : { entryId: row.entryId },
|
||||
...live?.readOnlyReason === undefined ? {} : { readOnlyReason: live.readOnlyReason },
|
||||
@@ -465,6 +470,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]):
|
||||
...bundle.installTarget === undefined ? {} : { installTarget: bundle.installTarget },
|
||||
installed: bundle.installed,
|
||||
optional: bundle.optional,
|
||||
...bundle.experimental === undefined ? {} : { experimental: bundle.experimental },
|
||||
removable: bundle.removable,
|
||||
enabled: bundle.enabled,
|
||||
rows,
|
||||
|
||||
@@ -58,6 +58,7 @@ const CODE_KEYS = {
|
||||
'bundle-in-use': 'reasonBundleInUse',
|
||||
'stale-approval': 'reasonStaleApproval',
|
||||
'incompatible-version': 'reasonIncompatibleVersionUnnamed',
|
||||
'unsupported-platform': 'reasonUnsupportedPlatform',
|
||||
'operation-error': 'reasonOperationError',
|
||||
} satisfies Record<ManagementError['code'], PluginManagerLocaleKey>
|
||||
|
||||
@@ -115,12 +116,12 @@ export function shortName(name: string): string {
|
||||
* @returns localized copy with a technical-name fallback and the independent beta status.
|
||||
*/
|
||||
export function packageText(
|
||||
pkg: Pick<PackageView, 'name' | 'meta'>, resolveText: PluginManagerFace['resolveText'],
|
||||
pkg: Pick<PackageView, 'name' | 'meta' | 'experimental'>, resolveText: PluginManagerFace['resolveText'],
|
||||
): { title: string; description: string | undefined; beta: boolean } {
|
||||
return {
|
||||
title: pkg.meta?.title === undefined ? pkg.name : resolveText(pkg.meta.title),
|
||||
description: pkg.meta?.description === undefined ? undefined : resolveText(pkg.meta.description) || undefined,
|
||||
beta: pkg.name.startsWith('@deepseek-ai/dsh-experimental-'),
|
||||
beta: pkg.experimental === true || pkg.name.startsWith('@deepseek-ai/dsh-experimental-'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-
|
||||
export interface PluginConfigViewProps {
|
||||
/** `summary` renders the one-liner alone, as text or inline nodes; `page` renders the form with its save control. */
|
||||
readonly view: 'summary' | 'page'
|
||||
/** Whether the owning bundle is being changed; supplied on bundle configuration pages. */
|
||||
readonly busy?: boolean
|
||||
/** Host-owned configuration values and write actions for this page's entry. */
|
||||
readonly form?: ConfigPageForm | undefined
|
||||
}
|
||||
|
||||
@@ -206,6 +206,23 @@ function renderTab(
|
||||
}
|
||||
|
||||
describe('PluginManagerPage', () => {
|
||||
it('starts each page at the top and keeps scroll position during a refresh', () => {
|
||||
const { set } = renderTab({ packages: [pkg()] })
|
||||
const open = screen.getByRole('button', { name: 'View dsh-better-sidebar' })
|
||||
const panel = open.closest<HTMLElement>('[data-plugin-panel]')!
|
||||
panel.scrollTop = 350
|
||||
fireEvent.click(open)
|
||||
expect(panel.scrollTop).toBe(0)
|
||||
panel.scrollTop = 120
|
||||
set({ refreshStatus: 'refreshing' })
|
||||
expect(panel.scrollTop).toBe(120)
|
||||
set({ refreshStatus: 'idle', packages: [pkg({ version: '0.17.0' })] })
|
||||
expect(panel.scrollTop).toBe(120)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.backToList }))
|
||||
expect(panel.scrollTop).toBe(0)
|
||||
expect(screen.getByRole('heading', { name: en.title, level: 1 })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no tabs without contributions, and switches between Installed and a contributed tab', () => {
|
||||
const { actions, navigation, setTabs, set } = renderTab({ packages: [pkg()] }, {}, {
|
||||
'plugins.tab:discover': (_view, owner) => {
|
||||
@@ -707,6 +724,26 @@ describe('PluginManagerPage', () => {
|
||||
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true)
|
||||
})
|
||||
|
||||
it('keeps the persisted selection while marking a failed component as a problem', () => {
|
||||
renderTab({ packages: [pkg({ name: 'cua', enabled: true, rows: [row({ phase: 'failed', diagnostic: 'native preparation unavailable' })] })] })
|
||||
expect(document.querySelector('[data-plugin-package="cua"]')?.getAttribute('data-plugin-status')).toBe('problem')
|
||||
expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'cua') }).getAttribute('aria-checked')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'cua') }))
|
||||
expect(screen.getByText(`${en.reasonLabel}: native preparation unavailable`)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('marks a normally named Computer Use bundle experimental and explains persistent PTC before enabling', () => {
|
||||
const description = 'Enabling switches this profile to persistent PTC.'
|
||||
const { actions } = renderTab({ packages: [pkg({
|
||||
name: '@deepseek-ai/dsh-cua-bundle', experimental: true, enabled: false, optional: true, installed: false,
|
||||
meta: { title: 'Computer Use', description },
|
||||
})] })
|
||||
expect(screen.getByText(en.statusBeta)).toBeTruthy()
|
||||
expect(screen.getByText(description)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'Computer Use') }))
|
||||
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-cua-bundle', true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'@deepseek-ai/dsh-experimental-agent-team-profile',
|
||||
'@deepseek-ai/dsh-experimental-auto-review',
|
||||
@@ -1357,6 +1394,11 @@ describe('PluginManagerPage', () => {
|
||||
expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonIncompatibleVersionUnnamed} ${en.reasonIncompatibleInstalled}`)).toBeTruthy()
|
||||
fireEvent.click(within(detail).getByRole('button', { name: en.backToList }))
|
||||
expect(document.querySelector('[data-plugin-detail]')).toBeNull()
|
||||
set({ packages: [pkg({ enabled: false, error: { code: 'unsupported-platform' } })] })
|
||||
expect(screen.getByText(en.reasonUnsupportedPlatform)).toBeTruthy()
|
||||
expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', true)
|
||||
set({ packages: [pkg({ enabled: true, error: { code: 'unsupported-platform' } })] })
|
||||
expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', false)
|
||||
// A bundle that leaves the list drops back to the cards.
|
||||
const { version: _version, ...unversioned } = pkg()
|
||||
set({ packages: [unversioned] })
|
||||
@@ -1398,7 +1440,7 @@ describe('PluginManagerPage', () => {
|
||||
fireEvent.click(toggle('row-2'))
|
||||
expect(actions.setRowEnabled).toHaveBeenCalledTimes(1)
|
||||
expect(toggle('include:row-5')).toHaveProperty('disabled', true)
|
||||
expect(within(detail).getByText(en.rowPhaseFailed)).toBeTruthy()
|
||||
expect(within(detail.querySelector<HTMLElement>('[data-plugin-row="include:row-3"]')!).getByText(en.rowPhaseFailed)).toBeTruthy()
|
||||
expect(within(detail).getByText(en.rowPhaseLoading)).toBeTruthy()
|
||||
expect(within(detail).getByText(en.rowPhaseUnloading)).toBeTruthy()
|
||||
expect(document.querySelector('[data-plugin-row="include:row-3"]')?.getAttribute('data-state')).toBe('failed')
|
||||
@@ -2267,6 +2309,28 @@ describe('PluginManagerPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])('shows progress only for the changing bundle and clears it when settled (enabled=%s)', (enabled) => {
|
||||
const name = 'dsh-better-sidebar'
|
||||
const { set, setLanguage, actions } = renderTab({ packages: [pkg({ enabled }), pkg({ name: 'dsh-other' })] })
|
||||
const toggle = screen.getByRole('switch', { name: `Enable ${name}` })
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
fireEvent.click(toggle)
|
||||
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, !enabled)
|
||||
set({ busy: [name] })
|
||||
for (const dict of [en, zh]) {
|
||||
setLanguage(dict)
|
||||
const status = screen.getByRole('status', { name: dict.applyingChange.replace('{name}', name) })
|
||||
expect(status.querySelector('[data-state="ongoing"]')).not.toBeNull()
|
||||
}
|
||||
expect(toggle).toHaveProperty('disabled', true)
|
||||
fireEvent.click(toggle)
|
||||
expect(actions.setEnabled).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('switch', { name: '启用 dsh-other' })).toHaveProperty('disabled', false)
|
||||
set({ busy: [] })
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(toggle).toHaveProperty('disabled', false)
|
||||
})
|
||||
|
||||
it.each([false, true])('opens activation guidance details and returns to the originating list (experimental: %s)', (fromMore) => {
|
||||
const name = fromMore ? '@deepseek-ai/dsh-experimental-terminal-bundle' : 'dsh-better-sidebar'
|
||||
const bundle = pkg({ name, official: fromMore })
|
||||
@@ -2323,7 +2387,6 @@ it('supplies the accepted entry values and atomic mutation action to a custom pl
|
||||
expect(mutate).toHaveBeenCalledWith([{ op: 'set', path: ['count'], value: 3 }], 7)
|
||||
})
|
||||
|
||||
|
||||
describe('on-demand Official bundles', () => {
|
||||
const name = '@deepseek-ai/dsh-subagent-codex'
|
||||
const title = 'Codex subagent'
|
||||
@@ -2430,3 +2493,40 @@ describe('on-demand Official bundles', () => {
|
||||
expect(detail.querySelector('[data-plugin-rows]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the same activation guidance from a package detail switch', () => {
|
||||
const name = 'dsh-better-sidebar'
|
||||
const { set, actions } = renderTab({ packages: [pkg({ enabled: false })] }, { bundles: new Set([name]) }, {
|
||||
[`plugins.bundle.activation:${name}`]: (_view, owner) => <button onClick={(owner as PluginActivationOwnerProps).onDismiss}>Finish setup</button>,
|
||||
[`plugins.bundle.config:${name}`]: () => <div>Bundle setup</div>,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: `View ${name}` }))
|
||||
expect(screen.getByText('Bundle setup')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
expect(actions.setEnabled).toHaveBeenCalledWith(name, true)
|
||||
set({ packages: [pkg()], busy: [name] })
|
||||
expect(screen.queryByText('Finish setup')).toBeNull()
|
||||
set({ busy: [] })
|
||||
fireEvent.click(screen.getByText('Finish setup'))
|
||||
expect(screen.queryByText('Finish setup')).toBeNull()
|
||||
expect(screen.getByText('Bundle setup')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
set({ packages: [pkg({ enabled: false })] })
|
||||
expect(screen.queryByText('Finish setup')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
it('keeps bundle configuration mounted while reporting its pending operation', () => {
|
||||
const name = 'dsh-better-sidebar'
|
||||
const { set } = renderTab({ packages: [pkg()] }, { bundles: new Set([name]) }, {
|
||||
[`plugins.bundle.config:${name}`]: (_view, owner) => <div data-testid="bundle-config">{String((owner as { busy?: boolean }).busy)}</div>,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', name) }))
|
||||
const panel = screen.getByTestId('bundle-config')
|
||||
expect(panel.textContent).toBe('false')
|
||||
set({ busy: [name] })
|
||||
expect(screen.getByTestId('bundle-config')).toBe(panel)
|
||||
expect(panel.textContent).toBe('true')
|
||||
set({ busy: [] })
|
||||
expect(panel.textContent).toBe('false')
|
||||
})
|
||||
@@ -119,6 +119,13 @@ it('hands a custom page the shared configuration form of its entry', () => {
|
||||
})
|
||||
|
||||
describe('packageView', () => {
|
||||
it('carries the current inventory failure into a component row', () => {
|
||||
const plugins = PLUGINS.map(plugin => plugin.entryId === ROW_ENTRY
|
||||
? { ...plugin, fiberPhase: 'failed' as const, diagnostic: 'native preparation unavailable' } : plugin)
|
||||
expect(packageView(BUNDLE, plugins).rows[0]?.diagnostic).toBe('native preparation unavailable')
|
||||
expect(packageView(BUNDLE, PLUGINS).rows[0]?.diagnostic).toBeUndefined()
|
||||
})
|
||||
|
||||
it('joins a bundle with the entries its rows run as', () => {
|
||||
expect(packageView(BUNDLE, PLUGINS)).toEqual({
|
||||
name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.', source: 'dsh-better-sidebar@^0.16.0',
|
||||
@@ -1730,6 +1737,9 @@ it('recognizes the official registry without a trailing slash and with uppercase
|
||||
await vi.waitFor(() => { expect(state().install.registry).toEqual({ kind: 'offered', registry: MIRROR }) })
|
||||
})
|
||||
|
||||
it('retains the bundle experimental marker in its client projection', () => {
|
||||
expect(packageView({ ...BUNDLE, experimental: true }, PLUGINS)).toMatchObject({ experimental: true })
|
||||
})
|
||||
|
||||
describe('desktop analytics outcomes', () => {
|
||||
it('redacts authenticated installer URLs from click and failed-result events', async () => {
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: dfc89b14d4c0d21e
|
||||
zh: 062b233951ed157b
|
||||
/deepseek-ai-dsh-host-plugin-inventory/use-this-package/what-you-can-and-cannot-do-with-it:
|
||||
en: 387f87777eef7ec5
|
||||
zh: badcd90c8840f870
|
||||
en: 0243819e8404c74a
|
||||
zh: 05dab926c0ecff34
|
||||
/deepseek-ai-dsh-host-plugin-inventory/understand-the-implementation:
|
||||
en: 42b15ac6321706aa
|
||||
zh: 4ed0861911abaf5d
|
||||
|
||||
@@ -41,6 +41,8 @@ With a roster composed, `agentPresets` carries one group per preset in roster or
|
||||
|
||||
The inventory is a snapshot for display and diagnostics: a client can render the roster, flag failed entries, and detect changes by comparing snapshots. It cannot enable, disable, add, or remove plugins, and it carries no history — a fiber that already failed and was removed is absent. Because the service reads the Loader on every call, the answer always reflects the current composition rather than a cached view.
|
||||
|
||||
Failed entries include the current startup diagnostic read from their Cordis fiber. Recovery removes it; inventory reads do not wait for loading entries or retain a separate error cache.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -41,6 +41,8 @@ Loader 行与预设行可以携带可选的 `meta`,其中包含标题、描述
|
||||
|
||||
该清单是供展示与诊断的快照:客户端可以渲染名单、标出失败条目,并通过比较快照检测变化。它不能启用、停用、添加或移除插件,也不携带历史——已经失败并被移除的 fiber 缺席。由于服务每次调用都读取 Loader,答案总是反映当前组合,而不是缓存视图。
|
||||
|
||||
失败条目包含从 Cordis fiber 读取的当前启动诊断。恢复后移除该诊断;清单查询不等待仍在加载的条目,也不另存错误缓存。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -86,11 +86,18 @@ export async function readPluginInventory(ctx: Context): Promise<PluginInventory
|
||||
if (entry.options.group) continue
|
||||
const base = entry.parent.tree.ctx.baseUrl
|
||||
const meta = base === undefined ? undefined : packages?.metaOf(entry.options.name, base)
|
||||
const fiber = entry.fiber
|
||||
const fiberPhase = fiber === undefined ? null : FIBER_PHASE[fiber.state]
|
||||
let diagnostic: string | undefined
|
||||
if (fiber !== undefined && fiberPhase === 'failed') {
|
||||
try { await fiber.await() } catch (error) { diagnostic = error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
entries.push({
|
||||
entryId: pluginEntryId(entry.id),
|
||||
moduleName: entry.options.name,
|
||||
enabled: !entry.disabled,
|
||||
fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
|
||||
fiberPhase,
|
||||
...diagnostic === undefined ? {} : { diagnostic },
|
||||
...meta === undefined ? {} : { meta },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface PluginInventoryEntry {
|
||||
/** Effective Loader enablement, including disabled ancestor groups. */
|
||||
readonly enabled: boolean
|
||||
readonly fiberPhase: PluginFiberPhase
|
||||
/** Current startup failure message; absent when this entry has no failed fiber. */
|
||||
readonly diagnostic?: string
|
||||
}
|
||||
|
||||
/** Effective enablement of one preset composition row. */
|
||||
|
||||
@@ -149,6 +149,30 @@ describe('PluginInventoryGateway', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['error', 'stack object'] as const)('reads %s startup diagnostics from failed fibers and clears them after recovery', async (kind) => {
|
||||
const { ctx, inventory } = await harness()
|
||||
const failure: unknown = kind === 'error' ? new Error('native preparation unavailable')
|
||||
: { stack: 'plugin startup failure', toString: () => 'native preparation unavailable' }
|
||||
ctx.loader.builtins.failing = {
|
||||
async apply(_ctx: Context, config: { fail: boolean }) {
|
||||
if (config.fail) throw failure
|
||||
},
|
||||
}
|
||||
const entry = ctx.loader.resolve(await ctx.loader.create({ name: 'cordis:failing', config: { fail: true } }))
|
||||
await expect(entry.fiber!.await()).rejects.toBe(failure)
|
||||
for (let index = 0; index < 2; index++) {
|
||||
expect((await inventory.list()).entries).toContainEqual({
|
||||
entryId: entry.id, moduleName: 'cordis:failing', enabled: true,
|
||||
fiberPhase: 'failed', diagnostic: 'native preparation unavailable',
|
||||
})
|
||||
}
|
||||
await entry.update({ config: { fail: false } })
|
||||
await entry.fiber!.await()
|
||||
expect((await inventory.list()).entries).toContainEqual({
|
||||
entryId: entry.id, moduleName: 'cordis:failing', enabled: true, fiberPhase: 'active',
|
||||
})
|
||||
})
|
||||
|
||||
it('projects current non-group Loader entries without a second cache', async () => {
|
||||
const { ctx, inventory } = await harness()
|
||||
const activeId = await ctx.loader.create({ name: 'cordis:active' })
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 1db86285abf6c499
|
||||
/deepseek-ai-dsh-package-manifest/use-this-package:
|
||||
en: f275964a8845888a
|
||||
zh: b284e09aacea079b
|
||||
en: c4bbd42bbe88fd5b
|
||||
zh: f948c43f2eb44b18
|
||||
/deepseek-ai-dsh-package-manifest/understand-the-implementation:
|
||||
en: 41ec449dee927f04
|
||||
zh: ec1b583bd9025d84
|
||||
|
||||
@@ -50,6 +50,9 @@ The following metadata fields are optional. Omitting them leaves the format vers
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `dsh.manifestVersion` | Manifest format identifier; the declared format is `1`, independent of the npm package version and Session format version. |
|
||||
| `dsh.bundle.platforms` | Optional nonempty list of Node Host platform names, such as `["darwin"]`; omission allows every operating system. The launcher and plugin manager enforce it before composing or selecting the bundle. |
|
||||
| `dsh.bundle.experimental` | Explicit experimental feature label in plugin management; independent of the package name and enabled state. |
|
||||
| `dsh.bundle.configureOnEnable` | Entry-id map of JSON config fields that Plugin Manager saves on explicit enablement. Settings use ordinary profile reconciliation and remain after disablement; see [Plugin Manager](../../boot/plugin-manager/README.md). |
|
||||
| `engines.dsh` | Author-declared compatible DSH versions as a SemVer range, including exact prerelease versions. This field sits beside `engines.node` and `engines.npm`; an engines object may omit `dsh`. |
|
||||
|
||||
`LocalizedText` carries literal text or a language map with a required English fallback. `PluginLocalizedMeta` carries optional display title, description, an image data URL resolved from a package root's `package.json.icon` or an exported `<specifier>/icon`, and metadata diagnostics for installed plugins. [App boot](../../boot/app-boot/README.md) reads these values; this package only supplies their types.
|
||||
|
||||
@@ -50,6 +50,9 @@ const manifest: DshPackageManifest = {
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `dsh.manifestVersion` | manifest(元数据清单)格式标识;声明的格式为 `1`,独立于 npm 包版本和 Session 格式版本。 |
|
||||
| `dsh.bundle.platforms` | 可选的非空 Node Host 平台名称列表,例如 `["darwin"]`;省略时允许所有操作系统。启动器和插件管理器在组合或选择该组合包前检查此字段。 |
|
||||
| `dsh.bundle.experimental` | 在插件页显示实验性标签,不改变包名或启用状态。 |
|
||||
| `dsh.bundle.configureOnEnable` | 以条目 id 为键的 JSON 配置字段映射,由插件管理器在显式启用时保存。通过正常 profile 重组生效,关闭保留;见[插件管理器](../../boot/plugin-manager/README.zh.md)。 |
|
||||
| `engines.dsh` | 作者声明的兼容 DSH 版本,使用 SemVer 范围,也可填写精确的预发布版本。此字段与 `engines.node`、`engines.npm` 并列;engines 对象可省略 `dsh`。 |
|
||||
|
||||
`LocalizedText` 携带字面文本或带必需英文回退值的语言映射。`PluginLocalizedMeta` 携带已安装插件的可选展示标题、描述、从包根 `package.json.icon` 或导出的 `<标识>/icon` 解析的图片 data URL,以及元信息诊断。[App boot](../../boot/app-boot/README.zh.md) 读取这些值;本包只提供其类型。
|
||||
|
||||
@@ -67,6 +67,14 @@ export interface DshEnginesManifest {
|
||||
|
||||
/** The configuration layer exported by a bundle package. */
|
||||
export interface DshBundleManifest {
|
||||
/** Top-level fields saved to existing profile entries on explicit enablement.
|
||||
* Settings remain after disablement.
|
||||
*/
|
||||
configureOnEnable?: Record<string, Record<string, unknown>>
|
||||
/** Supported Node Host platform names; omission allows every operating system. */
|
||||
platforms?: string[]
|
||||
/** Mark this optional feature as experimental in plugin management. */
|
||||
experimental?: boolean
|
||||
/** One patch file path, or an ordered list applied in sequence, each relative to the declaring package root. */
|
||||
patch: string | string[]
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user