@dsh-workbench/module-tools 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/bin/dsh-wb-module.mjs +4 -2
- package/package.json +3 -2
- package/src/build.mjs +2 -11
- package/src/check.mjs +88 -7
- package/src/checks/alias.mjs +51 -29
- package/src/checks/bundles.mjs +44 -31
- package/src/checks/cards.mjs +119 -107
- package/src/checks/contracts.mjs +621 -116
- package/src/checks/kit.mjs +127 -64
- package/src/checks/markers.mjs +55 -32
- package/src/checks/primitives.mjs +82 -57
- package/src/checks/source.mjs +106 -92
- package/src/checks/standalone.mjs +54 -24
- package/src/checks/style.mjs +133 -94
- package/src/css-injection.mjs +27 -0
- package/src/design.mjs +424 -0
- package/src/discover.mjs +1 -1
- package/src/init.mjs +6 -1
- package/src/publish.mjs +134 -0
- package/template/scripts/smoke.mjs.tpl +104 -4
- package/template-diy/README.md.tpl +282 -0
- package/template-diy/cordis.patch.yml.tpl +3 -0
- package/template-diy/design.json.tpl +38 -0
- package/template-diy/package.json.tpl +43 -0
- package/template-diy/scripts/smoke.mjs.tpl +232 -0
- package/template-diy/src/client.tsx.tpl +258 -0
- package/template-diy/src/host.ts.tpl +131 -0
- package/template-diy/src/messages.ts.tpl +28 -0
- package/template-diy/src/plugin.test.ts.tpl +359 -0
- package/template-diy/tsconfig.json +20 -0
- package/template-diy/types/globals.d.ts +27 -0
- package/template-diy/vitest.config.ts +14 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
// 模块包的接线自检:把 client.js
|
|
1
|
+
// 模块包的接线自检:把 client.js 当成浏览器里的加载器那样跑一遍,再把宿主产物当 ESM 跑一遍。
|
|
2
2
|
//
|
|
3
3
|
// 为什么值得有:宿主侧挂没挂上,curl /panels 就能看见;浏览器侧挂没挂上,过去只能靠"刷个页面看看"。
|
|
4
4
|
// 这个脚本复刻 dsh 加载器的**契约**(`window.__ModuleLoader__.load({ id, factory })` + `factory(require)`),
|
|
5
5
|
// 于是"模块注册的卡片到底进没进外壳"变成一条机器判据,而不再是人的眼睛。
|
|
6
6
|
//
|
|
7
|
+
// ★ 这份脚本只验**形状**:它认不出成员名、也不认识你的数据源,所以换数据源、换卡片内容都不用改它。
|
|
8
|
+
// 你自己的那条读该不该跑通,属于 `?wbMock=1` 三档夹具与你自己 vitest 的事。
|
|
9
|
+
//
|
|
7
10
|
// 负例在最后一段:服务不在的时候必须**报出来**,不许静默通过 —— 静默的模块是最难查的那种坏。
|
|
8
11
|
|
|
9
12
|
const PLUGIN_ID = '__NAME__';
|
|
@@ -19,9 +22,45 @@ function check(name, ok, detail) {
|
|
|
19
22
|
console.log(`❌ ${name}${detail === undefined ? '' : ` —— ${detail}`}`);
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
/**
|
|
26
|
+
* 什么属性都拿得到、什么调用都返回自己的替身 —— 这样"模块用了 kit 的哪个成员"就不再是
|
|
27
|
+
* 这份脚本要知道的事(`check:cards` 用的是同一招)。真红只留给模块自己的代码。
|
|
28
|
+
*/
|
|
29
|
+
function anyStub(path = 'stub') {
|
|
30
|
+
const target = function () {
|
|
31
|
+
return anyStub(`${path}()`);
|
|
32
|
+
};
|
|
33
|
+
return new Proxy(target, {
|
|
34
|
+
get(_target, key) {
|
|
35
|
+
if (key === Symbol.toPrimitive) return () => 0;
|
|
36
|
+
if (key === Symbol.iterator) return function* () {};
|
|
37
|
+
if (key === 'then') return undefined;
|
|
38
|
+
if (key === 'length') return 0;
|
|
39
|
+
if (key === 'toString') return () => path;
|
|
40
|
+
return anyStub(`${path}.${String(key)}`);
|
|
41
|
+
},
|
|
42
|
+
apply() {
|
|
43
|
+
return anyStub(`${path}()`);
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 外壳 kit 的替身:**三个桶都必须在** —— 卡片是在模块顶层解构 kit 的,少一个桶是 import 那一刻就抛。 */
|
|
49
|
+
function fakeKit() {
|
|
50
|
+
return {
|
|
51
|
+
hooks: new Proxy({}, { get: () => () => anyStub('hook') }),
|
|
52
|
+
components: new Proxy({}, { get: () => () => null }),
|
|
53
|
+
api: new Proxy({}, { get: () => () => anyStub('api') }),
|
|
54
|
+
lib: anyStub('lib'),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
22
58
|
function moduleRequire(name) {
|
|
23
|
-
if (name === 'react')
|
|
24
|
-
|
|
59
|
+
if (name === 'react') {
|
|
60
|
+
// 必须是**真的元素对象**:返回 null 的话,"卡片画得出来"这类断言会永远假红。
|
|
61
|
+
return { createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }) };
|
|
62
|
+
}
|
|
63
|
+
if (name === '@dsh-workbench/core/client') return { kit: fakeKit() };
|
|
25
64
|
throw new Error(`模块包不该 require ${JSON.stringify(name)}:模块表里只有外壳的 seed 词与外部声明`);
|
|
26
65
|
}
|
|
27
66
|
|
|
@@ -71,7 +110,25 @@ pluginExports.apply(ctx);
|
|
|
71
110
|
check('卡片真的注册进了外壳', faces.length === 1, `注册了 ${faces.length} 个`);
|
|
72
111
|
check(`注册的是 ${UNIT_ID} 单元`, faces[0]?.manifest.id === UNIT_ID, faces[0]?.manifest.id);
|
|
73
112
|
check('卡片是个能画的组件', typeof faces[0]?.card === 'function');
|
|
74
|
-
check(
|
|
113
|
+
check(
|
|
114
|
+
'卡片自己带标题与顺序(外壳的看板据此排)',
|
|
115
|
+
typeof faces[0]?.manifest.card?.titleKey === 'string' &&
|
|
116
|
+
faces[0].manifest.card.titleKey !== '' &&
|
|
117
|
+
typeof faces[0]?.manifest.card?.order === 'number',
|
|
118
|
+
JSON.stringify(faces[0]?.manifest.card),
|
|
119
|
+
);
|
|
120
|
+
check(
|
|
121
|
+
'卡片在空数据下也画得出来(不许抛)',
|
|
122
|
+
(() => {
|
|
123
|
+
try {
|
|
124
|
+
return (
|
|
125
|
+
faces[0].card({ world: { current: {} }, refreshToken: 0, onRefresh: () => {}, t: (key) => key }) != null
|
|
126
|
+
);
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
})(),
|
|
131
|
+
);
|
|
75
132
|
|
|
76
133
|
const warnings = [];
|
|
77
134
|
const realWarn = console.warn;
|
|
@@ -90,5 +147,48 @@ check(
|
|
|
90
147
|
warnings.join(' | '),
|
|
91
148
|
);
|
|
92
149
|
|
|
150
|
+
// ── 宿主产物:面注册得对不对 ──────────────────────────────────────────────
|
|
151
|
+
const hostExports = await import(new URL('../dist/host.js', import.meta.url).href);
|
|
152
|
+
check('宿主产物导出了 apply', typeof hostExports.apply === 'function');
|
|
153
|
+
check(
|
|
154
|
+
'宿主产物也注入 workbenchUnits',
|
|
155
|
+
Array.isArray(hostExports.inject) && hostExports.inject.includes('workbenchUnits'),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const hostFaces = [];
|
|
159
|
+
hostExports.apply({
|
|
160
|
+
get: (name) =>
|
|
161
|
+
name === 'workbenchUnits'
|
|
162
|
+
? {
|
|
163
|
+
register(face) {
|
|
164
|
+
hostFaces.push(face);
|
|
165
|
+
return () => {};
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
: undefined,
|
|
169
|
+
effect: (fn) => {
|
|
170
|
+
fn();
|
|
171
|
+
},
|
|
172
|
+
logger: { warn: () => {} },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
check('宿主面注册进了外壳', hostFaces.length === 1, `注册了 ${hostFaces.length} 个`);
|
|
176
|
+
// 给一份最小 deps:宿主面的 `mount(deps)` 通常要 `deps.ctx.get('shell')` 之类,
|
|
177
|
+
// 不给 deps 的话**任何真的用了 `mount(deps)` 的模块**都会在这里崩 —— 而那不是模块的错。
|
|
178
|
+
let mounted = {};
|
|
179
|
+
try {
|
|
180
|
+
mounted = hostFaces[0]?.mount({ ctx: { get: () => undefined }, logger: { warn: () => {} } }) ?? {};
|
|
181
|
+
} catch (error) {
|
|
182
|
+
failures.push(`宿主面的 mount(deps) 抛了:${error.message}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const routes = mounted.routes ?? {};
|
|
186
|
+
const strays = Object.keys(routes).filter((key) => key !== UNIT_ID && !key.startsWith(`${UNIT_ID}/`));
|
|
187
|
+
check(`每条路由都住在自己那一格(${UNIT_ID} 或 ${UNIT_ID}/…)`, strays.length === 0, strays.join(', '));
|
|
188
|
+
check(
|
|
189
|
+
'registerTools() 交得出一份清单(这份模块没有工具时是空数组)',
|
|
190
|
+
Array.isArray(mounted.registerTools?.()),
|
|
191
|
+
);
|
|
192
|
+
|
|
93
193
|
console.log(`\n${failures.length === 0 ? '模块自检通过' : `${failures.length} 条没过`}`);
|
|
94
194
|
if (failures.length > 0) process.exit(1);
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# __NAME__
|
|
2
|
+
|
|
3
|
+
`__UNIT__` 模块:一个独立的 dsh 插件包,只认外壳的服务名,不认识外壳源码。
|
|
4
|
+
|
|
5
|
+
这份是 **DIY 模板**(`--template diy`)生成的骨架:一张**读当前项目目录的只读卡**,
|
|
6
|
+
数据从宿主侧**真读**一个目录算出来(`node:fs`),不是一个写死的样板。
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
dsh-wb-module init <unit> --dir <目录> --template diy # 生成这份骨架(默认模板是没有数据源的空壳)
|
|
10
|
+
dsh-wb-module build # 出 dist/host.js 与套了 loader 壳的 client.js
|
|
11
|
+
dsh-wb-module check # 十条判据:契约 / 产物边界 / 源码边界 / dsh 原语 / kit 面 / 卡片渲染 / 独立构建 / 内部标识 / 样式归属 / 不指外壳
|
|
12
|
+
dsh-wb-module check --only cards # 十条可以单跑
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
包自带的四个脚本(`check` 的契约判据要求它们都在):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm build # 构建产物
|
|
19
|
+
pnpm typecheck # tsc --noEmit
|
|
20
|
+
pnpm test # vitest:路由真读数 + 卡片五态 + 三种夹具
|
|
21
|
+
pnpm selfcheck # 构建 + 把 client.js 当浏览器加载器跑一遍 + 调一次 <unit>/overview 看它真读盘
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 需求底稿(做之前填,做完对着它验收)
|
|
25
|
+
|
|
26
|
+
三张表既是**要问清的需求**,也是**最后的验收单** —— 表里每一格都要能在界面上见过或点过。
|
|
27
|
+
**空着的格就是还没定的事**,别带着空格往下做:落点是同目录的 `design.json`
|
|
28
|
+
(`dsh-wb-module design check` 会把空格一格一格列出来),**下面标了 start/end 的那一段就是由它生成的**
|
|
29
|
+
—— 要改三张表就改 `design.json`,再 `dsh-wb-module design render`;手改那一段会被 next 一次 check 判红。
|
|
30
|
+
所以下次他说"改一下"不用从头问。
|
|
31
|
+
|
|
32
|
+
怎么问、怎么填:指引 `SKILL.md` 的七步 · `DESIGN.md`(判域 / 两条分工 / 三张表)·
|
|
33
|
+
`STEPS.md`(每步问什么、什么时候算做完)· `PANEL.md` · `TOOLS.md` · `LINKAGE.md`。
|
|
34
|
+
|
|
35
|
+
<!-- dsh-wb:design:start -->
|
|
36
|
+
### 表 1 面板:他看什么、他能自己配什么
|
|
37
|
+
|
|
38
|
+
| 这一格 | 一眼能得出什么结论 | 点一下可以去哪 |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| 这个目录占了多少空间 | 该不该清、谁在占 | (本骨架没有外链;要加就把目标写这儿) |
|
|
41
|
+
|
|
42
|
+
| 能自己配的 | 管什么 | 默认是什么 | 可选值从哪来 | 选错了会怎样 |
|
|
43
|
+
|---|---|---|---|---|
|
|
44
|
+
| 列几个最大的文件 | 明细长度 | `5` | `1..50` | 夹回范围内,不报错 |
|
|
45
|
+
|
|
46
|
+
状态矩阵(每种状态:他看到什么 / 他能做什么 / agent 能接着做什么)——
|
|
47
|
+
图号对着下面「卡片画哪五态」那一节:
|
|
48
|
+
|
|
49
|
+
| # | 状态 | 他看到什么 | 他能做什么 | agent 能接着做什么 |
|
|
50
|
+
|---|---|---|---|---|
|
|
51
|
+
| A | 待机(没选项目) | 「先打开一个项目」,不发请求 | 去左栏开一个项目 | 说清"要先有项目" |
|
|
52
|
+
| B | 加载 | 「正在读目录」 | 等 | —— |
|
|
53
|
+
| C | 就绪 | 路径 + N 文件 / M 目录 / 总字节 + 最大的几个 | 刷新 | 读一次这一格(联动见表 3) |
|
|
54
|
+
| D | 就绪但空 | 「这个目录是空的」 | 换一个目录 | 同上 |
|
|
55
|
+
| E | 读不到 | 读不到这个目录 + 宿主的原话 | 重试 / 改路径 | 同上 |
|
|
56
|
+
|
|
57
|
+
### 表 2 agent:他能替用户做什么、哪几件要点头
|
|
58
|
+
|
|
59
|
+
| 能力 | 一句话 | 传什么 / 拿回什么(贴真实调用与返回) | 要点头吗 | 点头时他看到的"会发生什么" |
|
|
60
|
+
|---|---|---|---|---|
|
|
61
|
+
| (这份骨架故意没有能力:`manifest.tools` 是空的。要加就照这张表填,只读的不点头、动东西的要点头) | | | | |
|
|
62
|
+
|
|
63
|
+
### 表 3 联动:agent 动完,这个模块哪一格变
|
|
64
|
+
|
|
65
|
+
| 谁在动 | 面板什么时候变 | 哪一格变(投影 + 最窄那个参数) | 用户看到什么 |
|
|
66
|
+
|---|---|---|---|
|
|
67
|
+
| agent 读一次这一格 | 读完立刻 | 本单元那条只读投影(`__UNIT__/overview`),只带这个目录 | 那一格变成 agent 看到的那份数 |
|
|
68
|
+
| agent 只预览 / 只说了句话 | 不动 | —— | 面板没变 |
|
|
69
|
+
| 用户自己按刷新 | 立刻 | 同上 | 转一下,然后是新值 |
|
|
70
|
+
|
|
71
|
+
### 数据源探明记录
|
|
72
|
+
|
|
73
|
+
`opencli` 只是**侦察兵**:探明之后固化成**一条**读,日常只走这一条
|
|
74
|
+
(这条就是 `src/host.ts` 里那个数据源)。改数据源时先改这张表,再改代码。
|
|
75
|
+
|
|
76
|
+
| 项 | 值(照抄,别转述) |
|
|
77
|
+
|---|---|
|
|
78
|
+
| 哪条读(命令 / URL) | 本机目录本身:`readdirSync(path)` + `statSync`(不走网络、不要凭据) |
|
|
79
|
+
| 参数(缺省 / 范围 / 上限) | `path` 必填(卡片给当前项目);`limit` 默认 `5`,夹在 `1..50` |
|
|
80
|
+
| 凭据从哪来 | 不需要 |
|
|
81
|
+
| 没登录 / 凭据过期时是什么样 | 不适用(本机读盘,没有登录这回事) |
|
|
82
|
+
| 超时 / 连不上时是什么样 | 路径不存在 / 没权限 / 不是目录 → HTTP 200 + `ok:false`,码 `overview-unreadable` |
|
|
83
|
+
| 当初探明用的那次调用(原文) | `node -e "require('node:fs').readdirSync(process.cwd())"` |
|
|
84
|
+
|
|
85
|
+
<!-- dsh-wb:design:end -->
|
|
86
|
+
|
|
87
|
+
## 生成出来的形状
|
|
88
|
+
|
|
89
|
+
| 文件 | 是什么 |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `package.json` | 自述清单:`dsh.workbench.unit` = `__UNIT__`、两个面(`src/host.ts` / `src/client.tsx`)、四个脚本、peer 下限 |
|
|
92
|
+
| `cordis.patch.yml` | 插进 dsh 组合的补丁 |
|
|
93
|
+
| `src/host.ts` | 宿主面:`readOverview()`(真读目录)+ 一条只读路由 `<unit>/overview` |
|
|
94
|
+
| `src/client.tsx` | 卡片:`Card` 壳 + 五态(待机 / 加载 / 就绪 / 出错,其中"就绪但空"单列一态)+ `?wbMock=1` 夹具 |
|
|
95
|
+
| `src/messages.ts` | 卡片那几句文案(zh / en) |
|
|
96
|
+
| `src/plugin.test.ts` | 面注册 + 路由真读 + 五态 + 三种夹具的断言 |
|
|
97
|
+
| `scripts/smoke.mjs` | 接线自检:卡片进没进外壳、路由真读没真读 |
|
|
98
|
+
| `tsconfig.json` / `vitest.config.ts` / `types/globals.d.ts` | 经典 JSX 运行时(`jsxFactory: h`)、vitest 的别名、浏览器半侧的运行时垫片 |
|
|
99
|
+
| `README.md` | 就是本文 |
|
|
100
|
+
|
|
101
|
+
## 数据是在哪算出来的
|
|
102
|
+
|
|
103
|
+
宿主侧,`src/host.ts` 的 `readOverview(path, limit)`:
|
|
104
|
+
|
|
105
|
+
* `readdirSync(path, { withFileTypes: true })` 数出**目录数**;
|
|
106
|
+
* 每个普通文件 `statSync` 一次,累加**总字节**、记下 `name / bytes / mtime`(mtime 是 ISO 串);
|
|
107
|
+
* 按字节降序(同字节按名字)排,取前 `limit` 个当**最大的几个文件**。
|
|
108
|
+
|
|
109
|
+
已知的取舍(不是 bug,是这份骨架刻意保持简单的地方):
|
|
110
|
+
|
|
111
|
+
* **符号链接不计数**:既不算文件也不算目录(想要跟随就自己换成 `statSync` + `lstatSync` 判断);
|
|
112
|
+
* **同步读**:模板优先让代码一眼看得懂;真接大目录时改成 `node:fs/promises` 版本更合适;
|
|
113
|
+
* 只读**直接子项**,不递归。
|
|
114
|
+
|
|
115
|
+
## 那条只读路由
|
|
116
|
+
|
|
117
|
+
键是 `<unit>/overview`(`__UNIT__` 就是本模块的单元 id)——**必须住在自己那一格的命名空间里**,
|
|
118
|
+
外壳装 / 升 / 卸时要能点名到它;写成别的名字注册当场就被判红。
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
GET /api/__UNIT__/overview?path=<绝对路径>&limit=<1..50>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
查询参数:
|
|
125
|
+
|
|
126
|
+
| 参数 | 缺省 | 说明 |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `path` | 无(必填) | 要读的目录。卡片把 `props.world.current.workspace.path` 塞进来;缺了不回数据,直接回 `no-path` |
|
|
129
|
+
| `limit` | `5` | 返回几个"最大的文件",夹在 `1..50` 之间 |
|
|
130
|
+
|
|
131
|
+
成功:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{ "ok": true, "path": "/…", "files": 128, "dirs": 12, "bytes": 3145728,
|
|
135
|
+
"largest": [{ "name": "client.js", "bytes": 182400, "mtime": "2026-01-05T09:12:44.000Z" }] }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
读不到(路径不存在 / 没权限 / 不是目录):**HTTP 200 + `ok:false`**,错误码 `overview-unreadable`。
|
|
139
|
+
压根没给 `path`:同样 `ok:false`,错误码 `no-path` —— 别拿宿主进程的工作目录当默认值:
|
|
140
|
+
那会让"我没说要看哪儿"变成"屏幕上是 dsh 服务启动目录",两个都不像用户想要的东西。
|
|
141
|
+
之所以不让它抛:抛出去会被外壳的传输层转成 HTTP 500,卡片只剩一句"宿主返回 HTTP 500",
|
|
142
|
+
用户看不到是哪条路径出了什么事。
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{ "ok": false, "error": { "code": "overview-unreadable", "message": "ENOENT: no such file or directory, scandir '/…'" } }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
卡片侧读数的 hook 是 `kit.hooks.useApiResource`(`route` 就是 `__UNIT__/overview`)。
|
|
149
|
+
注意它的 `value` 是**整个响应对象**(成功时形如 `{ ok: true, ...payload }`),不是 `payload` 本身。
|
|
150
|
+
|
|
151
|
+
## 这个模块会不会写东西、要不要跑一会儿、靠什么跑起来
|
|
152
|
+
|
|
153
|
+
**这张卡只读** —— 上面那条 `/overview` 就是全部。要动手(提交、发布、批量改)就得再加两样,
|
|
154
|
+
下面三段是**最小可抄的写法**;不需要的整段删掉,别留半截。
|
|
155
|
+
|
|
156
|
+
### 会写:清单里标出来 + 认领审批
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
tools: [{ name: 'workbench___UNIT___rebuild', writes: true }],
|
|
160
|
+
|
|
161
|
+
needsApproval: (call) => ({
|
|
162
|
+
kind: 'ask',
|
|
163
|
+
reason: '会覆盖产物',
|
|
164
|
+
question: { zh: '要重建这 3 个模块吗?', en: 'Rebuild these 3 modules?' },
|
|
165
|
+
fallback: { zh: '会覆盖它们的 dist 与 client.js', en: 'Overwrites their dist and client.js' },
|
|
166
|
+
}),
|
|
167
|
+
|
|
168
|
+
approvalDetail: async (call) => ({ zh: '会覆盖 3 个模块的产物,不碰源码', en: '…' }),
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
清单写着 `writes: true` 而不认领,外壳**当场 deny**(用户看到的是一次莫名其妙的拒绝)——
|
|
172
|
+
`dsh-wb-module check --only contracts` 会替你先把这条看一遍。用户侧看到的问话就是
|
|
173
|
+
`question` + `approvalDetail` 合起来的那几行:**一行一个事实,别写"这个操作会写数据"这种空话。**
|
|
174
|
+
|
|
175
|
+
### 跑一会儿:长任务
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
mount({ longTasks, ... }) {
|
|
179
|
+
const runs = longTasks?.create({
|
|
180
|
+
key: 'rebuild',
|
|
181
|
+
run: async ({ payload, onStep }) => { onStep({ kind: 'start', text: '开始…' }); /* … */ return {}; },
|
|
182
|
+
outcome: (result) => ({ why: '…' }),
|
|
183
|
+
view: (snapshot) => ({ steps: String(snapshot.steps.length) }),
|
|
184
|
+
});
|
|
185
|
+
return { routes: {}, registerTools: () => [], ...(runs === undefined ? {} : { longTasks: runs }) };
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
浏览器侧用 kit 那套画(`kit.hooks.useLongTask` + `<LongTaskRow …/>`):状态只有
|
|
190
|
+
`running` 进行中 / `needs-login` 要登录 / `ok` 完成 / `failed` 出错 —— **契约里没有"停止"**,
|
|
191
|
+
别在界面上摆一个不存在的按钮。
|
|
192
|
+
|
|
193
|
+
### 存起来:他能自己配什么(`prefs`)
|
|
194
|
+
|
|
195
|
+
选一次就想让它一直算数(钉哪个环境、忽略哪些、超时多久)→ 走 `prefs` 通道:**按项目记、刷新还在**。
|
|
196
|
+
只是"这次看哪一种"→ 当 route 参数传,别落盘。两种都写进 `design.json` 的 `config[].persistence`
|
|
197
|
+
(`prefs` / `view`)—— 表里写了 `prefs`,产物里就必须真的接上(`check --only contracts` 会拦)。
|
|
198
|
+
|
|
199
|
+
宿主半侧在 `mount` 里返回(最好单独一个纯文件,像 `src/prefs.ts` 那样能单测):
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
prefs: {
|
|
203
|
+
resource: 'your-module',
|
|
204
|
+
handle: (payload) => applyPref(deps.store.for(UNIT_ID, workspace), payload),
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
浏览器半侧:`useHostWrite({ channel: 'prefs', resource: 'your-module' })` + `Field` / `TextField`,外面套
|
|
209
|
+
`SettingsSection`,保存用 `SaveBar`;存失败留在原地。(输入框内边距与"标签 + 提示"排布是外壳给的默认,
|
|
210
|
+
不用为这个写 CSS。)语义:没写过 ≠ 选空、清空要把文件收掉、不认识的值报错并列出还认的候选。
|
|
211
|
+
|
|
212
|
+
### 靠什么跑起来:`env`
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
env: {
|
|
216
|
+
command: 'opencli',
|
|
217
|
+
libs: [{ name: 'x-lib', registryEnv: 'DSH_WORKBENCH_X_REGISTRY', registryDefault: 'https://…/', purpose: '装它干嘛' }],
|
|
218
|
+
plugins: [{ name: 'x', commands: ['a', 'b'], installSource: { kind: 'npm', package: 'opencli-plugin-x', registryEnv: '…' } }],
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
外壳的隔离运行时按它装、按它探(`workbench_modules list` 里逐条看得到)。**没装好时用户看到的那句话
|
|
223
|
+
由你写**:`libInstallCommand({ need })` 会连源和缓存目录一起拼好,原样给他,别自己拼 npm 命令。
|
|
224
|
+
|
|
225
|
+
## 卡片画哪五态
|
|
226
|
+
|
|
227
|
+
`src/client.tsx` 的 `overviewStateOf({ fixture, phase, value, error })` 是一条纯函数,
|
|
228
|
+
每个分支各对应一屏(`src/plugin.test.ts` 一屏一屏断言):
|
|
229
|
+
|
|
230
|
+
| 态 | 什么时候 | 画什么 |
|
|
231
|
+
|---|---|---|
|
|
232
|
+
| 待机 | 没有选中工作区(`path === ''`),请求压根不发(`useApiResource(…, { enabled: false })`,`phase` 停在 `idle`) | `CenteredState`:先打开一个项目 |
|
|
233
|
+
| 加载 | 还没有值(首读),或 `phase === 'error'` 之外什么都没来 | `CenteredState`:正在读目录 |
|
|
234
|
+
| 就绪 | 有值,且文件数 + 目录数 > 0 | `StateBlock`:路径 + 「N 个文件 · M 个目录 · 总字节」+ 最大的几个文件列表 |
|
|
235
|
+
| 就绪但空 | 有值,但文件数 = 目录数 = 0 | `EmptyState`:这个目录是空的 |
|
|
236
|
+
| 出错 | `phase === 'error'`(或夹具给的是错误) | `CenteredState`:读不到这个目录 + 宿主的原话 |
|
|
237
|
+
|
|
238
|
+
待机那一屏要排在夹具**之后**:`?wbMock=1` 且没开项目时,夹具仍然要演得出来(否则
|
|
239
|
+
"读不到"这种状态在没有项目时就永远看不到,而那正是干净 profile 下的评审场景)。
|
|
240
|
+
|
|
241
|
+
刷新时 `useApiResource` 会把 `phase` 打成 `loading` **同时保留上一次的 `value`** ——
|
|
242
|
+
这时刻意继续画旧数据(右上角的转圈由 `Card` 的 `refreshing` 表示),不退回骨架:
|
|
243
|
+
一次刷新不该让整张卡闪一下。
|
|
244
|
+
|
|
245
|
+
## `?wbMock=1` 夹具
|
|
246
|
+
|
|
247
|
+
打开 `?wbMock=1`(搜索串里含 `wbMock=1`)时卡片顶上多一条 `MockStrip`,
|
|
248
|
+
`kit.hooks.useFixturePick` 提供三个夹具,**只切状态、不发请求**:
|
|
249
|
+
|
|
250
|
+
| id | 标签 | 演的是 |
|
|
251
|
+
|---|---|---|
|
|
252
|
+
| `ready` | 有数据 | 128 个文件 / 12 个目录 / 3 MB,带三个最大的文件 |
|
|
253
|
+
| `empty` | 空目录 | 就绪但那一路"空"态 |
|
|
254
|
+
| `error` | 读不到 | `EACCES: permission denied, scandir /mock/secret` |
|
|
255
|
+
|
|
256
|
+
夹具优先于真请求:只要 mock 开着,画的就是夹具那一屏。
|
|
257
|
+
改夹具时顺手改一眼 `src/plugin.test.ts` 的「夹具」那一组断言 —— 那组就是守它的。
|
|
258
|
+
|
|
259
|
+
## 装进本地工作台
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
dsh plugin --profile workbench add <本目录>
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## 改的时候这些是硬约束
|
|
266
|
+
|
|
267
|
+
* **只用 kit 里真有的成员**:`check:kit` 拿产物实际取的成员去对 peer 下限那份外壳的 kit,
|
|
268
|
+
取了旧外壳没有的成员会在用户机器上首帧渲染就炸(peer 下限因此不许比 devDependency 旧)。
|
|
269
|
+
* **两个运行时不许互引**:宿主侧不许碰 DOM / react,浏览器侧不许碰 `node:` / `process.env` / `Buffer`
|
|
270
|
+
(`check:bundles` 对着产物查)。所以目录读在 `src/host.ts`,卡片只管画。
|
|
271
|
+
* `@/` 只许指自己(`@/modules/__UNIT__/*`);共享代码走包名;`@deepseek-ai/dsh-*` 只在客户端侧用,
|
|
272
|
+
且必须同时写进 `package.json` 的 `dsh.client.external`。
|
|
273
|
+
* **自己的样式走 CSS Modules**(`src/x.module.css` + `import styles from './x.module.css'`):
|
|
274
|
+
类名照常写就行 —— 构建会**按文件路径哈希**(跨模块不会撞),但**别带 `wb-` 前缀**:
|
|
275
|
+
`wb-*` 是外壳的全局类名,模块定义它、或借契约里没有的那一个,`check:style` 都会判红。
|
|
276
|
+
要借外壳契约里的类,写 `:global(.wb-…)`,而且那个类必须在契约里。
|
|
277
|
+
这份骨架没带样式文件:五态全用 kit 的组件,够看。
|
|
278
|
+
* **源码零注释**(`src/**`):要说的都写在这里。
|
|
279
|
+
|
|
280
|
+
要发布:把 `package.json` 里的 `private` 去掉,并显式写上发布目标
|
|
281
|
+
(`publishConfig.access: "public"` 或指向内网源的 `publishConfig.registry`)——
|
|
282
|
+
`private: true` 的包不许发到任何源,这是刻意的默认。
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"unit": "__UNIT__",
|
|
3
|
+
"name": "__NAME__",
|
|
4
|
+
"oneLine": "__DESC__",
|
|
5
|
+
"panel": [
|
|
6
|
+
{ "cell": "这个目录占了多少空间", "conclusion": "该不该清、谁在占", "link": "" }
|
|
7
|
+
],
|
|
8
|
+
"states": [
|
|
9
|
+
{ "key": "idle", "sees": "「先打开一个项目」,不发请求", "canDo": "去左栏开一个项目", "agentCan": "说清「要先有项目」" },
|
|
10
|
+
{ "key": "loading", "sees": "「正在读目录」", "canDo": "等", "agentCan": "——" },
|
|
11
|
+
{ "key": "ready", "sees": "路径 + N 文件 / M 目录 / 总字节 + 最大的几个", "canDo": "刷新", "agentCan": "读一次这一格(联动见表 3)" },
|
|
12
|
+
{ "key": "empty", "sees": "「这个目录是空的」", "canDo": "换一个目录", "agentCan": "同上" },
|
|
13
|
+
{ "key": "unreadable", "sees": "「读不到这个目录」+ 宿主的原话", "canDo": "重试 / 改路径", "agentCan": "同上" }
|
|
14
|
+
],
|
|
15
|
+
"config": [
|
|
16
|
+
{ "persistence": "view", "key": "列几个最大的文件", "watches": "明细长度", "default": "5", "valuesFrom": "1..50", "wrongChoice": "夹回范围内,不报错" }
|
|
17
|
+
],
|
|
18
|
+
"capabilities": [],
|
|
19
|
+
"linkage": [
|
|
20
|
+
{ "who": "agent 读一次这一格", "when": "读完立刻", "whatChanges": "本单元那条只读投影(__UNIT__/overview),只带这个目录", "userSees": "那一格变成 agent 看到的那份数" },
|
|
21
|
+
{ "who": "agent 只预览 / 只说了句话", "when": "不动", "whatChanges": "——", "userSees": "面板没变" },
|
|
22
|
+
{ "who": "用户自己按刷新", "when": "立刻", "whatChanges": "同上", "userSees": "转一下,然后是新值" }
|
|
23
|
+
],
|
|
24
|
+
"datasource": {
|
|
25
|
+
"read": "本机目录本身:readdirSync(path) + statSync(不走网络、不要凭据)",
|
|
26
|
+
"params": "path 必填(卡片给当前项目);limit 默认 5,夹在 1..50",
|
|
27
|
+
"credentials": "不需要",
|
|
28
|
+
"notLoggedIn": "不适用(本机读盘,没有登录这回事)",
|
|
29
|
+
"timeout": "路径不存在 / 没权限 / 不是目录 → HTTP 200 + ok:false,码 overview-unreadable",
|
|
30
|
+
"probeCommand": "node -e \"require('node:fs').readdirSync(process.cwd())\""
|
|
31
|
+
},
|
|
32
|
+
"acceptance": [
|
|
33
|
+
{ "step": "打开面板 → 看到这张卡 → 路径 + N 文件 / M 目录 / 总字节", "checked": false },
|
|
34
|
+
{ "step": "换一个空目录 → 看到「这个目录是空的」(不是空白、也不是 0)", "checked": false },
|
|
35
|
+
{ "step": "把 limit 调成 50 → 明细变长;调成 0 → 夹回 1,不报错", "checked": false },
|
|
36
|
+
{ "step": "让 agent 读一次这一格 → 面板那一格变成同一份数", "checked": false }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "__DESC__",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/host.js",
|
|
9
|
+
"./client": "./client.js"
|
|
10
|
+
},
|
|
11
|
+
"files": ["dist", "client.js", "cordis.patch.yml"],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "dsh-wb-module build",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"selfcheck": "dsh-wb-module build && dsh-wb-module design check && node scripts/smoke.mjs",
|
|
17
|
+
"design": "dsh-wb-module design check",
|
|
18
|
+
"prepack": "dsh-wb-module build"
|
|
19
|
+
},
|
|
20
|
+
"dsh": {
|
|
21
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
22
|
+
"client": {
|
|
23
|
+
"platform": "web",
|
|
24
|
+
"inject": ["@dsh-workbench/core"],
|
|
25
|
+
"external": ["@dsh-workbench/core/client"]
|
|
26
|
+
},
|
|
27
|
+
"workbench": {
|
|
28
|
+
"unit": "__UNIT__",
|
|
29
|
+
"faces": { "host": "src/host.ts", "client": "src/client.tsx" }
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": { "@dsh-workbench/core": "^0.1.0", "react": "*" },
|
|
33
|
+
"peerDependenciesMeta": { "react": { "optional": true } },
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@dsh-workbench/core": "^0.1.0",
|
|
36
|
+
"@dsh-workbench/module-tools": "^0.1.0",
|
|
37
|
+
"@types/node": "^24.19.0",
|
|
38
|
+
"@types/react": "^19.3.0",
|
|
39
|
+
"react": "^19.3.0",
|
|
40
|
+
"typescript": "5.9",
|
|
41
|
+
"vitest": "^5.0.2"
|
|
42
|
+
}
|
|
43
|
+
}
|