dsh-plugin-toolkit 0.2.2 → 0.3.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,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1
4
+
5
+ - `unmount()` now waits one macrotask before the DOM goes away. React schedules work during its own unmount, and closing jsdom first turned that into `ReferenceError: window is not defined` after a passing test. Found by the example plugin's second test.
6
+ - The README links `dsh-plugin-example` and documents that `unmount()` is async.
7
+
8
+ ## 0.3.0
9
+
10
+ - `dsh-plugin dev`: copy a rebuilt bundle into the installed plugin, then confirm the reload from the host's `/plugins/events` stream. Subscribing happens *before* the write, because a copy made first is reported to nobody.
11
+ - Without `--host` the copy still happens and the tool says the reload is unconfirmed — no guessed port, no false negative.
12
+ - A failed `--build` copies nothing.
13
+
3
14
  ## 0.2.2
4
15
 
5
16
  - `unmount()` closes the jsdom window, so a panel that polls does not keep the test process alive.
package/README.md CHANGED
@@ -49,6 +49,8 @@ console.assert(panel.must('[role="status"]').textContent.includes('92'));
49
49
  await panel.unmount();
50
50
  ```
51
51
 
52
+ `unmount()` is async: it lets React finish the work its own unmount schedules before the DOM goes away. Skipping the `await` surfaces as `ReferenceError: window is not defined` *after* the test has already passed, which is a confusing way to learn it.
53
+
52
54
  What you get back: `text()`, `html()`, `query(selector)`, `must(selector)` (throws with the rendered HTML when it misses), `click(selector)`, `type(selector, value)`, `press(selector, key)`, `focus`, `attributes(selector)`, `snapshot(dir, name)` for a normalised DOM baseline, and `host` for assertions about what the bundle registered.
53
55
 
54
56
  To look at a bundle by hand:
@@ -65,6 +67,41 @@ $ npx dsh-plugin mount ../ai-context-doctor/plugin/client.js
65
67
  AIignore No workspace yet Could not read the report …
66
68
  ```
67
69
 
70
+ ## Start from the example
71
+
72
+ [`dsh-plugin-example`](https://github.com/WJDdidi-1/dsh-plugin-example) is a ~70-line plugin: one sidebar tab, two languages, one behaviour test, CI that runs `doctor` through the composite action. It is the shape this toolkit expects, so it is also the fastest way to see whether the toolkit fits your plugin.
73
+
74
+ ```sh
75
+ dsh plugin add dsh-plugin-example
76
+ ```
77
+
78
+ ## Edit a plugin that is already installed
79
+
80
+ 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.
81
+
82
+ ```sh
83
+ npx dsh-plugin dev . --host http://127.0.0.1:54133
84
+ ```
85
+
86
+ ```
87
+ demo-aiignore: /work/plugin/client.js -> ~/.dsh/profiles/desktop/node_modules/dsh-aiignore/client.js
88
+ initial: copied 163647 bytes at 1:18:43 AM
89
+ reloaded dsh-aiignore (rev e0c0230c4fc9)
90
+ watching /work/plugin
91
+ ```
92
+
93
+ 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.
94
+
95
+ | Flag | Meaning |
96
+ |---|---|
97
+ | `--dest <dir>` | write into this installed copy instead of searching `~/.dsh/profiles/*/node_modules/<name>` |
98
+ | `--entry <file>` | copy this file instead of `dsh.client.entry`, `client.js`, `lib/client.js` |
99
+ | `--build <command>` | run this before each copy (a failed build copies nothing) |
100
+ | `--host <url>` | confirm the reload against the app at this URL; without it the copy still happens and the tool says it is unconfirmed |
101
+ | `--once` | copy once and exit, for scripts |
102
+
103
+ Two things it does not do, because the host does not: React state inside the reloaded plugin is lost, and a failed swap does not roll back to the previous bundle.
104
+
68
105
  ## Use it in CI
69
106
 
70
107
  The same checks run as a GitHub Action, so a tag cannot go out on a plugin that would not install:
package/lib/cli.mjs CHANGED
@@ -15,6 +15,7 @@ export const HELP = `dsh-plugin — preflight and release DeepSeek Harness plugi
15
15
  dsh-plugin release [dir] [flags] tag, wait for npm, update the market index
16
16
  dsh-plugin index [dir] audit every version in market/index.json
17
17
  dsh-plugin mount <client.js> render a client bundle in Node and print what a user would see
18
+ dsh-plugin dev [dir] [flags] copy a rebuilt bundle into the installed plugin and confirm the reload
18
19
 
19
20
  Flags
20
21
  --dry-run show the steps without touching anything
@@ -34,6 +35,13 @@ function flags(argv) {
34
35
  else if (token === '--slot') parsed.slot = argv[++index];
35
36
  else if (token === '--surface') parsed.surface = argv[++index];
36
37
  else if (token === '--html') parsed.html = true;
38
+ else if (token === '--dest') parsed.dest = argv[++index];
39
+ else if (token === '--entry') parsed.entry = argv[++index];
40
+ else if (token === '--build') parsed.build = argv[++index];
41
+ else if (token === '--host') parsed.host = argv[++index];
42
+ else if (token === '--once') parsed.once = true;
43
+ else if (token === '--debounce') parsed.debounce = Number(argv[++index]);
44
+ else if (token === '--timeout') parsed.timeout = Number(argv[++index]);
37
45
  else if (token.startsWith('--') === true) throw new Error(`unknown flag ${token}`);
38
46
  else parsed.positional.push(token);
39
47
  }
@@ -86,6 +94,21 @@ export async function run(argv, io = console) {
86
94
  return problems === 0 ? 0 : 1;
87
95
  }
88
96
 
97
+ if (command === 'dev') {
98
+ const { dev } = await import('./dev.mjs');
99
+ return dev({
100
+ dir: path.resolve(options.positional[0] ?? '.'),
101
+ dest: options.dest,
102
+ entry: options.entry,
103
+ build: options.build,
104
+ host: options.host,
105
+ once: options.once === true,
106
+ debounceMs: options.debounce,
107
+ timeoutMs: options.timeout,
108
+ io,
109
+ });
110
+ }
111
+
89
112
  if (command === 'mount') {
90
113
  const file = options.positional[0];
91
114
  if (file === undefined) {
package/lib/dev.mjs ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Editing a plugin that is already installed.
3
+ *
4
+ * The host already knows how to swap a rebuilt bundle: it watches each plugin's
5
+ * entry artifact and reports a `rebuilt` frame on `/plugins/events`, which the
6
+ * page turns into a fresh mount. So `dev` does not need a new transport — it
7
+ * needs to put the new file where the host is looking, and then prove the frame
8
+ * arrived instead of asking the author to check by eye.
9
+ */
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
14
+
15
+ const IGNORED = new Set(['node_modules', '.git', 'docs', 'test', 'tests', 'coverage', 'fixtures']);
16
+
17
+ export function resolveEntry(dir, manifest) {
18
+ const declared = manifest?.dsh?.client?.entry;
19
+ const candidates = [declared, 'client.js', 'lib/client.js', 'dist/client.js'].filter((value) => typeof value === 'string');
20
+ for (const candidate of candidates) {
21
+ const absolute = path.resolve(dir, candidate);
22
+ if (fs.existsSync(absolute)) return absolute;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ /** Where the installed copy lives, so the host's watcher sees the change. */
28
+ export function resolveDest(name, { dest, home = path.join(os.homedir(), '.dsh') } = {}) {
29
+ if (typeof dest === 'string' && dest.length > 0) return path.resolve(dest);
30
+ const profiles = path.join(home, 'profiles');
31
+ if (fs.existsSync(profiles) === false) return null;
32
+ for (const profile of fs.readdirSync(profiles)) {
33
+ const candidate = path.join(profiles, profile, 'node_modules', name);
34
+ if (fs.existsSync(candidate) === true) return candidate;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ export function copyEntry({ from, to }) {
40
+ if (fs.existsSync(from) === false) throw new Error(`${from} does not exist`);
41
+ fs.mkdirSync(path.dirname(to), { recursive: true });
42
+ // copyFile updates mtime and ctime, which is exactly the revision the host
43
+ // compares — an identical rebuild still counts as a new one.
44
+ fs.copyFileSync(from, to);
45
+ const stat = fs.statSync(to);
46
+ return { file: to, bytes: stat.size, mtimeMs: stat.mtimeMs };
47
+ }
48
+
49
+ export function build({ command, cwd, io = console }) {
50
+ if (typeof command !== 'string' || command.trim() === '') return { ok: true, skipped: true };
51
+ io.log(`running: ${command}`);
52
+ const result = spawnSync(command, { cwd, shell: true, stdio: 'inherit' });
53
+ return { ok: result.status === 0, status: result.status };
54
+ }
55
+
56
+ /**
57
+ * Reads the SSE stream until a `rebuilt` frame names this plugin, then closes
58
+ * the connection. A timeout is reported as such: the file may be fine and the
59
+ * app simply not running.
60
+ */
61
+ export async function verifyReload({ host, id, timeoutMs = 6000, fetchImpl = fetch, action }) {
62
+ const controller = new AbortController();
63
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
64
+ const url = `${String(host).replace(/\/+$/, '')}/plugins/events`;
65
+ try {
66
+ const response = await fetchImpl(url, { headers: { accept: 'text/event-stream' }, signal: controller.signal });
67
+ if (response.ok !== true) return { ok: false, reason: `GET ${url} answered ${response.status}` };
68
+ const reader = response.body.getReader();
69
+ const decoder = new TextDecoder();
70
+ let buffer = '';
71
+ let acted = false;
72
+ for (;;) {
73
+ const { value, done } = await reader.read();
74
+ if (done === true) break;
75
+ buffer += decoder.decode(value, { stream: true });
76
+ const frames = buffer.split('\n\n');
77
+ buffer = frames.pop() ?? '';
78
+ for (const frame of frames) {
79
+ const data = frame.split('\n').find((line) => line.startsWith('data: '));
80
+ if (data === undefined) continue;
81
+ // The first frame proves the stream is live. Only then is the file
82
+ // written: a copy made before subscribing can be reported to nobody.
83
+ if (acted === false && typeof action === 'function') {
84
+ acted = true;
85
+ await action();
86
+ }
87
+ let payload;
88
+ try {
89
+ payload = JSON.parse(data.slice(6));
90
+ } catch {
91
+ continue;
92
+ }
93
+ if (payload.type !== 'rebuilt') continue;
94
+ if (JSON.stringify(payload).includes(id) === false) continue;
95
+ return { ok: true, rev: payload.rev ?? null, frame: payload };
96
+ }
97
+ }
98
+ return { ok: false, reason: 'the stream ended before a rebuilt frame arrived' };
99
+ } catch (error) {
100
+ if (error.name === 'AbortError') return { ok: false, reason: `no rebuilt frame for ${id} within ${timeoutMs}ms` };
101
+ return { ok: false, reason: error.message };
102
+ } finally {
103
+ clearTimeout(timer);
104
+ controller.abort();
105
+ }
106
+ }
107
+
108
+ export function watchPlugin({ dir, onChange, debounceMs = 200, io = console }) {
109
+ const timers = new Map();
110
+ const watcher = fs.watch(dir, { recursive: true }, (event, file) => {
111
+ if (typeof file !== 'string') return;
112
+ const parts = file.split(path.sep);
113
+ if (parts.some((part) => IGNORED.has(part)) === true) return;
114
+ if (/\.(png|jpg|jpeg|gif|webp|map|log)$/i.test(file) === true) return;
115
+ clearTimeout(timers.get(file));
116
+ timers.set(file, setTimeout(() => {
117
+ timers.delete(file);
118
+ onChange(file);
119
+ }, debounceMs));
120
+ });
121
+ io.log(`watching ${dir}`);
122
+ return () => {
123
+ for (const timer of timers.values()) clearTimeout(timer);
124
+ watcher.close();
125
+ };
126
+ }
127
+
128
+ export async function dev(options) {
129
+ const {
130
+ dir,
131
+ dest: destOption,
132
+ entry: entryOption,
133
+ build: buildCommand,
134
+ host,
135
+ once = false,
136
+ debounceMs = 200,
137
+ timeoutMs = 4000,
138
+ io = console,
139
+ fetchImpl = fetch,
140
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
141
+ } = options;
142
+
143
+ const manifestPath = path.join(dir, 'package.json');
144
+ if (fs.existsSync(manifestPath) === false) {
145
+ io.error(`dsh-plugin dev: ${dir} has no package.json`);
146
+ return 1;
147
+ }
148
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
149
+ const source = entryOption === undefined ? resolveEntry(dir, manifest) : path.resolve(dir, entryOption);
150
+ if (source === null) {
151
+ io.error('dsh-plugin dev: no client entry found (dsh.client.entry, client.js, lib/client.js)');
152
+ return 1;
153
+ }
154
+ const destDir = resolveDest(manifest.name, { dest: destOption });
155
+ if (destDir === null) {
156
+ io.error(`dsh-plugin dev: ${manifest.name} is not installed in any profile; pass --dest`);
157
+ return 1;
158
+ }
159
+ const relative = path.relative(dir, source);
160
+ const target = path.join(destDir, relative);
161
+ io.log(`${manifest.name}: ${source} -> ${target}`);
162
+
163
+ const sync = async (reason) => {
164
+ if (buildCommand !== undefined) {
165
+ const built = build({ command: buildCommand, cwd: dir, io });
166
+ if (built.ok === false) {
167
+ io.error(`build failed (exit ${built.status}); nothing copied`);
168
+ return { ok: false };
169
+ }
170
+ }
171
+ let copied = null;
172
+ if (typeof host !== 'string' || host.length === 0) {
173
+ // Without the app's URL there is nothing to confirm against, and a
174
+ // guessed port would report "not confirmed" for a reload that happened.
175
+ copied = copyEntry({ from: source, to: target });
176
+ io.log(`${reason}: copied ${copied.bytes} bytes at ${new Date(copied.mtimeMs).toLocaleTimeString()}`);
177
+ io.log('pass --host <the URL the app printed> to confirm the reload happened');
178
+ return { ok: true, verified: { ok: false, reason: 'no host given' } };
179
+ }
180
+ const verified = await verifyReload({
181
+ host,
182
+ id: manifest.name,
183
+ timeoutMs,
184
+ fetchImpl,
185
+ action: () => {
186
+ copied = copyEntry({ from: source, to: target });
187
+ io.log(`${reason}: copied ${copied.bytes} bytes at ${new Date(copied.mtimeMs).toLocaleTimeString()}`);
188
+ },
189
+ });
190
+ if (copied === null) {
191
+ // The stream never opened, so the copy still has to happen — otherwise a
192
+ // stopped app would silently mean "nothing was updated".
193
+ copied = copyEntry({ from: source, to: target });
194
+ io.log(`${reason}: copied ${copied.bytes} bytes (no stream to confirm with)`);
195
+ }
196
+ if (verified.ok === true) io.log(`reloaded ${manifest.name} (rev ${verified.rev})`);
197
+ else io.log(`copied, but not confirmed: ${verified.reason}`);
198
+ return { ok: true, verified };
199
+ };
200
+
201
+ if (once === true) {
202
+ const result = await sync('once');
203
+ return result.ok === true ? 0 : 1;
204
+ }
205
+
206
+ let stopping = false;
207
+ const stop = () => {
208
+ stopping = true;
209
+ };
210
+ process.once('SIGINT', stop);
211
+ const close = watchPlugin({ dir, debounceMs, io, onChange: (file) => { void sync(file); } });
212
+ await sync('initial');
213
+ while (stopping === false) await sleep(200);
214
+ close();
215
+ return 0;
216
+ }
@@ -184,7 +184,14 @@ export async function mountClient(options) {
184
184
  },
185
185
  writeSnapshot: (dir, name) => write(dir, name, root.innerHTML),
186
186
  unmount: async () => {
187
- await act(async () => clientRoot.unmount());
187
+ await act(async () => {
188
+ clientRoot.unmount();
189
+ // React schedules follow-up work, and a polling panel schedules timers.
190
+ // One macrotask lets that land while the DOM is still alive; closing the
191
+ // window first turns it into "ReferenceError: window is not defined"
192
+ // after the test has already passed.
193
+ await new Promise((resolve) => setTimeout(resolve, 0));
194
+ });
188
195
  globalThis.fetch = previousFetch;
189
196
  restoreGlobals();
190
197
  // A panel polls; jsdom's timers and rAF would keep the test process alive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.2.2",
3
+ "version": "0.3.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": {