feat: plugin-registry 标准发布面(dsh.plugin.json + 双 client bundle + registry 打包),v0.1.2

- dsh.plugin.json 原生清单:id dsh-external/dsh-better-sidebar,version 0.1.2,
  main = lib/index.js,client.main = lib/client-registry.js,contributes 空声明
- tsdown client 构建参数化:lib/client.js(官方通道,id = 包名)+ lib/client-registry.js
  (registry 通道,id = 清单 id),同源不漂移
- scripts/package-registry.mjs 组装 registry/ 安装根(gitignore,不入库)
- tests/manifest-consistency.spec.ts:id 两段校验 / version 同步 / 入口存在 /
  双 bundle id 不互换 / contributes 空
- README:新增「通过 plugin-registry 安装(标准)」章节 + 保留官方 profile 安装
  方案 + 通道互斥说明;架构表补 manifest 与双 client 行
This commit is contained in:
Menghuan1918
2026-08-09 23:49:58 +08:00
parent 9b00135de4
commit 98bdf4a6ab
7 changed files with 262 additions and 35 deletions
+3
View File
@@ -142,3 +142,6 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
lib/
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
registry/
+59 -5
View File
@@ -5,6 +5,31 @@
<img width="4632" height="2720" alt="image" src="https://github.com/user-attachments/assets/39d86636-7654-412f-86ea-c60a2d5f20f0" />
<img width="1000" height="1186" alt="image" src="https://github.com/user-attachments/assets/9dadffe0-0738-4b6d-b929-f452f51768a2" />
## 🚀 通过 plugin-registry 安装(标准)
前置条件:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 命令可用;集成步骤见其 README)。
```sh
git clone https://github.com/dsh-external/DSH-better-sidebar.git
cd DSH-better-sidebar
pnpm install && pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types
node scripts/package-registry.mjs # 组装 registry/ 暂存目录(只含清单 + 构建产物 + README,不入库)
dsh registry install ./registry # 安装(默认禁用——信任边界)
dsh registry enable dsh-external/dsh-better-sidebar # 启用
```
> **通道互斥**:registry 安装与下方「不依赖 plugin-registry 的官方 profile 安装」是同一插件的两种安装方式,**每个部署二选一**——同时启用会双挂载(Node 半挂两次、页面渲染两个侧边栏)。已用官方方式装过时,先移除 `~/.dsh/profiles/web/cordis.patch.yml` 里的 better-sidebar 挂载行与 `package.json` 的 `link:` 依赖,再走 registry。
### 更新(registry 通道)
```sh
cd DSH-better-sidebar && git pull && pnpm install && pnpm build
node scripts/package-registry.mjs
dsh registry uninstall dsh-external/dsh-better-sidebar
dsh registry install ./registry
dsh registry enable dsh-external/dsh-better-sidebar
```
## 🚀 一键安装(把提示词发给 DSH)
把下面提示词**整段**发给 DSH,它会自动完成克隆、构建、注册与安装(前置条件:已安装 DSH 且 `dsh web` 可运行,Node.js ≥ 20、pnpm ≥ 10):
@@ -139,7 +164,7 @@
git clone https://github.com/dsh-external/DSH-better-sidebar.git
cd DSH-better-sidebar
pnpm install
pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js (client) + lib/types/*.d.ts
pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types/*.d.ts
```
> 运行期依赖(cordis、react、`@deepseek-ai/dsh-client-*` 等)按官方插件清单规范声明在 `peerDependencies`——web profile 已内置全部 peer 依赖,安装本插件无需额外装包;`dependencies` 只保留插件自有的运行时依赖(node-pty、ws、xterm、CodeMirror、schemastery 等)。
@@ -177,22 +202,49 @@ git pull && pnpm install && pnpm build # 在插件仓库目录执行
`link:` 引用无需重新 install。
### 通过 registry 安装(手动,与上面的官方方案二选一)
前置条件:`dsh registry` 命令可用(DSH 已集成 plugin-registry)。安装源是 `scripts/package-registry.mjs` 组装的 `registry/` 暂存目录——只含清单 + 构建产物 + README,避免把 `node_modules/`(本机 `link:` 符号链接、node-pty 二进制)与 `.git/` 一并装进 `<dshHome>/plugins`。
```sh
pnpm build # 先构建(产物见上)
node scripts/package-registry.mjs # 组装 registry/(重新运行会整体重建)
dsh registry install ./registry # 安装(默认禁用)
dsh registry enable dsh-external/dsh-better-sidebar
```
更新:
```sh
git pull && pnpm install && pnpm build
node scripts/package-registry.mjs
dsh registry uninstall dsh-external/dsh-better-sidebar
dsh registry install ./registry
dsh registry enable dsh-external/dsh-better-sidebar
```
- 只改了 **client** 代码 → 硬刷新页面即可(registry 通道的 bundle 由服务器按请求读取)
- 改了 **host** 代码 → 重启 DSH + 硬刷新
- 与官方方案的互斥见上文「通道互斥」说明
## 🛠️ 开发
```sh
pnpm typecheck # tsc --noEmit
pnpm test # vitest(单元 + 冒烟 + 插件形态 guard:真实 git/fs/node-pty 交互)
pnpm build # rm -rf lib && tsc(声明) + tsdown → lib/index.js + lib/invariant.js + lib/client.js + lib/types
pnpm build # rm -rf lib && tsc(声明) + tsdown → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(单元 + 冒烟 + 插件形态 guard:真实 git/fs/node-pty 交互;manifest 一致性测试读取 lib/,需先 build)
pnpm watch # tsdown --watch(client bundle 热重建)
```
### 规范符合性
插件按 DSH 官方插件规范组织(参考 [dsh-external/turtle-ui](https://github.com/dsh-external/turtle-ui) 与 mainline `packages/client/AGENTS.md`)。**安装通道与渲染方式是两个独立概念**:安装始终走 profile 清单协议(`dsh plugin` / cordis.yml 行,见[一键安装](#一键安装把提示词发给-dsh));下面的 portal 条目只描述面板在页面上的渲染方式。
插件按 DSH 官方插件规范组织(参考 [dsh-external/turtle-ui](https://github.com/dsh-external/turtle-ui) 与 mainline `packages/client/AGENTS.md`),并额外提供 plugin-registry 标准发布面(`dsh.plugin.json`)。**安装通道与渲染方式是两个独立概念**:安装走 profile 清单协议(`dsh plugin` / cordis.yml 行,见[一键安装](#一键安装把提示词发给-dsh))**或** registry 通道(`dsh registry`,见[通过 plugin-registry 安装](#通过-plugin-registry-安装标准)),二者互斥;下面的 portal 条目只描述面板在页面上的渲染方式。
- **插件形态**:`export const name / inject / Config / apply`,无 default 导出;`tests/plugin-shape.spec.ts` 通过 `Loader.unwrapExports` 守卫该形态
- **清单**:`types` + `exports`(`.` / `./invariant` / `./client` / `./src/*` / `./package.json`)、`dshClient`(`platform: 'web'` + 信息性 `inject` 边)、peerDependencies、`engines`、`files` 产物明细、`prepare`(消费者侧 `tsdown`,git 安装可用)
- **client 契约**:仅导出 `apply`/`inject`(+ 类型);store 为 `createSidebarStore()` 工厂,实例归 `apply` 所有;`src/invariant.ts` 伴生;client bundle 复刻官方 preset(externals = 平台模块表 + runtime/client 豁免、纯度门、CSS Modules 内联)
- **registry 形态(标准发布面)**:仓库根 `dsh.plugin.json`(原生清单:id `dsh-external/dsh-better-sidebar`、`main` = host 构建产物、`client.main` = registry client bundle、`contributes` 空声明);`tests/manifest-consistency.spec.ts` 守卫清单与构建产物一致性(id 严格两段、version 与 package.json 同步、bundle id = 清单 id)
- **双 client bundle**:同一 `src/client/index.tsx` 构建出 `lib/client.js`(官方通道,id = 包名 `dsh-better-sidebar`)与 `lib/client-registry.js`(registry 通道,id = 清单 id)——两通道的 bundle-id 契约互不相同,各产出一份、同源不漂移;每个部署二选一(见安装章节「通道互斥」)
- **已知偏差**:侧边栏面板经 portal(`document.body` + `createRoot`)挂载而非 slot——官方 slot 系统的 `'root'` 由 ui-layout 独占声明,重复声明 fail loud,外部插件无整面板 slot 可用;与 shell 的集成点(`conversation.chat.turnTail`)走官方 chain-slot 机制
## 🏗️ 架构
@@ -203,7 +255,9 @@ pnpm watch # tsdown --watch(client bundle 热重建)
|---|---|---|
| host | `src/index.ts` → `lib/index.js` | cordis 插件(`Config` 为 schemastery schema,可调 readLimit/mediaLimit/listLimit/terminalsPerSession/reconnectGraceMs):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/ws/terminal` WebSocket;fs / git / pty 服务 |
| invariant | `src/invariant.ts` → `lib/invariant.js` | 包属 invariant 伴生(注册包名,无运行时断言) |
| client | `src/client/index.tsx` → `lib/client.js` | 浏览器 bundle(`__ModuleLoader__.load` 闭包工厂):portal 侧边栏 + 各视图 + turnTail 拦截 |
| client(官方通道) | `src/client/index.tsx` → `lib/client.js` | 浏览器 bundle(`__ModuleLoader__.load` 闭包工厂,id = 包名):portal 侧边栏 + 各视图 + turnTail 拦截 |
| client(registry 通道) | `src/client/index.tsx` → `lib/client-registry.js` | 同一源码的 registry 版本(id = 清单 id `dsh-external/dsh-better-sidebar`,`dsh.plugin.json` 的 `client.main` 指向它) |
| manifest | `dsh.plugin.json`(仓库根;`scripts/package-registry.mjs` 组装 `registry/` 安装根) | registry 发布面:id / version / main / client.main;仅 registry 通道读取 |
- 所有 API 携带 `sessionId`;cwd 权威值取自会话 header,会话未附加(页面加载竞态)时回退客户端摘要 cwd,再回退进程 cwd;终端按 `${sessionId}:${tabId}` 键控,重连时若权威 cwd 变化则重启 shell 到正确目录
- 路由与 `/api` 同款信任围栏(Host 头 loopback / `connection.trustedHosts`,`src/trust-fence.ts`,拷贝自 `@deepseek-ai/dsh-client-connection`,BSD-3-Clause)
+16
View File
@@ -0,0 +1,16 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.1.2",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离",
"engines": {
"dsh": ">=0.0.1"
},
"contributes": {
"tools": [],
"skills": []
},
"client": {
"main": "./lib/client-registry.js"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
"version": "0.1.1",
"version": "0.1.2",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.",
"private": true,
"type": "module",
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Assemble the plugin-registry installable root (`registry/`) from the built
* `lib/` output. Run after `pnpm build`:
*
* pnpm build
* node scripts/package-registry.mjs
* dsh registry install ./registry
* dsh registry enable dsh-external/dsh-better-sidebar
*
* The registry install copies the whole source directory (`cp -r`), so
* installing the repo root would drag in `node_modules/` (machine-local
* `link:` devDependency symlinks, node-pty binaries) and `.git/`. This
* staging directory carries exactly the files the manifest references plus
* the docs — same layout as the dsh-subagent-tree `registry/` pattern. It is
* gitignored and rebuilt from scratch on every run.
*/
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(fileURLToPath(import.meta.url), '..', '..')
const out = join(root, 'registry')
/** Files copied into registry/, preserving relative paths (lib/ stays lib/). */
const files = [
'dsh.plugin.json',
'lib/index.js',
'lib/invariant.js',
'lib/client-registry.js',
'lib/client-registry.js.map',
'README.md',
'LICENSE',
]
rmSync(out, { recursive: true, force: true })
mkdirSync(out, { recursive: true })
for (const file of files) {
const source = join(root, file)
if (!existsSync(source)) {
console.error(`missing ${file} — run \`pnpm build\` first`)
process.exit(1)
}
const target = join(out, file)
mkdirSync(dirname(target), { recursive: true })
cpSync(source, target)
}
console.log(`registry/ assembled (${files.length} files)`)
console.log('next: dsh registry install ./registry && dsh registry enable dsh-external/dsh-better-sidebar')
+79
View File
@@ -0,0 +1,79 @@
/**
* Registry manifest consistency spec: guards the plugin-registry release form
* (`dsh.plugin.json`) against the build output and package.json, so the repo
* cannot drift into an uninstallable / uneable state:
* - `id` passes the registry's strict two-segment validation (native form),
* - `version` stays in sync with package.json,
* - the manifest entry files exist in the build output,
* - the registry client bundle registers exactly the manifest id (the
* browser-side `arrive()` contract), and the official client bundle keeps
* the package-name id (the client-modules compose contract) — the two
* bundles must never be swapped.
*
* Reads the built lib/ output, so run `pnpm build` first (the registry
* install validates main/client.main existence the same way).
*/
import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)))
/** The registry manifest face this spec touches (mirror of the registry's PluginManifest). */
interface PluginManifest {
id: string
version: string
main: string
description?: string
engines?: { dsh?: string }
contributes?: { tools?: string[]; skills?: string[] }
client?: { main?: string; inject?: string[]; immediately?: boolean }
}
interface PackageJson {
name: string
version: string
}
const manifest = JSON.parse(readFileSync(resolve(ROOT, 'dsh.plugin.json'), 'utf8')) as PluginManifest
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as PackageJson
/** The registry's strict id validation (manifest.ts / client-modules registerExternal): exactly two lowercase slash-separated segments. */
const ID_PATTERN = /^(?!node_modules\/)(?:@[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*|[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*)$/
/** The registered `__ModuleLoader__.load({ id })` value of a built client bundle. */
function bundleId(file: string): string {
const source = readFileSync(resolve(ROOT, file), 'utf8')
const match = /load\(\{\s*id:\s*"([^"]+)"/.exec(source)
if (match === null) throw new Error(`${file} registers no __ModuleLoader__.load id — run \`pnpm build\` first`)
return match[1]!
}
describe('registry manifest consistency (dsh.plugin.json)', () => {
it('id is a valid two-segment registry id (native form)', () => {
expect(manifest.id).toMatch(ID_PATTERN)
})
it('version matches package.json', () => {
expect(manifest.version).toBe(pkg.version)
})
it('main and client.main exist in the build output', () => {
expect(existsSync(resolve(ROOT, manifest.main))).toBe(true)
expect(manifest.client?.main).toBeDefined()
expect(existsSync(resolve(ROOT, manifest.client!.main!))).toBe(true)
})
it('the registry bundle registers exactly the manifest id and the official bundle the package name (no swap)', () => {
const registryId = bundleId(manifest.client!.main!)
expect(registryId).toBe(manifest.id)
expect(bundleId('lib/client.js')).toBe(pkg.name)
expect(registryId).not.toBe(pkg.name)
})
it('contributes declares no tools or skills (the host half registers none)', () => {
expect(manifest.contributes?.tools).toEqual([])
expect(manifest.contributes?.skills).toEqual([])
})
})
+55 -29
View File
@@ -1,10 +1,21 @@
/**
* tsdown build for dsh-better-sidebar: the host-half lib (lib/index.js and
* the lib/invariant.js companion, ESM node) plus the browser client bundle
* (lib/client.js, CJS closure factory).
* the lib/invariant.js companion, ESM node) plus the two browser client
* bundles (lib/client.js and lib/client-registry.js, CJS closure factory) —
* one per install channel:
*
* The client bundle replicates the official DSH client-bundle preset
* (packages/client/tsdown.client.ts):
* - `lib/client.js` serves the official profile channel, registering with
* the package-name id `dsh-better-sidebar` (the client-modules compose
* keys on the package name; keep it in sync with package.json `name`),
* - `lib/client-registry.js` serves the plugin-registry channel
* (dsh.plugin.json), registering with the manifest id
* `dsh-external/dsh-better-sidebar` (the registry browser-side `arrive()`
* check requires bundle id === plugin id).
*
* Both bundles replicate the official DSH client-bundle preset
* (packages/client/tsdown.client.ts) and are compiled from the same
* src/client/index.tsx source — only the registered id and the output file
* name differ, so they cannot drift:
* - externals resolve through the loader module table at runtime (the
* PLATFORM_MODULES seed list from apps/web's platform.ts, plus the
* runtime/client exemption),
@@ -13,7 +24,7 @@
* collaboration goes through cordis services, never value imports,
* - CSS Modules compile to hashed class maps and inject <style data-plugin>
* tags at factory execution,
* - the artifact registers itself via window.__ModuleLoader__.load({id,
* - each artifact registers itself via window.__ModuleLoader__.load({id,
* factory}) with the (require) => exports CJS closure shape.
*
* Types ship from lib/types (tsc -p tsconfig.build.json), not from tsdown.
@@ -27,8 +38,6 @@ import { transform } from 'lightningcss'
const require = createRequire(import.meta.url)
const PLUGIN_ID = 'dsh-better-sidebar'
/** Module specifiers the web shell shares into the frozen module table (the official PLATFORM_MODULES list, plus the runtime/client exemption). */
const CLIENT_EXTERNALS = [
'react',
@@ -58,14 +67,14 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
const REPOSITORY_ROOT = fileURLToPath(new URL('.', import.meta.url))
/** The style-injection prologue shared by module css and plain css loads. */
function injectTag(fileId: string, cssText: string): string {
const tagId = `${PLUGIN_ID}/${basename(fileId)}`
function injectTag(pluginId: string, fileId: string, cssText: string): string {
const tagId = `${pluginId}/${basename(fileId)}`
return [
`const css = ${JSON.stringify(cssText)};`,
`const tagId = ${JSON.stringify(tagId)};`,
`if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`,
` const tag = document.createElement('style');`,
` tag.dataset.plugin = ${JSON.stringify(PLUGIN_ID)};`,
` tag.dataset.plugin = ${JSON.stringify(pluginId)};`,
` tag.dataset.pluginCss = tagId;`,
` tag.textContent = css;`,
` document.head.appendChild(tag);`,
@@ -81,21 +90,18 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
return `../../../${repositoryPath}`
}
export default [
{
entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' },
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
// clean stays off: the build script removes lib/ wholesale before tsc, so
// a tsdown clean here would wipe the lib/types declarations tsc just
// emitted (and `watch` must never touch them).
clean: false,
},
{
/**
* One client bundle build for a plugin id. The same src/client/index.tsx is
* compiled twice with only the registered id and the output file name
* differing: the official channel uses the package name (`dsh-better-sidebar`)
* and the registry channel uses the manifest id
* (`dsh-external/dsh-better-sidebar`).
* @param pluginId - the `__ModuleLoader__.load({ id })` value and the
* data-plugin style-tag prefix of this bundle.
* @param entryFile - the output file name under lib/.
*/
function clientBundle(pluginId: string, entryFile: string): UserConfig {
return {
entry: { client: 'src/client/index.tsx' },
outDir: 'lib',
format: 'cjs',
@@ -160,22 +166,42 @@ export default [
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
return [
injectTag(fileId, code.toString()),
injectTag(pluginId, fileId, code.toString()),
`export default ${JSON.stringify(classMap)};`,
].join('\n')
}
return [
injectTag(fileId, source.toString('utf8')),
injectTag(pluginId, fileId, source.toString('utf8')),
'export default "";',
].join('\n')
},
}],
outputOptions: {
entryFileNames: 'client.js',
entryFileNames: entryFile,
sourcemapPathTransform: browserSourcePath,
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(PLUGIN_ID)}, factory: (require) => {`,
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(pluginId)}, factory: (require) => {`,
footer: `return module.exports; } });`,
intro: 'var module = { exports: {} }; var exports = module.exports;',
},
}
}
export default [
{
entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' },
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
// clean stays off: the build script removes lib/ wholesale before tsc, so
// a tsdown clean here would wipe the lib/types declarations tsc just
// emitted (and `watch` must never touch them).
clean: false,
},
// Official profile channel: bundle id = package name (package.json `name`).
clientBundle('dsh-better-sidebar', 'client.js'),
// Plugin-registry channel: bundle id = manifest id (dsh.plugin.json `id`).
clientBundle('dsh-external/dsh-better-sidebar', 'client-registry.js'),
] satisfies UserConfig[]