dsh-plugin-toolkit 0.5.0 → 0.7.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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ Documentation, restructured around what you are trying to do rather than what the tool contains.
6
+
7
+ - README opens with **Three paths** — write a plugin, develop a panel, ship a version — and each path shows the commands it needs.
8
+ - `--help` gives every command one example line that can be pasted as-is, instead of syntax with placeholders.
9
+ - The stale `Roadmap` (which still described `dev` as unbuilt, two releases after it shipped) is replaced by **What is not here yet**: no `npm publish`, no check against a running app, `dev --host` not covered by CI, no Windows test of the watcher.
10
+ - `test/help.test.mjs` keeps all three honest: every command needs an example without placeholders, the three paths must show each command, and the README may not call a shipped feature unbuilt.
11
+
12
+ ## 0.6.3
13
+
14
+ - `git` and `gh` are `.exe` on Windows, not `.cmd`; 0.6.2 renamed them too and `git.cmd` does not exist. Only the npm family is a shim now.
15
+
16
+ ## 0.6.2
17
+
18
+ - 0.6.1 named the Windows shim but spawned `npm.cmd` directly, which Node 18.20/20.12 reject with `EINVAL` (the fix for CVE-2024-27980). `lib/exec.mjs` now runs through the shell on Windows and quotes each argument; elsewhere nothing changes.
19
+
20
+ ## 0.6.1
21
+
22
+ - `npm`, `git` and `gh` are `.cmd` shims on Windows; `execFileSync('npm', …)` fails there with `spawnSync npm ENOENT`. One helper (`lib/exec.mjs`) now maps the name, instead of a `shell: true` at every call site. Caught by the Windows CI job that 0.6.0 added tests to.
23
+
24
+ ## 0.6.0
25
+
26
+ `dsh-plugin verify` — the check that reads the tarball instead of the working copy.
27
+
28
+ - Packs, installs that tarball into a throwaway directory, and mounts the bundle **from the installed copy**, so "works in the repo, broken after `npm i`" is caught before publishing.
29
+ - Fails when the tarball omits the client bundle (found by convention, so it is not in the `files` check doctor runs) or anything else `package.json` points at.
30
+ - `--no-mount` stops after packing, for plugins whose panel needs a host to be meaningful.
31
+
3
32
  ## 0.5.0
4
33
 
5
34
  `dsh-plugin init` — scaffolding that already passes the checks people hit after their first publish.
package/README.md CHANGED
@@ -2,19 +2,48 @@
2
2
 
3
3
  ![dsh-plugin-toolkit](docs/card.png)
4
4
 
5
- A preflight and release tool for [DeepSeek Harness](https://github.com/deepseek-ai/dsh) plugins, plus the pieces of the publishing ritual that kept going wrong by hand.
5
+ Everything a [DeepSeek Harness](https://github.com/deepseek-ai/dsh) plugin needs between `init` and a version the market index accepts: scaffolding, a test harness for the client bundle, a way to see a change without restarting the app, and a release that does not depend on remembering fourteen steps.
6
6
 
7
+ ```sh
8
+ npx dsh-plugin-toolkit@0.7.0 init ./my-panel --name my-panel # a plugin that already passes the checks
9
+ npx dsh-plugin-toolkit@0.7.0 verify . # pack it, install the tarball, mount the panel
10
+ npx dsh-plugin-toolkit@0.7.0 release --pr --merge # tag, wait for npm, update the index, open and merge the PR
7
11
  ```
8
- npx dsh-plugin doctor . # can this plugin be loaded and installed?
9
- npx dsh-plugin index # does every market-index version state a host range?
10
- npx dsh-plugin release --dry-run # what would shipping this version do?
12
+
13
+ ## Three paths
14
+
15
+ | You are… | Start with | Then |
16
+ | --- | --- | --- |
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 |
19
+ | shipping a version | [`verify`](#3-ship-a-version) | `release`, `index` |
20
+
21
+ ### 1. Write a plugin
22
+
23
+ [`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.
24
+
25
+ ```sh
26
+ npx dsh-plugin-toolkit@0.7.0 init ./my-panel --name my-panel
27
+ cd my-panel && npm install && npm test && npx dsh-plugin-toolkit@0.7.0 doctor .
11
28
  ```
12
29
 
13
- ## Requirements
30
+ ### 2. Develop a panel
14
31
 
15
- - Node.js 18 or newer (`node --fetch` is used to read the registry; no dependencies)
16
- - Git and `gh` on `PATH` for the steps that touch GitHub
17
- - DeepSeek Harness `>=0.2.0` — the version whose market index carries `engines.dsh`, which is what makes the range check meaningful
32
+ 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`](#test-a-panel-without-a-browser) supplies the missing half, and [`dev`](#edit-a-plugin-that-is-already-installed) pushes a rebuilt bundle into the running app.
33
+
34
+ ```sh
35
+ npx dsh-plugin-toolkit@0.7.0 mount ./client.js # what a user would see, without the app
36
+ npx dsh-plugin-toolkit@0.7.0 dev . --host http://127.0.0.1:54133
37
+ ```
38
+
39
+ ### 3. Ship a version
40
+
41
+ `verify` first (it is the only check that reads the tarball), then `release`, which tags, waits for the registry, writes the index entry with the publish time the registry reports, and — with `--pr --merge` — opens and merges the pull request.
42
+
43
+ ```sh
44
+ npx dsh-plugin-toolkit@0.7.0 verify .
45
+ npx dsh-plugin-toolkit@0.7.0 release . --pr --merge
46
+ ```
18
47
 
19
48
  ## Why this exists
20
49
 
@@ -24,11 +53,17 @@ Releasing one plugin fifteen times taught me three things the ecosystem's toolin
24
53
  2. **The market index is a second release, done by hand.** Each version needs an entry carrying `publishTimestamp` from the registry and `engines.dsh` from the manifest. Forget the range and the index validator rejects the PR with `Engines mismatch` — after the tag is already public.
25
54
  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
55
 
56
+ ## Requirements
57
+
58
+ - Node.js 18 or newer (the registry is read with `fetch`; no runtime dependencies)
59
+ - Git and `gh` on `PATH` for the steps that touch GitHub
60
+ - DeepSeek Harness `>=0.2.0` — the version whose market index carries `engines.dsh`, which is what makes the range check meaningful
61
+ - For `testkit`: `jsdom`, `react` and `react-dom` as dev dependencies (`npm install --save-dev jsdom react react-dom`)
27
62
 
28
63
  ## Start from a plugin that already passes
29
64
 
30
65
  ```sh
31
- $ npx dsh-plugin-toolkit@0.5.0 init ./my-panel --name my-panel --title "My panel"
66
+ $ npx dsh-plugin-toolkit@0.7.0 init ./my-panel --name my-panel --title "My panel"
32
67
  created 8 file(s) in /Users/you/my-panel
33
68
  .github/workflows/ci.yml
34
69
  .gitignore
@@ -198,6 +233,24 @@ The same checks run as a GitHub Action, so a tag cannot go out on a plugin that
198
233
 
199
234
  Exit code is `0` when the plugin can be installed and `1` when it cannot, so it drops into CI unchanged.
200
235
 
236
+ ## Prove the tarball works, not just the repository
237
+
238
+ `doctor` reads your working copy. Users install the tarball, and a `files` list can ship everything except the panel — `doctor` will not notice.
239
+
240
+ ```sh
241
+ $ npx dsh-plugin-toolkit@0.7.0 verify .
242
+ ok packed 5 file(s), installed the tarball, mounted the panel
243
+ My panel This panel came from dsh-plugin init. Clicks: 0
244
+ ```
245
+
246
+ It packs, installs that tarball into a throwaway directory, then mounts the bundle **from the installed copy**. When the `files` list drops the panel:
247
+
248
+ ```sh
249
+ $ npx dsh-plugin-toolkit@0.7.0 verify .
250
+ dsh-plugin verify: stopped at pack
251
+ ✗ pack: the tarball does not contain client.js, so the installed plugin has no panel
252
+ ```
253
+
201
254
  ## What `release` does
202
255
 
203
256
  `release` runs the sequence in the order that fails cheapest first:
@@ -231,17 +284,14 @@ Without `--pr` it prints the `git add` / `gh pr create` commands instead of runn
231
284
  | 1 | A check failed, or the release stopped (the failing stage is named) |
232
285
  | 2 | Unknown command |
233
286
 
234
- ## Looking at it without a Harness
235
-
236
- The fixtures under `test/fixtures/` are three tiny plugins — one good, one missing its host range, one whose README contradicts its manifest — and the tests run every check against them:
237
-
238
- ```
239
- npm test
240
- ```
287
+ ## What is not here yet
241
288
 
242
- ## Roadmap
289
+ Honest list, so nobody plans around a feature that does not exist:
243
290
 
244
- - `dev`: watch a client bundle and push it to a running app, instead of restarting the app for every change. Needs one probe first: whether the host exposes a channel that accepts a pushed bundle.
291
+ - **No `npm publish`.** `release` pushes the tag and waits for the registry, so publishing stays in the workflow where your token and provenance live. It never holds your npm credentials.
292
+ - **No check against a running app.** `verify` mounts the installed bundle in Node with a stubbed host. It cannot prove the panel appears in a real sidebar — only that the code the tarball ships renders.
293
+ - **`dev --host` is not covered by CI.** The unit tests use a synthetic event stream; the live path is exercised by hand against a running app, on macOS only.
294
+ - **No Windows testing of the `dev` file watcher.** The Windows CI job runs the whole suite, but the watcher is only asserted against a temp directory, not a real profile.
245
295
 
246
296
  ## License
247
297
 
package/lib/cli.mjs CHANGED
@@ -7,17 +7,32 @@ import path from 'node:path';
7
7
 
8
8
  import { doctor } from './doctor.mjs';
9
9
  import { init } from './init.mjs';
10
+ import { verify } from './verify.mjs';
10
11
  import { plan, release } from './release.mjs';
11
12
  import { auditVersions, readIndex } from './index-file.mjs';
12
13
 
13
14
  export const HELP = `dsh-plugin — preflight and release DeepSeek Harness plugins
14
15
 
15
- dsh-plugin doctor [dir] check the manifest, README and bundle
16
- dsh-plugin release [dir] [flags] tag, wait for npm, update the market index, open the PR
17
- dsh-plugin index [dir] audit every version in market/index.json
18
- dsh-plugin mount <client.js> render a client bundle in Node and print what a user would see
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
16
+ dsh-plugin init <dir> --name <pkg> scaffold a plugin that already passes doctor
17
+ dsh-plugin init ./my-panel --name my-panel
18
+
19
+ dsh-plugin doctor [dir] check the manifest, README and bundle
20
+ dsh-plugin doctor .
21
+
22
+ dsh-plugin verify [dir] pack it, install the tarball, mount the installed panel
23
+ dsh-plugin verify .
24
+
25
+ dsh-plugin mount <client.js> render a client bundle in Node and print what a user would see
26
+ dsh-plugin mount ./client.js --html
27
+
28
+ dsh-plugin dev [dir] [flags] copy a rebuilt bundle into the installed plugin and confirm the reload
29
+ dsh-plugin dev . --host http://127.0.0.1:54133
30
+
31
+ dsh-plugin release [dir] [flags] tag, wait for npm, update the market index, open the PR
32
+ dsh-plugin release . --pr --merge
33
+
34
+ dsh-plugin index [dir] audit every version in market/index.json
35
+ dsh-plugin index market/index.json
21
36
 
22
37
  Flags
23
38
  --dry-run show the steps without touching anything
@@ -46,6 +61,7 @@ function flags(argv) {
46
61
  else if (token === '--name') parsed.name = argv[++index];
47
62
  else if (token === '--title') parsed.title = argv[++index];
48
63
  else if (token === '--force') parsed.force = true;
64
+ else if (token === '--no-mount') parsed.noMount = true;
49
65
  else if (token === '--dest') parsed.dest = argv[++index];
50
66
  else if (token === '--entry') parsed.entry = argv[++index];
51
67
  else if (token === '--build') parsed.build = argv[++index];
@@ -89,6 +105,18 @@ export async function run(argv, io = console) {
89
105
  return 1;
90
106
  }
91
107
 
108
+ if (command === 'verify') {
109
+ const result = await verify({ dir, mount: options.noMount !== true });
110
+ if (result.ok === true) {
111
+ io.log(`ok packed ${result.files.length} file(s), installed the tarball, mounted the panel`);
112
+ if (typeof result.text === 'string') io.log(` ${result.text}`);
113
+ return 0;
114
+ }
115
+ io.error(`dsh-plugin verify: stopped at ${result.stage}`);
116
+ printFailures(result.failures, io);
117
+ return 1;
118
+ }
119
+
92
120
  if (command === 'init') {
93
121
  if (options.name === undefined) {
94
122
  io.error('dsh-plugin init: --name <package-name> is required');
package/lib/exec.mjs ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Only the npm family needs care on Windows:
3
+ *
4
+ * - `npm`, `npx`, `yarn` and `pnpm` are `.cmd` shims, so the bare name is
5
+ * `ENOENT`;
6
+ * - and since Node 18.20/20.12 a `.cmd` cannot be spawned without a shell
7
+ * (`EINVAL`, the fix for CVE-2024-27980).
8
+ *
9
+ * `git` and `gh` are real executables (`git.exe`, `gh.exe`) and must *not* get
10
+ * a `.cmd` suffix — `git.cmd` does not exist.
11
+ */
12
+ import { execFileSync } from 'node:child_process';
13
+
14
+ const SHIMS = new Set(['npm', 'npx', 'yarn', 'pnpm']);
15
+
16
+ export function command(name) {
17
+ return process.platform === 'win32' && SHIMS.has(name) ? `${name}.cmd` : name;
18
+ }
19
+
20
+ function quote(argument) {
21
+ const text = String(argument);
22
+ return /[\s"]/u.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
23
+ }
24
+
25
+ export function run(name, args, options = {}) {
26
+ if (process.platform === 'win32' && SHIMS.has(name)) {
27
+ return execFileSync(command(name), args.map(quote), { ...options, shell: true });
28
+ }
29
+ return execFileSync(name, args, options);
30
+ }
package/lib/pr.mjs CHANGED
@@ -5,18 +5,18 @@
5
5
  * Every side effect goes through an injected runner, so the sequence can be
6
6
  * asserted without a network, a repository, or a `gh` login.
7
7
  */
8
- import { execFileSync } from 'node:child_process';
8
+ import { run } from './exec.mjs';
9
9
 
10
10
  // stderr is piped, not inherited: git's "fatal: ambiguous argument" for a tag
11
11
  // that does not exist yet and gh's "no checks reported" both arrive there, and
12
12
  // both are expected misses that must not read as failures — but they still have
13
13
  // to be readable by the caller.
14
14
  export function defaultGit(dir) {
15
- return (args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
15
+ return (args) => run('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
16
16
  }
17
17
 
18
18
  export function defaultGh(dir) {
19
- return (args) => execFileSync('gh', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
19
+ return (args) => run('gh', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
20
20
  }
21
21
 
22
22
  export function branchName(version, prefix = 'market/') {
package/lib/release.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * registry, then touch the index — the index entry carries the publish time the
6
6
  * registry reported, so writing it earlier would mean guessing.
7
7
  */
8
- import { execFileSync } from 'node:child_process';
8
+ import { run } from './exec.mjs';
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
 
@@ -18,7 +18,7 @@ function git(dir, args) {
18
18
  // stderr is dropped on purpose: `rev-parse` on a tag that does not exist yet is
19
19
  // an expected miss, and git prints "fatal: ambiguous argument" for it, which
20
20
  // reads like a failure in the middle of a successful release.
21
- return execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
21
+ return run('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
22
22
  }
23
23
 
24
24
  export function tagName(version, prefix = 'plugin-v') {
package/lib/verify.mjs ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `doctor` reads the repository. Users install the tarball.
3
+ *
4
+ * `verify` closes that gap: pack it, install the tarball into a throwaway
5
+ * directory, then mount the bundle *from the installed copy*. The failures this
6
+ * catches — a `files` list that omits the entry, a patch file that is not
7
+ * shipped, a bundle that only works next to its source — all pass `doctor`.
8
+ */
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ import { doctor, declaredPaths } from './doctor.mjs';
14
+ import { resolveEntry } from './dev.mjs';
15
+ import { run } from './exec.mjs';
16
+
17
+ function packJson(dir) {
18
+ const output = run('npm', ['pack', '--json', '--dry-run'], {
19
+ cwd: dir,
20
+ encoding: 'utf8',
21
+ stdio: ['ignore', 'pipe', 'pipe'],
22
+ });
23
+ const [document] = JSON.parse(output);
24
+ return { files: (document.files ?? []).map((entry) => entry.path), filename: document.filename, size: document.size };
25
+ }
26
+
27
+ function packTarball(dir, destination) {
28
+ const output = run('npm', ['pack', '--pack-destination', destination, '--json'], {
29
+ cwd: dir,
30
+ encoding: 'utf8',
31
+ stdio: ['ignore', 'pipe', 'pipe'],
32
+ });
33
+ const [document] = JSON.parse(output);
34
+ return path.join(destination, document.filename);
35
+ }
36
+
37
+ export async function verify({ dir, mount = true }) {
38
+ const failures = [];
39
+ const report = doctor(dir);
40
+ for (const failure of report.failures) failures.push(failure);
41
+ if (failures.length > 0) return { ok: false, stage: 'doctor', failures, files: [] };
42
+
43
+ let packed;
44
+ try {
45
+ packed = packJson(dir);
46
+ } catch (error) {
47
+ return {
48
+ ok: false,
49
+ stage: 'pack',
50
+ failures: [{ check: 'pack', message: `npm pack failed: ${String(error.stderr ?? error.message).trim().split('\n').slice(-1)[0]}` }],
51
+ files: [],
52
+ };
53
+ }
54
+
55
+ // Everything package.json points at has to be inside the tarball, or the
56
+ // installed plugin is missing its own code.
57
+ const shipped = new Set(packed.files);
58
+ for (const target of declaredPaths(report.manifest)) {
59
+ if (shipped.has(target) === false) {
60
+ failures.push({
61
+ check: 'pack',
62
+ message: `the tarball does not contain ${target}, so the installed plugin would be missing it`,
63
+ });
64
+ }
65
+ }
66
+ // The client bundle is usually found by convention (`client.js`), so it is
67
+ // not in `declaredPaths` — but it still has to be in the tarball.
68
+ const repoEntry = resolveEntry(dir, report.manifest);
69
+ if (repoEntry !== null) {
70
+ const relative = path.relative(dir, repoEntry).split(path.sep).join('/');
71
+ if (shipped.has(relative) === false) {
72
+ failures.push({
73
+ check: 'pack',
74
+ message: `the tarball does not contain ${relative}, so the installed plugin has no panel`,
75
+ });
76
+ }
77
+ }
78
+
79
+ if (failures.length > 0) return { ok: false, stage: 'pack', failures, files: packed.files };
80
+
81
+ if (mount === false) return { ok: true, stage: 'pack', failures: [], files: packed.files, size: packed.size };
82
+
83
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-plugin-verify-'));
84
+ let installed;
85
+ try {
86
+ const tarball = packTarball(dir, scratch);
87
+ run('npm', ['install', tarball, '--prefix', path.join(scratch, 'install'), '--no-save', '--ignore-scripts', '--no-audit', '--no-fund'], {
88
+ cwd: scratch,
89
+ encoding: 'utf8',
90
+ stdio: ['ignore', 'pipe', 'pipe'],
91
+ });
92
+ installed = path.join(scratch, 'install', 'node_modules', report.manifest.name);
93
+ } catch (error) {
94
+ return {
95
+ ok: false,
96
+ stage: 'install',
97
+ failures: [{ check: 'install', message: `installing the tarball failed: ${String(error.stderr ?? error.message).trim().split('\n').slice(-1)[0]}` }],
98
+ files: packed.files,
99
+ };
100
+ }
101
+
102
+ if (fs.existsSync(installed) === false) {
103
+ return {
104
+ ok: false,
105
+ stage: 'install',
106
+ failures: [{ check: 'install', message: `the tarball installed no package at ${report.manifest.name}` }],
107
+ files: packed.files,
108
+ };
109
+ }
110
+
111
+ // Same convention the host uses: dsh.client.entry, then client.js.
112
+ const entryFile = resolveEntry(installed, report.manifest);
113
+ if (entryFile !== null) {
114
+ if (fs.existsSync(entryFile) === false) {
115
+ return {
116
+ ok: false,
117
+ stage: 'install',
118
+ failures: [{ check: 'install', message: `the installed copy has no ${path.basename(entryFile)}` }],
119
+ files: packed.files,
120
+ };
121
+ }
122
+ try {
123
+ const { mountClient } = await import('./testkit/index.mjs');
124
+ const panel = await mountClient({ file: entryFile });
125
+ const text = panel.text();
126
+ await panel.unmount();
127
+ return { ok: true, stage: 'mount', failures: [], files: packed.files, size: packed.size, installed, text };
128
+ } catch (error) {
129
+ return {
130
+ ok: false,
131
+ stage: 'mount',
132
+ failures: [{ check: 'mount', message: `the installed bundle does not mount: ${error.message}` }],
133
+ files: packed.files,
134
+ installed,
135
+ };
136
+ }
137
+ }
138
+
139
+ return { ok: true, stage: 'install', failures: [], files: packed.files, size: packed.size, installed };
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.5.0",
3
+ "version": "0.7.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": {