dsh-host-plugin-kit 0.0.0-stage → 0.1.1
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 +56 -2
- package/dev/bin-selftest.mjs +39 -0
- package/new.mjs +329 -0
- package/package.json +29 -4
package/README.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-host-plugin-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
一条命令生成 **DSH host 侧插件骨架**,自带三层验证(离线单测 / 真 cordis 冒烟 / 发布路径说明)与审批钩子模板。
|
|
4
|
+
|
|
5
|
+
面向 **Android 上的 DSH**(没有 Web Client 层,插件只能贡献 host 侧:工具、服务、事件、SKILL、MCP 桥)。
|
|
6
|
+
|
|
7
|
+
> 名字里的 host 是刻意的:npm 上的 `dsh-host-plugin-kit` 是另一套(客户端 bundle 生成 + verify),
|
|
8
|
+
> 与这套「host 侧工具插件骨架 + 三层测试」不是一回事。
|
|
9
|
+
|
|
10
|
+
## 用法
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
node new.mjs ~/work/my-plugin --name dsh-hello --desc "示例插件" [--prefix hello]
|
|
14
|
+
|
|
15
|
+
cd ~/work/my-plugin
|
|
16
|
+
node dev/test.mjs # 6 项离线单测
|
|
17
|
+
node host-test/rt.mjs # 5 项真 cordis 冒烟(用引擎自带的那份 cordis)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
生成物:
|
|
21
|
+
|
|
22
|
+
| 文件 | 作用 |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `index.mjs` | 注册工具(`ctx.effect` 内)、挂 `tools/pre-execute` 审批钩子 |
|
|
25
|
+
| `lib/core.mjs` | 业务逻辑,纯函数好测 |
|
|
26
|
+
| `cordis.patch.yml` | profile 的 insert 片段 |
|
|
27
|
+
| `package.json` | `dsh.bundle.patch` + 最小 `capabilities` |
|
|
28
|
+
| `dev/test.mjs` | 假 ctx 单测:注册、execute、审批 allow→ask、deny 不改写 |
|
|
29
|
+
| `host-test/rt.mjs` | 真 `@deepseek-ai/cordis`:inject、注册、execute、钩子、`dispose` 清理 |
|
|
30
|
+
| `locale/`、`icon.svg`、`README.md` | 插件三件套 |
|
|
31
|
+
|
|
32
|
+
## 生成的骨架遵守的约定
|
|
33
|
+
|
|
34
|
+
1. `export const name / inject / apply`;
|
|
35
|
+
2. 工具在 `ctx.effect` 里注册并返回 dispose(卸载要干净,有测试);
|
|
36
|
+
3. 写类工具把 `allow` 升级为 `ask`,**上游 `deny` 不许改写**(有测试);
|
|
37
|
+
4. 纯逻辑放 `lib/`,`index.mjs` 只做协议适配。
|
|
38
|
+
|
|
39
|
+
## 在这台手机上发布
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
# npm:新包首发先进 staged 状态,40–120 s 后才可见;发完务必下载 tarball 实际加载验证
|
|
43
|
+
node $HOME/tools/npm/bin/npm-cli.js publish
|
|
44
|
+
|
|
45
|
+
# GitHub:本机没有 git,走 REST API 一次成 commit
|
|
46
|
+
node $HOME/work/gitpub/publish-github.mjs --dir . --owner <you> --repo dsh-hello --token-file $HOME/.gh_token
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
装插件时**带版本号**(`dsh-hello@0.1.0`),否则会遇到「裸名字 spec 永远没有更新徽标」的坑。
|
|
50
|
+
|
|
51
|
+
## 自测
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
node dev/bin-selftest.mjs # 生成一个临时插件并跑它的两套测试
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// 脚手架自测:生成一个临时插件,跑它的两套测试,断言全绿。
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const dir = mkdtempSync(join(tmpdir(), 'kit-selftest-'));
|
|
8
|
+
let pass = 0, fail = 0;
|
|
9
|
+
const t = (label, fn) => {
|
|
10
|
+
try { fn(); pass++; console.log('PASS ' + label); }
|
|
11
|
+
catch (e) { fail++; console.log('FAIL ' + label + '\n ' + e.message); }
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
t('生成骨架', () => {
|
|
15
|
+
const r = spawnSync(process.execPath, [join(import.meta.dirname, '..', 'new.mjs'), dir, '--name', 'dsh-kitdemo'], { encoding: 'utf8' });
|
|
16
|
+
if (r.status !== 0) throw new Error(r.stderr || r.stdout);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
t('生成的 dev/test.mjs 全绿', () => {
|
|
20
|
+
const r = spawnSync(process.execPath, [join(dir, 'dev', 'test.mjs')], { encoding: 'utf8' });
|
|
21
|
+
if (r.status !== 0) throw new Error((r.stdout || '') + (r.stderr || ''));
|
|
22
|
+
if (!/通过 6 项/.test(r.stdout)) throw new Error('输出不含「通过 6 项」:' + r.stdout.slice(-200));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
t('生成的 host-test/rt.mjs 全绿(真 cordis)', () => {
|
|
26
|
+
const r = spawnSync(process.execPath, [join(dir, 'host-test', 'rt.mjs')], { encoding: 'utf8' });
|
|
27
|
+
if (r.status !== 0) throw new Error((r.stdout || '') + (r.stderr || ''));
|
|
28
|
+
if (!/通过 5 项/.test(r.stdout)) throw new Error('输出不含「通过 5 项」:' + r.stdout.slice(-200));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
t('拒绝覆盖已有插件目录', () => {
|
|
32
|
+
const r = spawnSync(process.execPath, [join(import.meta.dirname, '..', 'new.mjs'), dir, '--name', 'dsh-kitdemo'], { encoding: 'utf8' });
|
|
33
|
+
if (r.status === 0) throw new Error('第二次生成应当失败');
|
|
34
|
+
if (!/已经有 package.json/.test(r.stderr || '')) throw new Error('错误信息不对:' + r.stderr);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
console.log(`\n通过 ${pass} 项${fail ? `,失败 ${fail} 项` : ''}`);
|
|
39
|
+
process.exit(fail ? 1 : 0);
|
package/new.mjs
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dsh-plugin-kit —— 一条命令生成 DSH 插件骨架(host 侧:工具 + 审批钩子 + 三层验证)。
|
|
3
|
+
// 用法:node new.mjs <目标目录> --name dsh-hello [--desc "一句话"] [--prefix hello]
|
|
4
|
+
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
5
|
+
import { join, basename } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const argv = process.argv.slice(2);
|
|
8
|
+
const arg = (k, d = null) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
|
|
9
|
+
const positional = argv.filter((a, i) => !a.startsWith('--') && !(argv[i - 1] || '').startsWith('--'));
|
|
10
|
+
const target = positional[0];
|
|
11
|
+
const name = arg('--name') || (target ? basename(target) : null);
|
|
12
|
+
if (!target || !name) {
|
|
13
|
+
console.error('用法: node new.mjs <目标目录> --name dsh-hello [--desc "一句话"] [--prefix hello]');
|
|
14
|
+
process.exit(2);
|
|
15
|
+
}
|
|
16
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) { console.error('包名只能小写字母/数字/. _ -'); process.exit(2); }
|
|
17
|
+
if (existsSync(join(target, 'package.json'))) { console.error(`目标目录已经有 package.json:${target}`); process.exit(2); }
|
|
18
|
+
|
|
19
|
+
const prefix = arg('--prefix') || name.replace(/^dsh[-/]?/, '').replace(/[-.]/g, '_') || 'demo';
|
|
20
|
+
const desc = arg('--desc') || `${name}:一个 DSH 插件`;
|
|
21
|
+
const P = prefix;
|
|
22
|
+
const files = {};
|
|
23
|
+
|
|
24
|
+
files['package.json'] = JSON.stringify({
|
|
25
|
+
name, version: '0.1.0', description: desc, type: 'module', main: 'index.mjs',
|
|
26
|
+
exports: { '.': './index.mjs', './cordis.patch.yml': './cordis.patch.yml', './icon': './icon.svg', './package.json': './package.json' },
|
|
27
|
+
files: ['index.mjs', 'lib/', 'cordis.patch.yml', 'icon.svg', 'locale/', 'README.md'],
|
|
28
|
+
engines: { dsh: '>=0.2.0' },
|
|
29
|
+
license: 'MIT',
|
|
30
|
+
dsh: {
|
|
31
|
+
bundle: { patch: './cordis.patch.yml' },
|
|
32
|
+
capabilities: ['tools:register'],
|
|
33
|
+
labels: [],
|
|
34
|
+
},
|
|
35
|
+
}, null, 2) + '\n';
|
|
36
|
+
|
|
37
|
+
files['lib/core.mjs'] = `// 业务逻辑放这里,index.mjs 只做注册与协议适配(方便单测)。
|
|
38
|
+
export function hello(who = 'world') {
|
|
39
|
+
return \`hello, \${who}!\`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function echo(text) {
|
|
43
|
+
if (!text) throw new Error('text 不能为空');
|
|
44
|
+
return { echoed: String(text), length: String(text).length };
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
files['index.mjs'] = `// ${name} —— 由 dsh-plugin-kit 生成。
|
|
49
|
+
//
|
|
50
|
+
// 约定(三层验证都靠它):
|
|
51
|
+
// 1. 导出 name / inject / apply;
|
|
52
|
+
// 2. 工具在 ctx.effect 里注册,返回 dispose 才能卸载干净;
|
|
53
|
+
// 3. 写类工具挂 tools/pre-execute 钩子,把 allow 升级成 ask(上游 deny 不许改写)。
|
|
54
|
+
import { hello, echo } from './lib/core.mjs';
|
|
55
|
+
|
|
56
|
+
export const name = '${P}';
|
|
57
|
+
export const inject = ['tools'];
|
|
58
|
+
|
|
59
|
+
const READ = 'risk: read (read-only)';
|
|
60
|
+
const WRITE = 'risk: write (changes the phone)';
|
|
61
|
+
|
|
62
|
+
const text = (t) => [{ type: 'text', text: t }];
|
|
63
|
+
const renderer = (_args, value) => (Array.isArray(value?.content) ? value.content : text(JSON.stringify(value, null, 2)));
|
|
64
|
+
|
|
65
|
+
export function apply(ctx, config = {}) {
|
|
66
|
+
const logger = typeof ctx.logger === 'function' ? ctx.logger('${P}') : { info() {}, warn() {} };
|
|
67
|
+
const who = config.who || 'world';
|
|
68
|
+
|
|
69
|
+
const tools = [
|
|
70
|
+
{
|
|
71
|
+
name: '${P}_hello',
|
|
72
|
+
description: \`打个招呼(只读)。\\n[\${READ}]\`,
|
|
73
|
+
parameters: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: { who: { type: 'string', description: '称呼,默认取配置 who' } },
|
|
76
|
+
},
|
|
77
|
+
timeoutMs: 10_000,
|
|
78
|
+
isConcurrencySafe: () => true,
|
|
79
|
+
output: { schema: {}, render: renderer },
|
|
80
|
+
async execute(args) {
|
|
81
|
+
return { content: text(hello(args?.who || who)) };
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: '${P}_write',
|
|
86
|
+
description: \`示例写类工具:回显输入(真实插件里这里才真的写盘)。\\n[\${WRITE}]\`,
|
|
87
|
+
parameters: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: { text: { type: 'string', description: '要写入的文本' } },
|
|
90
|
+
required: ['text'],
|
|
91
|
+
},
|
|
92
|
+
timeoutMs: 30_000,
|
|
93
|
+
isConcurrencySafe: () => false,
|
|
94
|
+
output: { schema: {}, render: renderer },
|
|
95
|
+
async execute(args) {
|
|
96
|
+
const r = echo(args?.text);
|
|
97
|
+
return { content: text(\`写入 \${r.length} 字符:\${r.echoed}\`) };
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const disposeFns = [];
|
|
103
|
+
ctx.effect(() => {
|
|
104
|
+
for (const def of tools) {
|
|
105
|
+
if (ctx.tools.get?.(def.name) !== undefined) { logger.warn?.(\`\${def.name} 已存在,跳过\`); continue; }
|
|
106
|
+
try { disposeFns.push(ctx.tools.register(def)); }
|
|
107
|
+
catch (error) { logger.warn?.(\`注册 \${def.name} 失败:\${error?.message ?? error}\`); }
|
|
108
|
+
}
|
|
109
|
+
logger.info?.(\`${P}: 注册 \${disposeFns.length} 个工具\`);
|
|
110
|
+
return () => { for (const dispose of disposeFns.splice(0)) { try { dispose(); } catch {} } };
|
|
111
|
+
}, '${P}.tools');
|
|
112
|
+
|
|
113
|
+
const WRITE_TOOLS = new Set(['${P}_write']);
|
|
114
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
115
|
+
const decision = await next();
|
|
116
|
+
if (!WRITE_TOOLS.has(exec.name) || decision?.kind !== 'allow') return decision;
|
|
117
|
+
let argsText = '';
|
|
118
|
+
try { argsText = JSON.stringify(exec.arguments ?? {}); } catch {}
|
|
119
|
+
if (argsText.length > 200) argsText = argsText.slice(0, 200) + '…';
|
|
120
|
+
return { kind: 'ask', reason: \`${P} 的写类工具 \${exec.name} 要改东西:\${argsText}\` };
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
files['cordis.patch.yml'] = `# 把本插件插进 profile 的层栈。安装后重启引擎生效。
|
|
126
|
+
- insert:
|
|
127
|
+
- id: ${P}
|
|
128
|
+
name: ${name}
|
|
129
|
+
config:
|
|
130
|
+
who: ''
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
files['icon.svg'] = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
134
|
+
<rect width="64" height="64" rx="14" fill="#2f6f4f"/>
|
|
135
|
+
<circle cx="32" cy="32" r="13" fill="none" stroke="#fff" stroke-width="5"/>
|
|
136
|
+
<circle cx="32" cy="32" r="4" fill="#fff"/>
|
|
137
|
+
</svg>
|
|
138
|
+
`;
|
|
139
|
+
|
|
140
|
+
files['locale/zh-CN.json'] = JSON.stringify({
|
|
141
|
+
[`${P}.name`]: desc,
|
|
142
|
+
[`${P}.hello`]: '打个招呼',
|
|
143
|
+
[`${P}.write`]: '示例写类工具',
|
|
144
|
+
}, null, 2) + '\n';
|
|
145
|
+
|
|
146
|
+
files['dev/test.mjs'] = `// 离线单测:不碰网络、不碰真引擎。\nimport assert from 'node:assert/strict';
|
|
147
|
+
import * as plugin from '../index.mjs';
|
|
148
|
+
import { hello, echo } from '../lib/core.mjs';
|
|
149
|
+
|
|
150
|
+
let pass = 0; const fails = [];
|
|
151
|
+
const t = async (label, fn) => {
|
|
152
|
+
try { await fn(); pass++; console.log(' ok ' + label); }
|
|
153
|
+
catch (e) { fails.push(label); console.log(' FAIL ' + label + ' — ' + e.message); }
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const makeCtx = () => {
|
|
157
|
+
const registered = new Map();
|
|
158
|
+
const hooks = [];
|
|
159
|
+
const ctx = {
|
|
160
|
+
logger: () => ({ info() {}, warn() {} }),
|
|
161
|
+
effect: (fn) => { const d = fn(); return d || (() => {}); },
|
|
162
|
+
on: (ev, fn) => { hooks.push([ev, fn]); },
|
|
163
|
+
tools: { register: (d) => { registered.set(d.name, d); return () => registered.delete(d.name); }, get: (n) => registered.get(n) },
|
|
164
|
+
};
|
|
165
|
+
plugin.apply(ctx, {});
|
|
166
|
+
return { ctx, registered, hooks };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
await t('导出 name / inject / apply', () => {
|
|
170
|
+
assert.equal(plugin.name, '${P}');
|
|
171
|
+
assert.deepEqual(plugin.inject, ['tools']);
|
|
172
|
+
assert.equal(typeof plugin.apply, 'function');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await t('纯函数可测(hello / echo)', () => {
|
|
176
|
+
assert.equal(hello('kit'), 'hello, kit!');
|
|
177
|
+
assert.deepEqual(echo('ab'), { echoed: 'ab', length: 2 });
|
|
178
|
+
assert.throws(() => echo(''), /不能为空/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await t('注册 2 个工具', () => {
|
|
182
|
+
const { registered } = makeCtx();
|
|
183
|
+
assert.deepEqual([...registered.keys()].sort(), ['${P}_hello', '${P}_write']);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await t('hello 工具真能执行', async () => {
|
|
187
|
+
const { registered } = makeCtx();
|
|
188
|
+
const res = await registered.get('${P}_hello').execute({ who: 'world' });
|
|
189
|
+
assert.match(res.content[0].text, /hello, world!/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await t('write 工具真能执行(含校验)', async () => {
|
|
193
|
+
const { registered } = makeCtx();
|
|
194
|
+
const res = await registered.get('${P}_write').execute({ text: 'abc' });
|
|
195
|
+
assert.match(res.content[0].text, /写入 3 字符/);
|
|
196
|
+
await assert.rejects(() => registered.get('${P}_write').execute({}), /不能为空/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await t('写类工具走审批:allow → ask,deny 不被动', async () => {
|
|
200
|
+
const { hooks } = makeCtx();
|
|
201
|
+
const [, fn] = hooks.find(([ev]) => ev === 'tools/pre-execute');
|
|
202
|
+
assert.ok(fn, '没有挂 pre-execute');
|
|
203
|
+
assert.equal((await fn({ name: '${P}_write', arguments: { text: 'x' } }, async () => ({ kind: 'allow' }))).kind, 'ask');
|
|
204
|
+
assert.equal((await fn({ name: '${P}_hello', arguments: {} }, async () => ({ kind: 'allow' }))).kind, 'allow');
|
|
205
|
+
assert.equal((await fn({ name: '${P}_write', arguments: {} }, async () => ({ kind: 'deny' }))).kind, 'deny');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
console.log(\`\\n通过 \${pass} 项\${fails.length ? \`,失败 \${fails.length} 项:\${fails.join(' / ')}\` : ''}\`);
|
|
209
|
+
process.exit(fails.length ? 1 : 0);
|
|
210
|
+
`;
|
|
211
|
+
|
|
212
|
+
files['host-test/rt.mjs'] = `// 真 cordis 冒烟:验证 inject 满足、注册、execute、审批钩子、dispose 清理。
|
|
213
|
+
// cordis 从引擎自带的 host-test 目录解析(可用 DSH_HOST_TEST_DIR 覆盖)。
|
|
214
|
+
import { createRequire } from 'node:module';
|
|
215
|
+
import { pathToFileURL } from 'node:url';
|
|
216
|
+
import assert from 'node:assert/strict';
|
|
217
|
+
import * as plugin from '../index.mjs';
|
|
218
|
+
|
|
219
|
+
const base = process.env.DSH_HOST_TEST_DIR || \`\${process.env.HOME}/work/host-test\`;
|
|
220
|
+
const require = createRequire(base + '/package.json');
|
|
221
|
+
const { Context } = await import(pathToFileURL(require.resolve('@deepseek-ai/cordis')).href);
|
|
222
|
+
|
|
223
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
224
|
+
let pass = 0, fail = 0;
|
|
225
|
+
const t = async (label, fn) => {
|
|
226
|
+
try { await fn(); pass++; console.log('PASS ' + label); }
|
|
227
|
+
catch (e) { fail++; console.log('FAIL ' + label + '\\n ' + e.message); }
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const ctx = new Context();
|
|
231
|
+
const registered = new Map();
|
|
232
|
+
ctx.provide('tools', {
|
|
233
|
+
register(def) { registered.set(def.name, def); return () => registered.delete(def.name); },
|
|
234
|
+
get(n) { return registered.get(n); },
|
|
235
|
+
});
|
|
236
|
+
const hooks = [];
|
|
237
|
+
const origOn = ctx.on.bind(ctx);
|
|
238
|
+
ctx.on = (ev, fn, prepend) => { hooks.push([ev, fn]); return origOn(ev, fn, prepend); };
|
|
239
|
+
|
|
240
|
+
let fiber;
|
|
241
|
+
await t('真 cordis 加载插件', async () => {
|
|
242
|
+
fiber = ctx.plugin(plugin, {});
|
|
243
|
+
await sleep(120);
|
|
244
|
+
assert.ok(fiber);
|
|
245
|
+
assert.equal(plugin.inject[0], 'tools');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await t('2 个工具注册进 ctx.tools', () => {
|
|
249
|
+
assert.deepEqual([...registered.keys()].sort(), ['${P}_hello', '${P}_write']);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await t('hello 经 cordis 调 execute', async () => {
|
|
253
|
+
const res = await registered.get('${P}_hello').execute({});
|
|
254
|
+
assert.match(res.content[0].text, /hello,/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await t('审批钩子生效', async () => {
|
|
258
|
+
const [, fn] = hooks.find(([ev]) => ev === 'tools/pre-execute');
|
|
259
|
+
assert.equal((await fn({ name: '${P}_write', arguments: { text: 'y' } }, async () => ({ kind: 'allow' }))).kind, 'ask');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await t('dispose 后工具被摘掉', async () => {
|
|
263
|
+
await fiber.dispose();
|
|
264
|
+
await sleep(60);
|
|
265
|
+
assert.equal(registered.size, 0);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
console.log(\`\\n\${fail ? '❌' : '✅'} 通过 \${pass} 项\${fail ? \`, 失败 \${fail} 项\` : ''}\`);
|
|
269
|
+
process.exit(fail ? 1 : 0);
|
|
270
|
+
`;
|
|
271
|
+
|
|
272
|
+
files['README.md'] = `# ${name}
|
|
273
|
+
|
|
274
|
+
${desc}
|
|
275
|
+
|
|
276
|
+
## 工具
|
|
277
|
+
|
|
278
|
+
| 工具 | 风险 | 作用 |
|
|
279
|
+
|---|---|---|
|
|
280
|
+
| \`${P}_hello\` | 只读 | 打个招呼 |
|
|
281
|
+
| \`${P}_write\` | 写盘(走审批) | 示例写类工具 |
|
|
282
|
+
|
|
283
|
+
## 开发流程(在手机上也能跑)
|
|
284
|
+
|
|
285
|
+
\`\`\`sh
|
|
286
|
+
node dev/test.mjs # 离线单测(不碰网络)
|
|
287
|
+
node host-test/rt.mjs # 真 cordis 冒烟(用引擎自带的那份 cordis)
|
|
288
|
+
\`\`\`
|
|
289
|
+
|
|
290
|
+
发布:
|
|
291
|
+
|
|
292
|
+
\`\`\`sh
|
|
293
|
+
# npm(新包首发会先进 staged 状态,约 40–120 s 后可见;发完务必下载 tarball 实际加载验证)
|
|
294
|
+
node \$HOME/tools/npm/bin/npm-cli.js publish
|
|
295
|
+
# GitHub(本机没有 git,走 REST API)
|
|
296
|
+
node \$HOME/work/gitpub/publish-github.mjs --dir . --owner <you> --repo ${name} --token-file \$HOME/.gh_token
|
|
297
|
+
\`\`\`
|
|
298
|
+
|
|
299
|
+
## 配置
|
|
300
|
+
|
|
301
|
+
\`\`\`yaml
|
|
302
|
+
- insert:
|
|
303
|
+
- id: ${P}
|
|
304
|
+
name: ${name}
|
|
305
|
+
config:
|
|
306
|
+
who: ''
|
|
307
|
+
\`\`\`
|
|
308
|
+
|
|
309
|
+
## 契约
|
|
310
|
+
|
|
311
|
+
- \`inject: ['tools']\`;工具在 \`ctx.effect\` 里注册并返回 dispose;
|
|
312
|
+
- 写类工具在 \`tools/pre-execute\` 里把 \`allow\` 升级成 \`ask\`,**不改写上游 deny**;
|
|
313
|
+
- 包内 \`dsh.capabilities\` 按实际用到的最小集合声明。
|
|
314
|
+
|
|
315
|
+
MIT
|
|
316
|
+
`;
|
|
317
|
+
|
|
318
|
+
files['.gitignore'] = `node_modules/\n*.tgz\n`;
|
|
319
|
+
|
|
320
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
321
|
+
const abs = join(target, rel);
|
|
322
|
+
mkdirSync(join(abs, '..'), { recursive: true });
|
|
323
|
+
writeFileSync(abs, content);
|
|
324
|
+
}
|
|
325
|
+
console.log(`已生成 ${Object.keys(files).length} 个文件 → ${target}`);
|
|
326
|
+
console.log(`\n下一步:`);
|
|
327
|
+
console.log(` cd ${target}`);
|
|
328
|
+
console.log(` node dev/test.mjs # 离线单测`);
|
|
329
|
+
console.log(` node host-test/rt.mjs # 真 cordis 冒烟`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-host-plugin-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "DSH host 侧插件脚手架:一条命令生成带三层验证(离线单测 / 真 cordis 冒烟 / 发布脚本)的插件骨架,含审批钩子与 dispose 清理模板",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "new.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"dsh-plugin-kit": "new.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"new.mjs",
|
|
12
|
+
"dev/bin-selftest.mjs",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"keywords": [
|
|
20
|
+
"dsh",
|
|
21
|
+
"dsh-plugin",
|
|
22
|
+
"scaffold",
|
|
23
|
+
"cordis"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/Verchiel666/dsh-plugin-kit.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/Verchiel666/dsh-plugin-kit#readme",
|
|
30
|
+
"author": "Verchiel666"
|
|
31
|
+
}
|