dsh-plugin-toolkit 0.4.0 → 0.5.0
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/CHANGELOG.md +14 -0
- package/README.md +40 -0
- package/lib/cli.mjs +25 -0
- package/lib/init.mjs +171 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
`dsh-plugin init` — scaffolding that already passes the checks people hit after their first publish.
|
|
6
|
+
|
|
7
|
+
- Writes eight files: `package.json` (with `engines.dsh`, the patch file and a `files` list that actually ships the code), `cordis.patch.yml`, `index.js`, `client.js` (a sidebar tab), a testkit test for that tab, a CI workflow that runs both, `README.md` and `.gitignore`.
|
|
8
|
+
- Refuses a directory that is not empty unless `--force`, and refuses a name npm would refuse — the two mistakes that cost the most time later.
|
|
9
|
+
- The template is embedded, so `init` works offline.
|
|
10
|
+
|
|
11
|
+
## 0.4.1
|
|
12
|
+
|
|
13
|
+
- `examples/`: four runnable files (`mount-a-bundle`, `route-stubs`, `baseline`, `aria`), each showing one thing testkit does.
|
|
14
|
+
- `test/examples.test.mjs` runs every example and asserts it prints exactly what the README shows, and that the README names each one. An example that does not run teaches a shape that no longer exists.
|
|
15
|
+
- `fixtures/fetching-bundle/`: a bundle that calls its host on mount, so the route-stub example asserts something real instead of printing zeros.
|
|
16
|
+
|
|
3
17
|
## 0.4.0
|
|
4
18
|
|
|
5
19
|
`release` can now finish the job instead of printing the next command.
|
package/README.md
CHANGED
|
@@ -25,6 +25,25 @@ Releasing one plugin fifteen times taught me three things the ecosystem's toolin
|
|
|
25
25
|
3. **Reading a manifest is not the same as checking it.** A plugin can have a valid `package.json` and still be uninstallable: `files` that omit the entry the manifest points at, a bundle patch that does not exist, a README that never states the host range it declares.
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
## Start from a plugin that already passes
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
$ npx dsh-plugin-toolkit@0.5.0 init ./my-panel --name my-panel --title "My panel"
|
|
32
|
+
created 8 file(s) in /Users/you/my-panel
|
|
33
|
+
.github/workflows/ci.yml
|
|
34
|
+
.gitignore
|
|
35
|
+
README.md
|
|
36
|
+
client.js
|
|
37
|
+
cordis.patch.yml
|
|
38
|
+
index.js
|
|
39
|
+
package.json
|
|
40
|
+
test/panel.test.mjs
|
|
41
|
+
|
|
42
|
+
next: npm install && npm test && npm run doctor
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
What you get is not a snippet: it is a plugin whose `doctor` already passes (`engines.dsh`, the patch file, the `files` list), with a client bundle that registers a sidebar tab, a testkit test for it, and a CI workflow that runs both. `init` refuses a directory that is not empty unless you pass `--force`, and refuses a name npm would refuse — those are the two mistakes that cost the most time later.
|
|
46
|
+
|
|
28
47
|
## Test a panel without a browser
|
|
29
48
|
|
|
30
49
|
A plugin's client code is one file that calls `window.__ModuleLoader__.load({factory})` — there is no module to import, so the usual React test setup does not apply. `testkit` supplies the missing half: a DOM, the real React, and a host context that records what the panel registers.
|
|
@@ -78,6 +97,27 @@ assert.match(panel.must('[role="status"]').textContent, /Score moved 71 → 77/)
|
|
|
78
97
|
|
|
79
98
|
`must()` throws with the rendered markup when the selector misses, which is the difference between "the assertion failed" and "here is what the user actually got".
|
|
80
99
|
|
|
100
|
+
Every example below is a file in this repository, run by CI, and the output shown is the output it prints:
|
|
101
|
+
|
|
102
|
+
| Example | What it shows |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| `node examples/mount-a-bundle.mjs` | mount a real bundle, read the text, click, read it again |
|
|
105
|
+
| `node examples/route-stubs.mjs` | answer the routes a panel asks for, and assert on the requests |
|
|
106
|
+
| `node examples/baseline.mjs` | a DOM baseline compared on every run |
|
|
107
|
+
| `node examples/aria.mjs` | what assistive tech sees |
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
$ node examples/mount-a-bundle.mjs
|
|
111
|
+
Demo panel Count 0 0
|
|
112
|
+
Demo panel Count 1 1
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
$ node examples/route-stubs.mjs
|
|
117
|
+
Score 71
|
|
118
|
+
asked for: 1 route(s), undeclared route answers 404
|
|
119
|
+
```
|
|
120
|
+
|
|
81
121
|
To look at a bundle by hand:
|
|
82
122
|
|
|
83
123
|
```sh
|
package/lib/cli.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
8
|
import { doctor } from './doctor.mjs';
|
|
9
|
+
import { init } from './init.mjs';
|
|
9
10
|
import { plan, release } from './release.mjs';
|
|
10
11
|
import { auditVersions, readIndex } from './index-file.mjs';
|
|
11
12
|
|
|
@@ -16,6 +17,7 @@ export const HELP = `dsh-plugin — preflight and release DeepSeek Harness plugi
|
|
|
16
17
|
dsh-plugin index [dir] audit every version in market/index.json
|
|
17
18
|
dsh-plugin mount <client.js> render a client bundle in Node and print what a user would see
|
|
18
19
|
dsh-plugin dev [dir] [flags] copy a rebuilt bundle into the installed plugin and confirm the reload
|
|
20
|
+
dsh-plugin init <dir> --name <pkg> scaffold a plugin that already passes doctor
|
|
19
21
|
|
|
20
22
|
Flags
|
|
21
23
|
--dry-run show the steps without touching anything
|
|
@@ -25,6 +27,9 @@ Flags
|
|
|
25
27
|
--pr commit the index on a branch, push it, open the pull request
|
|
26
28
|
--merge with --pr: wait for checks, then squash-merge
|
|
27
29
|
--base <branch> base branch for the pull request, default main
|
|
30
|
+
--name <package-name> with init: the npm name, required
|
|
31
|
+
--title <text> with init: the tab title, default "My panel"
|
|
32
|
+
--force with init: write into a directory that is not empty
|
|
28
33
|
`;
|
|
29
34
|
|
|
30
35
|
function flags(argv) {
|
|
@@ -38,6 +43,9 @@ function flags(argv) {
|
|
|
38
43
|
else if (token === '--slot') parsed.slot = argv[++index];
|
|
39
44
|
else if (token === '--surface') parsed.surface = argv[++index];
|
|
40
45
|
else if (token === '--html') parsed.html = true;
|
|
46
|
+
else if (token === '--name') parsed.name = argv[++index];
|
|
47
|
+
else if (token === '--title') parsed.title = argv[++index];
|
|
48
|
+
else if (token === '--force') parsed.force = true;
|
|
41
49
|
else if (token === '--dest') parsed.dest = argv[++index];
|
|
42
50
|
else if (token === '--entry') parsed.entry = argv[++index];
|
|
43
51
|
else if (token === '--build') parsed.build = argv[++index];
|
|
@@ -81,6 +89,23 @@ export async function run(argv, io = console) {
|
|
|
81
89
|
return 1;
|
|
82
90
|
}
|
|
83
91
|
|
|
92
|
+
if (command === 'init') {
|
|
93
|
+
if (options.name === undefined) {
|
|
94
|
+
io.error('dsh-plugin init: --name <package-name> is required');
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
const result = init({ dir, name: options.name, title: options.title, force: options.force === true });
|
|
98
|
+
if (result.ok !== true) {
|
|
99
|
+
io.error(`dsh-plugin init: ${result.failures[0].message}`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
io.log(`created ${result.files.length} file(s) in ${result.dir}`);
|
|
103
|
+
for (const file of result.files) io.log(` ${file}`);
|
|
104
|
+
io.log('');
|
|
105
|
+
io.log('next: npm install && npm test && npm run doctor');
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
84
109
|
if (command === 'index') {
|
|
85
110
|
const target = options.index ?? (dir.endsWith('.json') === true ? dir : path.join(dir, 'market/index.json'));
|
|
86
111
|
const index = readIndex(target);
|
package/lib/init.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffolding, so the first ten minutes are not spent discovering that the
|
|
3
|
+
* market index rejects a plugin without `engines.dsh`.
|
|
4
|
+
*
|
|
5
|
+
* The template is embedded, not fetched: `init` has to work on a plane.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
11
|
+
|
|
12
|
+
export function checkName(name) {
|
|
13
|
+
if (typeof name !== 'string' || NPM_NAME.test(name) === false) {
|
|
14
|
+
return 'name must be a valid npm package name, e.g. my-plugin or @me/my-plugin';
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function manifest({ name, title, version }) {
|
|
20
|
+
return `${JSON.stringify(
|
|
21
|
+
{
|
|
22
|
+
name,
|
|
23
|
+
version,
|
|
24
|
+
description: `${title} — a panel for DeepSeek Harness`,
|
|
25
|
+
license: 'MIT',
|
|
26
|
+
type: 'module',
|
|
27
|
+
engines: { node: '>=18', dsh: '>=0.2.0' },
|
|
28
|
+
main: 'index.js',
|
|
29
|
+
files: ['index.js', 'client.js', 'cordis.patch.yml', 'README.md'],
|
|
30
|
+
scripts: { test: 'node --test', doctor: 'dsh-plugin doctor .' },
|
|
31
|
+
devDependencies: {
|
|
32
|
+
'dsh-plugin-toolkit': '^0.5.0',
|
|
33
|
+
jsdom: '^26.1.0',
|
|
34
|
+
react: '^18.3.1',
|
|
35
|
+
'react-dom': '^18.3.1',
|
|
36
|
+
},
|
|
37
|
+
dsh: {
|
|
38
|
+
bundle: { patch: './cordis.patch.yml' },
|
|
39
|
+
client: {
|
|
40
|
+
platform: 'web',
|
|
41
|
+
immediately: true,
|
|
42
|
+
inject: ['@deepseek-ai/dsh-client-ui-conversation'],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
null,
|
|
47
|
+
2,
|
|
48
|
+
)}\n`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function client({ name, title }) {
|
|
52
|
+
return `window.__ModuleLoader__.load({
|
|
53
|
+
id: '${name}',
|
|
54
|
+
factory: (require) => {
|
|
55
|
+
const React = require('react');
|
|
56
|
+
const { useState } = React;
|
|
57
|
+
const h = React.createElement;
|
|
58
|
+
const NS = '${name}';
|
|
59
|
+
const KIND = '${name}';
|
|
60
|
+
|
|
61
|
+
const COPY = {
|
|
62
|
+
en: { tabTitle: '${title}', title: '${title}', body: 'This panel came from dsh-plugin init.', clicks: 'Clicks' },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function Panel() {
|
|
66
|
+
const [clicks, setClicks] = useState(0);
|
|
67
|
+
return h(
|
|
68
|
+
'div',
|
|
69
|
+
{ className: 'panel' },
|
|
70
|
+
h('h1', { className: 'panel-title' }, COPY.en.title),
|
|
71
|
+
h('p', { className: 'panel-body' }, COPY.en.body),
|
|
72
|
+
h(
|
|
73
|
+
'button',
|
|
74
|
+
{ type: 'button', className: 'panel-button', onClick: () => setClicks((value) => value + 1) },
|
|
75
|
+
\`\${COPY.en.clicks}: \${clicks}\`,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
apply(ctx) {
|
|
82
|
+
ctx.locale.register(NS, COPY);
|
|
83
|
+
const dispose = ctx.inject(['sidebarRightTabs', 'sidebarRight'], (tabCtx) => {
|
|
84
|
+
if (tabCtx.slots === undefined) return;
|
|
85
|
+
const tr = tabCtx.locale.bind(NS);
|
|
86
|
+
const offType = tabCtx.sidebarRightTabs.register({
|
|
87
|
+
id: NS,
|
|
88
|
+
kind: KIND,
|
|
89
|
+
title: () => tr('tabTitle'),
|
|
90
|
+
guide: [{ id: 'panel', order: 40, title: () => tr('title'), description: () => tr('body') }],
|
|
91
|
+
});
|
|
92
|
+
const offBody = tabCtx.slots.register({ name: 'sidebar.right.pane.tab', key: NS, locale: NS }, Panel);
|
|
93
|
+
return () => {
|
|
94
|
+
offBody?.();
|
|
95
|
+
offType?.();
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
return () => dispose?.();
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function test({ name, title }) {
|
|
107
|
+
return `import assert from 'node:assert/strict';
|
|
108
|
+
import path from 'node:path';
|
|
109
|
+
import test from 'node:test';
|
|
110
|
+
import { fileURLToPath } from 'node:url';
|
|
111
|
+
|
|
112
|
+
import { mountClient } from 'dsh-plugin-toolkit/testkit';
|
|
113
|
+
|
|
114
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
115
|
+
|
|
116
|
+
test('the panel renders and its button counts', async () => {
|
|
117
|
+
const panel = await mountClient({
|
|
118
|
+
file: path.join(here, '..', 'client.js'),
|
|
119
|
+
copy: { '${name}': { tabTitle: '${title}', title: '${title}', body: 'This panel came from dsh-plugin init.', clicks: 'Clicks' } },
|
|
120
|
+
});
|
|
121
|
+
assert.match(panel.text(), /${title}/);
|
|
122
|
+
await panel.click('button');
|
|
123
|
+
assert.match(panel.text(), /Clicks: 1/);
|
|
124
|
+
await panel.unmount();
|
|
125
|
+
});
|
|
126
|
+
`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function files({ name, title = 'My panel', version = '0.1.0' }) {
|
|
130
|
+
const slug = name.includes('/') ? name.split('/')[1] : name;
|
|
131
|
+
return {
|
|
132
|
+
'package.json': manifest({ name, title, version }),
|
|
133
|
+
'cordis.patch.yml': `- insert:\n - id: ${name}\n name: '${name}'\n config: {}\n`,
|
|
134
|
+
'index.js': `export const name = '${name}';\n\n// The host half runs in Node. Most panels need nothing here.\nexport function apply() {}\n`,
|
|
135
|
+
'client.js': client({ name, title }),
|
|
136
|
+
'test/panel.test.mjs': test({ name, title }),
|
|
137
|
+
'README.md': `# ${slug}\n\n${title} — a panel for DeepSeek Harness.\n\n## Requirements\n\n| What | Version | Why |\n| --- | --- | --- |\n| DeepSeek Harness | >= 0.2.0 | provides \`sidebarRightTabs\` |\n| Node | >= 18 | the host half |\n\n## Develop\n\n\`\`\`sh\nnpm install\nnpm test # mounts client.js in Node\nnpm run doctor # checks the manifest before you publish\n\`\`\`\n\n## Install\n\n\`\`\`sh\ndsh plugin add ${name}\n\`\`\`\n`,
|
|
138
|
+
'.gitignore': 'node_modules/\n',
|
|
139
|
+
'.github/workflows/ci.yml': `name: ci\n\non:\n push:\n branches: [main]\n pull_request:\n\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n node: [18, 20, 22]\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: \${{ matrix.node }}\n - run: npm install\n - run: npm test\n - run: npx --yes dsh-plugin-toolkit@0.5.0 doctor .\n`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function init({ dir, name, title, force = false }) {
|
|
144
|
+
const problem = checkName(name);
|
|
145
|
+
if (problem !== null) return { ok: false, failures: [{ check: 'name', message: problem }] };
|
|
146
|
+
|
|
147
|
+
const target = path.resolve(dir);
|
|
148
|
+
if (fs.existsSync(target) === true) {
|
|
149
|
+
const entries = fs.readdirSync(target).filter((entry) => entry !== '.git');
|
|
150
|
+
if (entries.length > 0 && force !== true) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
failures: [
|
|
154
|
+
{
|
|
155
|
+
check: 'dir',
|
|
156
|
+
message: `${target} is not empty (${entries.slice(0, 3).join(', ')}); pass --force to write into it anyway`,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const written = [];
|
|
164
|
+
for (const [relative, content] of Object.entries(files({ name, title }))) {
|
|
165
|
+
const file = path.join(target, relative);
|
|
166
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
167
|
+
fs.writeFileSync(file, content);
|
|
168
|
+
written.push(relative);
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, dir: target, files: written.sort() };
|
|
171
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Preflight, release and test DeepSeek Harness plugins: checks what the host needs, waits for npm to actually serve the version, and keeps the market index honest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|