dsh-plugin-toolkit 0.7.3 → 0.8.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/CHANGELOG.md CHANGED
@@ -1,3 +1,31 @@
1
+ ## 0.8.1
2
+
3
+ `0.8.0` installed and then did nothing: the loader starts a plugin by importing
4
+ the bare package name, and `exports["."]` still pointed at the CLI, so the row
5
+ had no `apply` to run.
6
+
7
+ - `exports["."]` is `./index.js`; the CLI keeps `./cli` and `bin`, the testkit
8
+ keeps `./testkit`.
9
+ - `test/entry.test.mjs` guards the shape: the root export is the plugin, the
10
+ patch file names the package once as `- insert:`, and every path
11
+ `package.json` points at is in `files`.
12
+
13
+ ## 0.8.0
14
+
15
+ The toolkit can now be installed as a plugin, because a tool that cannot be
16
+ installed the way its users install things is asking them to take its word.
17
+
18
+ - A "Plugin doctor" tab in the right sidebar: name the folder that holds a
19
+ plugin's `package.json`, and it runs the same checks the market runs
20
+ (`doctor` plus the pack list) and says which one would fail.
21
+ - `index.js` registers `GET /toolkit/check?dir=`, read-only, same-origin only,
22
+ and answers with `{ok, dir, checks:[{check, ok, message}]}`.
23
+ - `cordis.patch.yml` and `dsh.bundle` in `package.json`, so `dsh plugin add
24
+ dsh-plugin-toolkit` installs the panel; the CLI keeps working from the same
25
+ package.
26
+ - The panel is tested the way a user meets it: `mountClient` on the real
27
+ `client.js`, with a stub answering the route the host half registers.
28
+
1
29
  ## 0.7.3
2
30
 
3
31
  The scaffold was two minor versions behind the tool that wrote it.
package/README.md CHANGED
@@ -18,6 +18,15 @@ npx dsh-plugin-toolkit@0.7.0 release --pr --merge # tag, wait for n
18
18
  | changing a panel that is already installed | [`dev`](#2-develop-a-panel) | `mount`, the testkit |
19
19
  | shipping a version | [`verify`](#3-ship-a-version) | `release`, `index` |
20
20
 
21
+ It also installs as a plugin. `dsh plugin add dsh-plugin-toolkit` adds a
22
+ **Plugin doctor** tab to the right sidebar: name the folder that holds a
23
+ plugin's `package.json`, and it runs the same checks `verify` runs and lists
24
+ what would fail — the same answers as the CLI, without leaving the app.
25
+
26
+ ```
27
+ $ dsh plugin add dsh-plugin-toolkit
28
+ ```
29
+
21
30
  ### 1. Write a plugin
22
31
 
23
32
  [`init`](#start-from-a-plugin-that-already-passes) writes the files whose absence shows up as a failed first publish — `engines.dsh`, the patch file, a `files` list that ships the code, a client bundle, a test for it, and the CI that runs both. `doctor` then checks the same things without writing anything, and [`verify`](#prove-the-tarball-works-not-just-the-repository) checks the tarball instead of the working copy.
package/client.js ADDED
@@ -0,0 +1,161 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-plugin-toolkit',
3
+ factory: (require) => {
4
+ const module = { exports: {} };
5
+ const React = require('react');
6
+ const { useState, useCallback } = React;
7
+
8
+ const NS = 'dsh-plugin-toolkit';
9
+ const KIND = 'toolkit';
10
+
11
+ const COPY = {
12
+ en: {
13
+ tabTitle: 'Plugin doctor',
14
+ title: 'Does this plugin survive an install?',
15
+ hint: 'The checks the market runs, on the folder you name.',
16
+ placeholder: '/path/to/your-plugin',
17
+ check: 'Check',
18
+ checking: 'Checking…',
19
+ pass: 'Everything the market checks passes.',
20
+ fail: 'This would not install cleanly.',
21
+ noDir: 'Name the folder that holds the plugin\'s package.json.',
22
+ failed: 'Could not run the checks',
23
+ },
24
+ zh: {
25
+ tabTitle: '插件体检',
26
+ title: '这个插件装上去还能用吗?',
27
+ hint: '市场会跑的检查,对着你填的文件夹跑一遍。',
28
+ placeholder: '/path/to/your-plugin',
29
+ check: '检查',
30
+ checking: '检查中…',
31
+ pass: '市场会检查的项都通过了。',
32
+ fail: '这样装上去会出问题。',
33
+ noDir: '请填写放着 package.json 的插件文件夹。',
34
+ failed: '检查没能跑起来',
35
+ },
36
+ };
37
+
38
+ const h = React.createElement;
39
+
40
+ const CSS = [
41
+ '.tk-panel{padding:16px;display:flex;flex-direction:column;gap:10px;font-size:13px}',
42
+ '.tk-title{margin:0;font-size:14px;font-weight:600}',
43
+ '.tk-hint{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary,#888)}',
44
+ '.tk-form{display:flex;gap:8px}',
45
+ '.tk-input{flex:1;min-width:0;height:28px;padding:0 9px;font:inherit;font-size:12px;',
46
+ 'border:0.5px solid var(--dsw-alias-border-l2,#ccc);border-radius:6px;',
47
+ 'background:var(--dsw-alias-bg-layer-1,#fff);color:inherit}',
48
+ '.tk-input:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:-1px}',
49
+ '.tk-button{height:28px;padding:0 12px;border-radius:6px;border:0;cursor:pointer;font:inherit;',
50
+ 'background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff}',
51
+ '.tk-button:disabled{opacity:.6;cursor:default}',
52
+ '.tk-failed{margin:0;font-size:12px;color:var(--dsw-alias-state-error-primary,#c0392b)}',
53
+ '.tk-verdict{margin:0;font-size:12px;font-weight:600}',
54
+ '.tk-verdict[data-ok="true"]{color:var(--dsw-alias-state-success-primary,#2f855a)}',
55
+ '.tk-verdict[data-ok="false"]{color:var(--dsw-alias-state-warn-primary,#b7791f)}',
56
+ '.tk-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}',
57
+ '.tk-row{display:flex;gap:8px;align-items:baseline;padding:5px 8px;border-radius:6px;',
58
+ 'background:var(--dsw-alias-bg-layer-1,#fafafa)}',
59
+ '.tk-mark{width:12px;flex:none;font-weight:600}',
60
+ '.tk-row[data-ok="true"] .tk-mark{color:var(--dsw-alias-state-success-primary,#2f855a)}',
61
+ '.tk-row[data-ok="false"] .tk-mark{color:var(--dsw-alias-state-error-primary,#c0392b)}',
62
+ '.tk-body{display:flex;flex-direction:column;gap:1px;min-width:0}',
63
+ '.tk-check{font-size:11px;text-transform:uppercase;letter-spacing:.04em;',
64
+ 'color:var(--dsw-alias-label-tertiary,#888)}',
65
+ '.tk-message{font-size:12px;overflow-wrap:anywhere}',
66
+ ].join('');
67
+
68
+ function injectStyles(document) {
69
+ if (document.getElementById('dsh-plugin-toolkit:styles') !== null) return;
70
+ const style = document.createElement('style');
71
+ style.id = 'dsh-plugin-toolkit:styles';
72
+ style.textContent = CSS;
73
+ document.head.appendChild(style);
74
+ }
75
+
76
+ function Row({ entry }) {
77
+ return h(
78
+ 'li',
79
+ { className: 'tk-row', 'data-ok': entry.ok === true ? 'true' : 'false' },
80
+ h('span', { className: 'tk-mark', 'aria-hidden': 'true' }, entry.ok === true ? '✓' : '✗'),
81
+ h('span', { className: 'tk-body' },
82
+ h('span', { className: 'tk-check' }, entry.check),
83
+ h('span', { className: 'tk-message' }, entry.message)),
84
+ );
85
+ }
86
+
87
+ function Panel() {
88
+ const [dir, setDir] = useState('');
89
+ const [result, setResult] = useState(null);
90
+ const [busy, setBusy] = useState(false);
91
+ const [failed, setFailed] = useState(null);
92
+ const tr = (key) => {
93
+ const language = COPY[document.documentElement.lang === 'zh' ? 'zh' : 'en'];
94
+ return language[key] ?? key;
95
+ };
96
+
97
+ const check = useCallback(() => {
98
+ if (dir.trim().length === 0) {
99
+ setFailed(tr('noDir'));
100
+ return;
101
+ }
102
+ setBusy(true);
103
+ setFailed(null);
104
+ fetch(`/toolkit/check?dir=${encodeURIComponent(dir.trim())}`)
105
+ .then((response) => (response.ok === true ? response.json() : Promise.reject(new Error(String(response.status)))))
106
+ .then((payload) => setResult(payload))
107
+ .catch((error) => setFailed(`${tr('failed')} · ${error.message}`))
108
+ .finally(() => setBusy(false));
109
+ }, [dir]);
110
+
111
+ return h('div', { className: 'tk-panel' },
112
+ h('h2', { className: 'tk-title' }, tr('title')),
113
+ h('p', { className: 'tk-hint' }, tr('hint')),
114
+ h('div', { className: 'tk-form' },
115
+ h('input', {
116
+ className: 'tk-input',
117
+ type: 'text',
118
+ value: dir,
119
+ placeholder: tr('placeholder'),
120
+ 'aria-label': tr('placeholder'),
121
+ onChange: (event) => setDir(event.target.value),
122
+ onKeyDown: (event) => { if (event.key === 'Enter') check(); },
123
+ }),
124
+ h('button', {
125
+ className: 'tk-button',
126
+ type: 'button',
127
+ disabled: busy === true,
128
+ onClick: check,
129
+ }, busy === true ? tr('checking') : tr('check'))),
130
+ failed !== null ? h('p', { className: 'tk-failed', role: 'status' }, failed) : null,
131
+ result !== null
132
+ ? h('div', { className: 'tk-result' },
133
+ h('p', { className: 'tk-verdict', 'data-ok': result.ok === true ? 'true' : 'false', role: 'status' },
134
+ result.ok === true ? tr('pass') : tr('fail')),
135
+ h('ul', { className: 'tk-rows' }, result.checks.map((entry) => h(Row, { key: entry.check, entry }))))
136
+ : null);
137
+ }
138
+
139
+ function apply(ctx) {
140
+ injectStyles(document);
141
+ ctx.locale.register(NS, COPY);
142
+ ctx.inject(['sidebarRightTabs', 'sidebarRight'], (tabCtx) => {
143
+ if (tabCtx.slots === undefined || tabCtx.sidebarRightTabs === undefined) return;
144
+ tabCtx.effect(() => tabCtx.slots.inject('sidebar.right.pane.tab', () => tabCtx.slots.register({
145
+ name: 'sidebar.right.pane.tab',
146
+ key: NS,
147
+ locale: NS,
148
+ }, Panel)));
149
+ tabCtx.effect(() => tabCtx.sidebarRightTabs.register({
150
+ id: NS,
151
+ kind: KIND,
152
+ priority: 'extension',
153
+ title: () => 'Plugin doctor',
154
+ }));
155
+ });
156
+ }
157
+
158
+ module.exports = { apply, name: 'dsh-plugin-toolkit' };
159
+ return module.exports;
160
+ },
161
+ });
@@ -0,0 +1,6 @@
1
+ # The market installs bundles through this patch, so it has to name the entry
2
+ # the way the harness expects: the package, once.
3
+ - insert:
4
+ - id: dsh-plugin-toolkit
5
+ name: dsh-plugin-toolkit
6
+ config: {}
package/index.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The host half: a panel is a promise that something checked your plugin, so
3
+ * something has to do the checking on the side that can read files.
4
+ *
5
+ * Read-only by design. The panel passes a directory, this runs the same
6
+ * `doctor` and pack-list checks the CLI runs, and answers with what failed and
7
+ * the command that fixes it. Nothing here writes to the workspace.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ import { doctor } from './lib/doctor.mjs';
13
+ import { verify } from './lib/verify.mjs';
14
+
15
+ export const name = 'dsh-plugin-toolkit';
16
+
17
+ const LIMIT = 400;
18
+
19
+ function sendJson(res, status, body) {
20
+ const text = JSON.stringify(body);
21
+ res.statusCode = status;
22
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
23
+ res.setHeader('Content-Length', Buffer.byteLength(text));
24
+ res.end(text);
25
+ }
26
+
27
+ function readDir(value) {
28
+ if (typeof value !== 'string' || value.trim().length === 0) return null;
29
+ const resolved = path.resolve(value.trim());
30
+ if (resolved.length > LIMIT) return null;
31
+ try {
32
+ if (fs.statSync(resolved).isDirectory() === false) return null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ return resolved;
37
+ }
38
+
39
+ /** Same-origin only: this route has no authentication of its own. */
40
+ function sameOrigin(req) {
41
+ const origin = req.headers !== undefined ? req.headers.origin : undefined;
42
+ if (typeof origin !== 'string' || origin.length === 0) return true;
43
+ const host = req.headers.host;
44
+ try {
45
+ return new URL(origin).host === host;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ export async function checkPlugin(dir) {
52
+ const target = readDir(dir);
53
+ if (target === null) {
54
+ return { ok: false, dir: null, checks: [{ check: 'dir', ok: false, message: 'that is not a directory' }] };
55
+ }
56
+ const checks = [];
57
+ const report = doctor(target);
58
+ if (report.failures.length === 0) {
59
+ checks.push({ check: 'manifest', ok: true, message: `${report.manifest.name}@${report.manifest.version}` });
60
+ }
61
+ for (const failure of report.failures) {
62
+ checks.push({ check: failure.check, ok: false, message: failure.message });
63
+ }
64
+ // The pack list is the check `doctor` cannot make: it reads the working copy,
65
+ // so a `files` entry that forgets client.js passes it and ships a plugin with
66
+ // no panel.
67
+ const packed = await verify({ dir: target, mount: false });
68
+ const known = new Set(checks.map((entry) => entry.check));
69
+ if (packed.ok === true) {
70
+ checks.push({ check: 'tarball', ok: true, message: `${packed.files.length} file(s)` });
71
+ } else {
72
+ for (const failure of packed.failures ?? []) {
73
+ // `verify` re-runs doctor first; do not say the same thing twice.
74
+ if (known.has(failure.check) === true) continue;
75
+ checks.push({ check: failure.check ?? packed.stage, ok: false, message: failure.message });
76
+ }
77
+ }
78
+ return { ok: checks.every((entry) => entry.ok === true), dir: target, checks };
79
+ }
80
+
81
+ export function apply(ctx) {
82
+ const checkHandler = async (req, res) => {
83
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method-not-allowed' });
84
+ if (sameOrigin(req) === false) return sendJson(res, 403, { error: 'cross-origin' });
85
+ const url = new URL(req.url, 'http://localhost');
86
+ try {
87
+ return sendJson(res, 200, await checkPlugin(url.searchParams.get('dir')));
88
+ } catch (error) {
89
+ return sendJson(res, 500, { error: 'check-failed', message: String(error && error.message) });
90
+ }
91
+ };
92
+
93
+ let installed = false;
94
+ const installRoutes = (serviceCtx) => {
95
+ if (installed) return;
96
+ const server = serviceCtx !== null && serviceCtx !== undefined ? serviceCtx.webServer : undefined;
97
+ if (server === null || server === undefined || typeof server.register !== 'function') return;
98
+ server.register({ kind: 'exact', path: '/toolkit/check', handler: checkHandler });
99
+ installed = true;
100
+ };
101
+
102
+ const defer = (deps, callback) => {
103
+ try {
104
+ if (typeof ctx.inject === 'function') ctx.inject(deps, callback);
105
+ } catch {
106
+ // The service may already be mounted on this context.
107
+ }
108
+ };
109
+ defer(['webServer'], (serviceCtx) => installRoutes(serviceCtx));
110
+ installRoutes(ctx);
111
+ }
package/package.json CHANGED
@@ -1,24 +1,29 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
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": {
7
7
  "dsh-plugin": "bin/dsh-plugin.mjs"
8
8
  },
9
9
  "exports": {
10
- ".": "./lib/cli.mjs",
10
+ ".": "./index.js",
11
+ "./cli": "./lib/cli.mjs",
11
12
  "./testkit": "./lib/testkit/index.mjs"
12
13
  },
13
14
  "files": [
14
15
  "bin/",
15
16
  "lib/",
17
+ "index.js",
18
+ "client.js",
19
+ "cordis.patch.yml",
16
20
  "README.md",
17
21
  "LICENSE",
18
22
  "CHANGELOG.md"
19
23
  ],
20
24
  "engines": {
21
- "node": ">=18"
25
+ "node": ">=18",
26
+ "dsh": ">=0.2.0"
22
27
  },
23
28
  "keywords": [
24
29
  "dsh-plugin",
@@ -65,5 +70,11 @@
65
70
  "jsdom": "^26.1.0",
66
71
  "react": "^19.3.1",
67
72
  "react-dom": "^19.3.1"
68
- }
73
+ },
74
+ "dsh": {
75
+ "bundle": {
76
+ "patch": "./cordis.patch.yml"
77
+ }
78
+ },
79
+ "main": "index.js"
69
80
  }