dsh-plugin-toolkit 0.8.7 → 0.9.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,3 +1,23 @@
1
+ ## 0.9.0
2
+
3
+ - `audit`: every plugin installed in a profile, in one pass — version, whether the
4
+ installed copy passes the same checks `doctor` runs, and whether the running app
5
+ loaded it (read from the graph frame on `/plugins/events`). Answers "why is my
6
+ plugin not in the sidebar?" with the plugin named.
7
+ - `doctor --installed` (used by `audit`): skip the `files` check. After install the
8
+ tarball is already unpacked, so `files` cannot decide what shipped — npm always
9
+ ships `package.json` and the README, and the check reported false problems for
10
+ every published plugin.
11
+ - `client entry` accepts the bundle named by the `./client` export, which is how a
12
+ package with a `dsh/` folder names its bundle (`@liustack/modlens` was reported
13
+ as missing its own client bundle).
14
+
15
+ ## 0.8.8
16
+
17
+ - `dev` reads the plugin graph the app sends first, so a plugin the app never
18
+ loaded is reported as that ("restart the app once after installing it") instead
19
+ of as a timeout. Found by running `dev` against a real app, not a fake stream.
20
+
1
21
  ## 0.8.7
2
22
 
3
23
  - `panel.aria(selector)` and `panel.expectAria(selector, expected)`: assert what a
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,26 @@ 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
+ Without `--host` the loaded column is `?`, because guessing would be worse than
216
+ saying nothing. Only packages that declare a `dsh` field are audited — the rest of
217
+ `node_modules` is ordinary dependencies, and auditing those is noise.
218
+
199
219
  ## Edit a plugin that is already installed
200
220
 
201
221
  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.
@@ -217,6 +237,12 @@ watching /work/plugin
217
237
 
218
238
  That last line is the whole point: the revision came back from the app's own event stream, so "it reloaded" is a fact, not a hope.
219
239
 
240
+ A plugin the running app never loaded cannot be reloaded, and the app says so by leaving it out of the graph it sends first. `dev` reads that graph, so a freshly installed plugin gets the sentence that fixes it instead of a timeout:
241
+
242
+ ```
243
+ copied, but not confirmed: the running app has not loaded demo-panel, so it will not report a reload; restart the app once after installing it
244
+ ```
245
+
220
246
  | Flag | Meaning |
221
247
  |---|---|
222
248
  | `--dest <dir>` | write into this installed copy instead of searching `~/.dsh/profiles/*/node_modules/<name>` |
package/lib/audit.mjs ADDED
@@ -0,0 +1,98 @@
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
+ function describe({ failures }) {
52
+ if (failures.length === 0) return 'ok';
53
+ return `${failures.length} problem${failures.length === 1 ? '' : 's'}: ${failures[0].check}`;
54
+ }
55
+
56
+ export async function audit({ home, host, io = console, fetchImpl, timeoutMs = 3000 } = {}) {
57
+ const installed = listInstalled({ home });
58
+ if (installed.length === 0) {
59
+ io.log('no plugins installed in any profile (a plugin declares a "dsh" field)');
60
+ return { ok: true, plugins: [], graph: null };
61
+ }
62
+
63
+ let graph = null;
64
+ if (typeof host === 'string' && host.length > 0) {
65
+ graph = await readGraph({ host, timeoutMs, fetchImpl });
66
+ if (graph.ok !== true) io.log(`could not read the plugin graph: ${graph.reason}`);
67
+ }
68
+
69
+ const rows = installed.map(({ dir, manifest, profile }) => {
70
+ let failures;
71
+ try {
72
+ ({ failures } = doctor(dir, { installed: true }));
73
+ } catch (error) {
74
+ failures = [{ check: 'manifest', message: error.message }];
75
+ }
76
+ const loaded = graph?.ok === true ? graph.ids.has(manifest.name) : null;
77
+ return { name: manifest.name, version: manifest.version ?? '?', profile, dir, failures, loaded };
78
+ });
79
+
80
+ const width = Math.max(...rows.map((row) => row.name.length), 6);
81
+ for (const row of rows) {
82
+ const state = row.loaded === null ? '?' : row.loaded === true ? 'loaded' : 'not loaded';
83
+ io.log(`${row.name.padEnd(width)} ${String(row.version).padEnd(8)} ${state.padEnd(10)} ${describe(row)}`);
84
+ for (const failure of row.failures) io.log(`${' '.repeat(width + 22)}${failure.check}: ${failure.message}`);
85
+ }
86
+
87
+ const failed = rows.filter((row) => row.failures.length > 0);
88
+ const notLoaded = rows.filter((row) => row.loaded === false);
89
+ if (notLoaded.length > 0) {
90
+ io.log('');
91
+ io.log(`${notLoaded.length} installed plugin(s) are not in the running app; restart the app once after installing.`);
92
+ }
93
+ if (failed.length > 0) {
94
+ io.log('');
95
+ io.log(`${failed.length} of ${rows.length} installed plugin(s) have problems.`);
96
+ }
97
+ return { ok: failed.length === 0, plugins: rows, graph };
98
+ }
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
@@ -16,7 +16,9 @@ const IGNORED = new Set(['node_modules', '.git', 'docs', 'test', 'tests', 'cover
16
16
 
17
17
  export function resolveEntry(dir, manifest) {
18
18
  const declared = manifest?.dsh?.client?.entry;
19
- const candidates = [declared, 'client.js', 'lib/client.js', 'dist/client.js'].filter((value) => typeof value === 'string');
19
+ const exported = manifest?.exports;
20
+ const subpath = typeof exported === 'string' ? undefined : exported?.['./client'];
21
+ const candidates = [declared, subpath, 'client.js', 'lib/client.js', 'dist/client.js'].filter((value) => typeof value === 'string');
20
22
  for (const candidate of candidates) {
21
23
  const absolute = path.resolve(dir, candidate);
22
24
  if (fs.existsSync(absolute)) return absolute;
@@ -61,6 +63,15 @@ export function build({ command, cwd, io = console }) {
61
63
  export async function verifyReload({ host, id, timeoutMs = 6000, fetchImpl = fetch, action }) {
62
64
  const controller = new AbortController();
63
65
  const timer = setTimeout(() => controller.abort(), timeoutMs);
66
+ // The app sends its plugin graph first. If this plugin is not in it, no amount
67
+ // of waiting will produce a rebuilt frame: it was installed after the app
68
+ // started, and one restart is the fix.
69
+ let loaded = null;
70
+ // Two ways to give up — the stream ended, or it timed out — and both mean the
71
+ // same thing when the app never had this plugin in its graph.
72
+ const miss = (fallback) => (loaded !== null && loaded.has(id) === false
73
+ ? `the running app has not loaded ${id}, so it will not report a reload; restart the app once after installing it`
74
+ : fallback);
64
75
  const url = `${String(host).replace(/\/+$/, '')}/plugins/events`;
65
76
  try {
66
77
  const response = await fetchImpl(url, { headers: { accept: 'text/event-stream' }, signal: controller.signal });
@@ -90,14 +101,62 @@ export async function verifyReload({ host, id, timeoutMs = 6000, fetchImpl = fet
90
101
  } catch {
91
102
  continue;
92
103
  }
104
+ if (payload.type === 'graph') {
105
+ const entries = payload.graph?.entries;
106
+ if (Array.isArray(entries) === true) loaded = new Set(entries.map((entry) => entry.id));
107
+ continue;
108
+ }
93
109
  if (payload.type !== 'rebuilt') continue;
94
110
  if (JSON.stringify(payload).includes(id) === false) continue;
95
111
  return { ok: true, rev: payload.rev ?? null, frame: payload };
96
112
  }
97
113
  }
98
- return { ok: false, reason: 'the stream ended before a rebuilt frame arrived' };
114
+ return { ok: false, reason: miss('the stream ended before a rebuilt frame arrived') };
115
+ } catch (error) {
116
+ if (error.name === 'AbortError') return { ok: false, reason: miss(`no rebuilt frame for ${id} within ${timeoutMs}ms`) };
117
+ return { ok: false, reason: error.message };
118
+ } finally {
119
+ clearTimeout(timer);
120
+ controller.abort();
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The app's own list of what it loaded, read once. `audit` uses it to answer
126
+ * "installed, but why is there no panel?" for every plugin at once.
127
+ */
128
+ export async function readGraph({ host, timeoutMs = 3000, fetchImpl = fetch }) {
129
+ const controller = new AbortController();
130
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
131
+ const url = `${String(host).replace(/\/+$/, '')}/plugins/events`;
132
+ try {
133
+ const response = await fetchImpl(url, { headers: { accept: 'text/event-stream' }, signal: controller.signal });
134
+ if (response.ok !== true) return { ok: false, reason: `GET ${url} answered ${response.status}` };
135
+ const reader = response.body.getReader();
136
+ const decoder = new TextDecoder();
137
+ let buffer = '';
138
+ for (;;) {
139
+ const { value, done } = await reader.read();
140
+ if (done === true) break;
141
+ buffer += decoder.decode(value, { stream: true });
142
+ const frames = buffer.split('\n\n');
143
+ buffer = frames.pop() ?? '';
144
+ for (const frame of frames) {
145
+ const data = frame.split('\n').find((line) => line.startsWith('data: '));
146
+ if (data === undefined) continue;
147
+ let payload;
148
+ try {
149
+ payload = JSON.parse(data.slice(6));
150
+ } catch {
151
+ continue;
152
+ }
153
+ if (payload.type !== 'graph' || Array.isArray(payload.graph?.entries) === false) continue;
154
+ return { ok: true, ids: new Set(payload.graph.entries.map((entry) => entry.id)) };
155
+ }
156
+ }
157
+ return { ok: false, reason: 'the stream ended before a graph frame arrived' };
99
158
  } catch (error) {
100
- if (error.name === 'AbortError') return { ok: false, reason: `no rebuilt frame for ${id} within ${timeoutMs}ms` };
159
+ if (error.name === 'AbortError') return { ok: false, reason: `no graph frame within ${timeoutMs}ms` };
101
160
  return { ok: false, reason: error.message };
102
161
  } finally {
103
162
  clearTimeout(timer);
package/lib/doctor.mjs CHANGED
@@ -87,7 +87,14 @@ export function checkPatchFile(dir, manifest) {
87
87
  export function checkClientEntry(dir, manifest) {
88
88
  const client = manifest.dsh?.client;
89
89
  if (client === undefined || client === null) return null;
90
- const entries = ['client.js', 'lib/client.js', 'dist/client.js'];
90
+ // The declared entry comes first, then the `./client` export (how a package
91
+ // with a `dsh/` folder names its bundle), then the plain file names. A plugin
92
+ // that ships `dsh/client.js` is not wrong for failing to also ship `client.js`.
93
+ const exported = manifest.exports;
94
+ const subpath = typeof exported === 'string' ? undefined : exported?.['./client'];
95
+ const entries = [client.entry, subpath, 'client.js', 'lib/client.js', 'dist/client.js']
96
+ .filter((entry) => typeof entry === 'string' && entry.length > 0)
97
+ .map((entry) => entry.replace(/^\.\//u, ''));
91
98
  const found = entries.some((entry) => fs.existsSync(path.join(dir, entry)));
92
99
  return found ? null : `dsh.client is declared but none of ${entries.join(', ')} exists`;
93
100
  }
@@ -173,7 +180,7 @@ export function checkRootExport(dir, manifest) {
173
180
  }
174
181
 
175
182
  /** Runs every check and returns `{ failures, notes }`. */
176
- export function doctor(dir) {
183
+ export function doctor(dir, { installed = false } = {}) {
177
184
  const { manifest } = readManifest(dir);
178
185
  const checks = [
179
186
  ['version', checkVersion(manifest)],
@@ -182,7 +189,9 @@ export function doctor(dir) {
182
189
  ['dsh field', checkDshField(manifest)],
183
190
  ['patch file', checkPatchFile(dir, manifest)],
184
191
  ['client entry', checkClientEntry(dir, manifest)],
185
- ['files list', checkFilesList(manifest)],
192
+ // After install the tarball is already unpacked, so `files` cannot be used to
193
+ // decide what shipped: npm always ships package.json and the README.
194
+ ['files list', installed === true ? null : checkFilesList(manifest)],
186
195
  ['root export', checkRootExport(dir, manifest)],
187
196
  ];
188
197
  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.8.7",
3
+ "version": "0.9.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": {