dsh-plugin-toolkit 0.4.1 → 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 CHANGED
@@ -1,5 +1,13 @@
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
+
3
11
  ## 0.4.1
4
12
 
5
13
  - `examples/`: four runnable files (`mount-a-bundle`, `route-stubs`, `baseline`, `aria`), each showing one thing testkit does.
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.
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.4.1",
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": {