dsh-plugin-toolkit 0.8.8 → 0.9.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 +24 -0
- package/README.md +25 -1
- package/lib/audit.mjs +120 -0
- package/lib/cli.mjs +9 -0
- package/lib/dev.mjs +48 -1
- package/lib/doctor.mjs +23 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
## 0.9.1
|
|
2
|
+
|
|
3
|
+
- `audit` separates problems from advisories. Running 0.9.0 on a real profile
|
|
4
|
+
said "17 of 19 installed plugin(s) have problems" — 13 of those load fine and
|
|
5
|
+
only lack `engines.dsh` or a README Requirements section, which the market index
|
|
6
|
+
needs and the loader does not. Those are now counted separately, so the plugins
|
|
7
|
+
that are actually broken stay visible.
|
|
8
|
+
- `client entry` and `dev` resolve a `./client` export written as a conditions
|
|
9
|
+
object (`{ default: './lib/client.cjs' }`), not only as a string.
|
|
10
|
+
|
|
11
|
+
## 0.9.0
|
|
12
|
+
|
|
13
|
+
- `audit`: every plugin installed in a profile, in one pass — version, whether the
|
|
14
|
+
installed copy passes the same checks `doctor` runs, and whether the running app
|
|
15
|
+
loaded it (read from the graph frame on `/plugins/events`). Answers "why is my
|
|
16
|
+
plugin not in the sidebar?" with the plugin named.
|
|
17
|
+
- `doctor --installed` (used by `audit`): skip the `files` check. After install the
|
|
18
|
+
tarball is already unpacked, so `files` cannot decide what shipped — npm always
|
|
19
|
+
ships `package.json` and the README, and the check reported false problems for
|
|
20
|
+
every published plugin.
|
|
21
|
+
- `client entry` accepts the bundle named by the `./client` export, which is how a
|
|
22
|
+
package with a `dsh/` folder names its bundle (`@liustack/modlens` was reported
|
|
23
|
+
as missing its own client bundle).
|
|
24
|
+
|
|
1
25
|
## 0.8.8
|
|
2
26
|
|
|
3
27
|
- `dev` reads the plugin graph the app sends first, so a plugin the app never
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ npx dsh-plugin-toolkit@0.7.0 release --pr --merge # tag, wait for n
|
|
|
15
15
|
| You are… | Start with | Then |
|
|
16
16
|
| --- | --- | --- |
|
|
17
17
|
| writing your first plugin | [`init`](#1-write-a-plugin) | `doctor`, `verify` |
|
|
18
|
-
| changing a panel that is already installed | [`dev`](#2-develop-a-panel) | `mount`, the testkit |
|
|
18
|
+
| changing a panel that is already installed | [`dev`](#2-develop-a-panel) | `mount`, the testkit, `audit` |
|
|
19
19
|
| shipping a version | [`verify`](#3-ship-a-version) | `release`, `index` |
|
|
20
20
|
|
|
21
21
|
It also installs as a plugin. `dsh plugin add dsh-plugin-toolkit` adds a
|
|
@@ -196,6 +196,30 @@ AIignore No workspace yet Could not read the report …
|
|
|
196
196
|
dsh plugin add dsh-plugin-example
|
|
197
197
|
```
|
|
198
198
|
|
|
199
|
+
## See what is installed, and what the app loaded
|
|
200
|
+
|
|
201
|
+
`doctor` checks one folder you name. The question that actually gets asked is
|
|
202
|
+
"why is my plugin not in the sidebar?", and the answer needs three things at once:
|
|
203
|
+
what is installed, whether each installed copy is valid, and which of them the
|
|
204
|
+
running app actually loaded.
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
$ dsh-plugin audit --host http://127.0.0.1:54133
|
|
208
|
+
dsh-aiignore 0.37.1 loaded ok
|
|
209
|
+
demo-panel 0.3.0 not loaded 1 problem: root export
|
|
210
|
+
root export: the root export index.js does not export apply, so the loader imports the package and mounts nothing
|
|
211
|
+
|
|
212
|
+
1 installed plugin(s) are not in the running app; restart the app once after installing.
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Advisories (`engines.dsh`, README Requirements) are counted apart from problems:
|
|
216
|
+
the market index needs them, the loader does not, so they should not bury a plugin
|
|
217
|
+
that is actually broken.
|
|
218
|
+
|
|
219
|
+
Without `--host` the loaded column is `?`, because guessing would be worse than
|
|
220
|
+
saying nothing. Only packages that declare a `dsh` field are audited — the rest of
|
|
221
|
+
`node_modules` is ordinary dependencies, and auditing those is noise.
|
|
222
|
+
|
|
199
223
|
## Edit a plugin that is already installed
|
|
200
224
|
|
|
201
225
|
The host already swaps a rebuilt bundle without a page reload: it watches each plugin's entry artifact and reports a `rebuilt` frame on `/plugins/events`. `dev` puts the new file where the host is looking, then **proves** the frame arrived instead of asking you to check by eye.
|
package/lib/audit.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every plugin installed in a profile, checked in one pass.
|
|
3
|
+
*
|
|
4
|
+
* `doctor` answers "is this plugin correct?" for one folder you name. The
|
|
5
|
+
* question that actually gets asked is "why is my plugin not in the sidebar?",
|
|
6
|
+
* and the answer depends on three things a single folder cannot tell you: what
|
|
7
|
+
* is installed, whether each installed copy is valid, and which of them the
|
|
8
|
+
* running app actually loaded.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { doctor } from './doctor.mjs';
|
|
15
|
+
import { readGraph } from './dev.mjs';
|
|
16
|
+
|
|
17
|
+
/** Every package folder inside every profile, scoped names included. */
|
|
18
|
+
export function listInstalled({ home = path.join(os.homedir(), '.dsh') } = {}) {
|
|
19
|
+
const profiles = path.join(home, 'profiles');
|
|
20
|
+
if (fs.existsSync(profiles) === false) return [];
|
|
21
|
+
const found = [];
|
|
22
|
+
for (const profile of fs.readdirSync(profiles).sort()) {
|
|
23
|
+
const modules = path.join(profiles, profile, 'node_modules');
|
|
24
|
+
if (fs.existsSync(modules) === false) continue;
|
|
25
|
+
for (const entry of fs.readdirSync(modules).sort()) {
|
|
26
|
+
if (entry.startsWith('.') === true) continue;
|
|
27
|
+
const candidates = entry.startsWith('@')
|
|
28
|
+
? fs.readdirSync(path.join(modules, entry)).sort().map((name) => path.join(modules, entry, name))
|
|
29
|
+
: [path.join(modules, entry)];
|
|
30
|
+
for (const dir of candidates) {
|
|
31
|
+
const manifestPath = path.join(dir, 'package.json');
|
|
32
|
+
if (fs.existsSync(manifestPath) === false) continue;
|
|
33
|
+
let manifest;
|
|
34
|
+
try {
|
|
35
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
36
|
+
} catch {
|
|
37
|
+
// A package.json that does not parse is still an installed plugin, and
|
|
38
|
+
// "it does not parse" is exactly what the author needs to hear.
|
|
39
|
+
manifest = { name: path.basename(dir) };
|
|
40
|
+
}
|
|
41
|
+
// Only packages that declare themselves as plugins. node_modules is full
|
|
42
|
+
// of ordinary dependencies, and auditing those is noise.
|
|
43
|
+
if (manifest.dsh === undefined) continue;
|
|
44
|
+
found.push({ dir, manifest, profile });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `engines.dsh` and the README Requirements section are what the market index
|
|
53
|
+
* needs. The loader does not read either, so a plugin without them still loads —
|
|
54
|
+
* calling that "a problem" would bury the plugins that are actually broken.
|
|
55
|
+
*/
|
|
56
|
+
const ADVISORY = new Set(['engines.dsh', 'readme']);
|
|
57
|
+
|
|
58
|
+
function split(failures) {
|
|
59
|
+
return {
|
|
60
|
+
hard: failures.filter((failure) => ADVISORY.has(failure.check) === false),
|
|
61
|
+
advisory: failures.filter((failure) => ADVISORY.has(failure.check) === true),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function describe({ hard, advisory }) {
|
|
66
|
+
if (hard.length === 0 && advisory.length === 0) return 'ok';
|
|
67
|
+
const parts = [];
|
|
68
|
+
if (hard.length > 0) parts.push(`${hard.length} problem${hard.length === 1 ? '' : 's'}: ${hard[0].check}`);
|
|
69
|
+
if (advisory.length > 0) parts.push(`${advisory.length} advisor${advisory.length === 1 ? 'y' : 'ies'}`);
|
|
70
|
+
return parts.join(', ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function audit({ home, host, io = console, fetchImpl, timeoutMs = 3000 } = {}) {
|
|
74
|
+
const installed = listInstalled({ home });
|
|
75
|
+
if (installed.length === 0) {
|
|
76
|
+
io.log('no plugins installed in any profile (a plugin declares a "dsh" field)');
|
|
77
|
+
return { ok: true, plugins: [], graph: null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let graph = null;
|
|
81
|
+
if (typeof host === 'string' && host.length > 0) {
|
|
82
|
+
graph = await readGraph({ host, timeoutMs, fetchImpl });
|
|
83
|
+
if (graph.ok !== true) io.log(`could not read the plugin graph: ${graph.reason}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const rows = installed.map(({ dir, manifest, profile }) => {
|
|
87
|
+
let failures;
|
|
88
|
+
try {
|
|
89
|
+
({ failures } = doctor(dir, { installed: true }));
|
|
90
|
+
} catch (error) {
|
|
91
|
+
failures = [{ check: 'manifest', message: error.message }];
|
|
92
|
+
}
|
|
93
|
+
const loaded = graph?.ok === true ? graph.ids.has(manifest.name) : null;
|
|
94
|
+
return { name: manifest.name, version: manifest.version ?? '?', profile, dir, failures, ...split(failures), loaded };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const width = Math.max(...rows.map((row) => row.name.length), 6);
|
|
98
|
+
for (const row of rows) {
|
|
99
|
+
const state = row.loaded === null ? '?' : row.loaded === true ? 'loaded' : 'not loaded';
|
|
100
|
+
io.log(`${row.name.padEnd(width)} ${String(row.version).padEnd(8)} ${state.padEnd(10)} ${describe(row)}`);
|
|
101
|
+
for (const failure of row.failures) io.log(`${' '.repeat(width + 22)}${failure.check}: ${failure.message}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const failed = rows.filter((row) => row.hard.length > 0);
|
|
105
|
+
const advised = rows.filter((row) => row.hard.length === 0 && row.advisory.length > 0);
|
|
106
|
+
const notLoaded = rows.filter((row) => row.loaded === false);
|
|
107
|
+
if (notLoaded.length > 0) {
|
|
108
|
+
io.log('');
|
|
109
|
+
io.log(`${notLoaded.length} installed plugin(s) are not in the running app; restart the app once after installing.`);
|
|
110
|
+
}
|
|
111
|
+
if (advised.length > 0) {
|
|
112
|
+
io.log('');
|
|
113
|
+
io.log(`${advised.length} plugin(s) load but are not ready for the market index (engines.dsh, README Requirements).`);
|
|
114
|
+
}
|
|
115
|
+
if (failed.length > 0) {
|
|
116
|
+
io.log('');
|
|
117
|
+
io.log(`${failed.length} of ${rows.length} installed plugin(s) have problems.`);
|
|
118
|
+
}
|
|
119
|
+
return { ok: failed.length === 0, plugins: rows, graph };
|
|
120
|
+
}
|
package/lib/cli.mjs
CHANGED
|
@@ -25,6 +25,8 @@ export const HELP = `dsh-plugin — preflight and release DeepSeek Harness plugi
|
|
|
25
25
|
dsh-plugin mount <client.js> render a client bundle in Node and print what a user would see
|
|
26
26
|
dsh-plugin mount ./client.js --html
|
|
27
27
|
|
|
28
|
+
dsh-plugin audit [flags] every installed plugin: valid, and loaded by the running app
|
|
29
|
+
dsh-plugin audit --host http://127.0.0.1:54133
|
|
28
30
|
dsh-plugin dev [dir] [flags] copy a rebuilt bundle into the installed plugin and confirm the reload
|
|
29
31
|
dsh-plugin dev . --host http://127.0.0.1:54133
|
|
30
32
|
|
|
@@ -74,6 +76,7 @@ function flags(argv) {
|
|
|
74
76
|
else if (token === '--pr') parsed.pr = true;
|
|
75
77
|
else if (token === '--merge') parsed.merge = true;
|
|
76
78
|
else if (token === '--publish') parsed.publish = true;
|
|
79
|
+
else if (token === '--home') { parsed.home = args[++index]; }
|
|
77
80
|
else if (token === '--base') parsed.base = argv[++index];
|
|
78
81
|
else if (token === '--market') parsed.market = argv[++index];
|
|
79
82
|
else if (token.startsWith('--') === true) throw new Error(`unknown flag ${token}`);
|
|
@@ -158,6 +161,12 @@ export async function run(argv, io = console) {
|
|
|
158
161
|
return problems === 0 ? 0 : 1;
|
|
159
162
|
}
|
|
160
163
|
|
|
164
|
+
if (command === 'audit') {
|
|
165
|
+
const { audit } = await import('./audit.mjs');
|
|
166
|
+
const result = await audit({ home: options.home, host: options.host, timeoutMs: options.timeout, io });
|
|
167
|
+
return result.ok === true ? 0 : 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
161
170
|
if (command === 'dev') {
|
|
162
171
|
const { dev } = await import('./dev.mjs');
|
|
163
172
|
return dev({
|
package/lib/dev.mjs
CHANGED
|
@@ -12,11 +12,15 @@ import os from 'node:os';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { spawnSync } from 'node:child_process';
|
|
14
14
|
|
|
15
|
+
import { exportTarget } from './doctor.mjs';
|
|
16
|
+
|
|
15
17
|
const IGNORED = new Set(['node_modules', '.git', 'docs', 'test', 'tests', 'coverage', 'fixtures']);
|
|
16
18
|
|
|
17
19
|
export function resolveEntry(dir, manifest) {
|
|
18
20
|
const declared = manifest?.dsh?.client?.entry;
|
|
19
|
-
const
|
|
21
|
+
const exported = manifest?.exports;
|
|
22
|
+
const subpath = typeof exported === 'string' ? undefined : exportTarget(exported?.['./client']);
|
|
23
|
+
const candidates = [declared, subpath, 'client.js', 'lib/client.js', 'dist/client.js'].filter((value) => typeof value === 'string');
|
|
20
24
|
for (const candidate of candidates) {
|
|
21
25
|
const absolute = path.resolve(dir, candidate);
|
|
22
26
|
if (fs.existsSync(absolute)) return absolute;
|
|
@@ -119,6 +123,49 @@ export async function verifyReload({ host, id, timeoutMs = 6000, fetchImpl = fet
|
|
|
119
123
|
}
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The app's own list of what it loaded, read once. `audit` uses it to answer
|
|
128
|
+
* "installed, but why is there no panel?" for every plugin at once.
|
|
129
|
+
*/
|
|
130
|
+
export async function readGraph({ host, timeoutMs = 3000, fetchImpl = fetch }) {
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
133
|
+
const url = `${String(host).replace(/\/+$/, '')}/plugins/events`;
|
|
134
|
+
try {
|
|
135
|
+
const response = await fetchImpl(url, { headers: { accept: 'text/event-stream' }, signal: controller.signal });
|
|
136
|
+
if (response.ok !== true) return { ok: false, reason: `GET ${url} answered ${response.status}` };
|
|
137
|
+
const reader = response.body.getReader();
|
|
138
|
+
const decoder = new TextDecoder();
|
|
139
|
+
let buffer = '';
|
|
140
|
+
for (;;) {
|
|
141
|
+
const { value, done } = await reader.read();
|
|
142
|
+
if (done === true) break;
|
|
143
|
+
buffer += decoder.decode(value, { stream: true });
|
|
144
|
+
const frames = buffer.split('\n\n');
|
|
145
|
+
buffer = frames.pop() ?? '';
|
|
146
|
+
for (const frame of frames) {
|
|
147
|
+
const data = frame.split('\n').find((line) => line.startsWith('data: '));
|
|
148
|
+
if (data === undefined) continue;
|
|
149
|
+
let payload;
|
|
150
|
+
try {
|
|
151
|
+
payload = JSON.parse(data.slice(6));
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (payload.type !== 'graph' || Array.isArray(payload.graph?.entries) === false) continue;
|
|
156
|
+
return { ok: true, ids: new Set(payload.graph.entries.map((entry) => entry.id)) };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { ok: false, reason: 'the stream ended before a graph frame arrived' };
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error.name === 'AbortError') return { ok: false, reason: `no graph frame within ${timeoutMs}ms` };
|
|
162
|
+
return { ok: false, reason: error.message };
|
|
163
|
+
} finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
controller.abort();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
122
169
|
export function watchPlugin({ dir, onChange, debounceMs = 200, io = console }) {
|
|
123
170
|
const timers = new Map();
|
|
124
171
|
const watcher = fs.watch(dir, { recursive: true }, (event, file) => {
|
package/lib/doctor.mjs
CHANGED
|
@@ -84,10 +84,28 @@ export function checkPatchFile(dir, manifest) {
|
|
|
84
84
|
return `dsh.bundle.patch points at ${bundle.patch}, which does not exist`;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** `exports['./client']` is a string in some packages and a conditions object in others. */
|
|
88
|
+
export function exportTarget(value) {
|
|
89
|
+
if (typeof value === 'string') return value;
|
|
90
|
+
if (value !== null && typeof value === 'object') {
|
|
91
|
+
for (const key of ['default', 'import', 'require']) {
|
|
92
|
+
if (typeof value[key] === 'string') return value[key];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
87
98
|
export function checkClientEntry(dir, manifest) {
|
|
88
99
|
const client = manifest.dsh?.client;
|
|
89
100
|
if (client === undefined || client === null) return null;
|
|
90
|
-
|
|
101
|
+
// The declared entry comes first, then the `./client` export (how a package
|
|
102
|
+
// with a `dsh/` folder names its bundle), then the plain file names. A plugin
|
|
103
|
+
// that ships `dsh/client.js` is not wrong for failing to also ship `client.js`.
|
|
104
|
+
const exported = manifest.exports;
|
|
105
|
+
const subpath = typeof exported === 'string' ? undefined : exportTarget(exported?.['./client']);
|
|
106
|
+
const entries = [client.entry, subpath, 'client.js', 'lib/client.js', 'dist/client.js']
|
|
107
|
+
.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
108
|
+
.map((entry) => entry.replace(/^\.\//u, ''));
|
|
91
109
|
const found = entries.some((entry) => fs.existsSync(path.join(dir, entry)));
|
|
92
110
|
return found ? null : `dsh.client is declared but none of ${entries.join(', ')} exists`;
|
|
93
111
|
}
|
|
@@ -173,7 +191,7 @@ export function checkRootExport(dir, manifest) {
|
|
|
173
191
|
}
|
|
174
192
|
|
|
175
193
|
/** Runs every check and returns `{ failures, notes }`. */
|
|
176
|
-
export function doctor(dir) {
|
|
194
|
+
export function doctor(dir, { installed = false } = {}) {
|
|
177
195
|
const { manifest } = readManifest(dir);
|
|
178
196
|
const checks = [
|
|
179
197
|
['version', checkVersion(manifest)],
|
|
@@ -182,7 +200,9 @@ export function doctor(dir) {
|
|
|
182
200
|
['dsh field', checkDshField(manifest)],
|
|
183
201
|
['patch file', checkPatchFile(dir, manifest)],
|
|
184
202
|
['client entry', checkClientEntry(dir, manifest)],
|
|
185
|
-
|
|
203
|
+
// After install the tarball is already unpacked, so `files` cannot be used to
|
|
204
|
+
// decide what shipped: npm always ships package.json and the README.
|
|
205
|
+
['files list', installed === true ? null : checkFilesList(manifest)],
|
|
186
206
|
['root export', checkRootExport(dir, manifest)],
|
|
187
207
|
];
|
|
188
208
|
const failures = checks.filter(([, result]) => result !== null && typeof result === 'string');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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": {
|