dsh-plugin-toolkit 0.3.5 → 0.4.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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1
4
+
5
+ - `examples/`: four runnable files (`mount-a-bundle`, `route-stubs`, `baseline`, `aria`), each showing one thing testkit does.
6
+ - `test/examples.test.mjs` runs every example and asserts it prints exactly what the README shows, and that the README names each one. An example that does not run teaches a shape that no longer exists.
7
+ - `fixtures/fetching-bundle/`: a bundle that calls its host on mount, so the route-stub example asserts something real instead of printing zeros.
8
+
9
+ ## 0.4.0
10
+
11
+ `release` can now finish the job instead of printing the next command.
12
+
13
+ - `--pr` commits the index on `market/<version>`, pushes it, and opens the pull request with `gh`.
14
+ - `--merge` (with `--pr`) waits for `gh pr checks` to settle and squash-merges.
15
+ - A repository with no CI is treated as settled rather than waited on; `gh` prints "no checks reported" on **stderr** and exits 1, which is why stderr is now piped instead of dropped.
16
+ - Re-running a release after a failure no longer throws: the same version with the same publish time is a no-op, and a different timestamp stops with both values in the message.
17
+ - A package the registry has never heard of fails immediately (`the registry has no package named …`) instead of polling for eight minutes.
18
+ - `release` forwards `fetch`, so a caller can answer instead of npm.
19
+
20
+ Verified end to end against a throwaway package: tag → publish → registry wait → index write → PR #3 → checks → merged.
21
+
3
22
  ## 0.3.5
4
23
 
5
24
  Four bugs found by running `release` for real against a throwaway package, instead of only `--dry-run`:
package/README.md CHANGED
@@ -78,6 +78,27 @@ assert.match(panel.must('[role="status"]').textContent, /Score moved 71 → 77/)
78
78
 
79
79
  `must()` throws with the rendered markup when the selector misses, which is the difference between "the assertion failed" and "here is what the user actually got".
80
80
 
81
+ Every example below is a file in this repository, run by CI, and the output shown is the output it prints:
82
+
83
+ | Example | What it shows |
84
+ | --- | --- |
85
+ | `node examples/mount-a-bundle.mjs` | mount a real bundle, read the text, click, read it again |
86
+ | `node examples/route-stubs.mjs` | answer the routes a panel asks for, and assert on the requests |
87
+ | `node examples/baseline.mjs` | a DOM baseline compared on every run |
88
+ | `node examples/aria.mjs` | what assistive tech sees |
89
+
90
+ ```sh
91
+ $ node examples/mount-a-bundle.mjs
92
+ Demo panel Count 0 0
93
+ Demo panel Count 1 1
94
+ ```
95
+
96
+ ```sh
97
+ $ node examples/route-stubs.mjs
98
+ Score 71
99
+ asked for: 1 route(s), undeclared route answers 404
100
+ ```
101
+
81
102
  To look at a bundle by hand:
82
103
 
83
104
  ```sh
@@ -166,10 +187,22 @@ Exit code is `0` when the plugin can be installed and `1` when it cannot, so it
166
187
  2. Refuse to run on a dirty tree, or if the tag already exists.
167
188
  3. Create and push the tag, which starts the plugin's release workflow.
168
189
  4. Poll the registry until it actually serves that version, reporting each wait.
169
- 5. Write the index entry with the publish time **the registry reports**, not the local clock.
170
- 6. Print the exact `git add` / `gh pr create` commands for the index change.
190
+ 5. Write the index entry with the publish time **the registry reports**, not the local clock. It waits for `time[version]` specifically: the abbreviated registry document has no `time` field, and an entry with a null timestamp is rejected by the market checker.
191
+ 6. With `--pr`: commit that entry on `market/<version>`, push it, and open the pull request. With `--merge` as well: wait for `gh pr checks` to settle, then squash-merge.
192
+
193
+ ```sh
194
+ npx dsh-plugin release . --pr --merge
195
+ ```
196
+
197
+ ```
198
+ tagged plugin-v0.4.0, npm served my-plugin@0.4.0 after 82s
199
+ updated market/index.json on market/0.4.0
200
+ opened https://github.com/me/my-plugin/pull/3
201
+ checks settled after 1s (1 look(s))
202
+ merged
203
+ ```
171
204
 
172
- Steps that touch your repository are printed, never guessed at. `--dry-run` prints the whole sequence with the commands it would run.
205
+ Without `--pr` it prints the `git add` / `gh pr create` commands instead of running them; `--dry-run` prints the whole sequence before anything happens. A re-run after a failure is safe: the same version with the same publish time is a no-op.
173
206
 
174
207
  ## Exit codes
175
208
 
package/lib/cli.mjs CHANGED
@@ -12,7 +12,7 @@ import { auditVersions, readIndex } from './index-file.mjs';
12
12
  export const HELP = `dsh-plugin — preflight and release DeepSeek Harness plugins
13
13
 
14
14
  dsh-plugin doctor [dir] check the manifest, README and bundle
15
- dsh-plugin release [dir] [flags] tag, wait for npm, update the market index
15
+ dsh-plugin release [dir] [flags] tag, wait for npm, update the market index, open the PR
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
18
  dsh-plugin dev [dir] [flags] copy a rebuilt bundle into the installed plugin and confirm the reload
@@ -22,6 +22,9 @@ Flags
22
22
  --index <path> index file, default market/index.json
23
23
  --tag-prefix <prefix> default plugin-v
24
24
  --budget <seconds> how long to wait for npm, default 480
25
+ --pr commit the index on a branch, push it, open the pull request
26
+ --merge with --pr: wait for checks, then squash-merge
27
+ --base <branch> base branch for the pull request, default main
25
28
  `;
26
29
 
27
30
  function flags(argv) {
@@ -42,6 +45,9 @@ function flags(argv) {
42
45
  else if (token === '--once') parsed.once = true;
43
46
  else if (token === '--debounce') parsed.debounce = Number(argv[++index]);
44
47
  else if (token === '--timeout') parsed.timeout = Number(argv[++index]);
48
+ else if (token === '--pr') parsed.pr = true;
49
+ else if (token === '--merge') parsed.merge = true;
50
+ else if (token === '--base') parsed.base = argv[++index];
45
51
  else if (token.startsWith('--') === true) throw new Error(`unknown flag ${token}`);
46
52
  else parsed.positional.push(token);
47
53
  }
@@ -152,6 +158,10 @@ export async function run(argv, io = console) {
152
158
  indexFile: options.index,
153
159
  tagPrefix: options.tagPrefix,
154
160
  budgetMs: options.budget === undefined ? undefined : options.budget * 1000,
161
+ pr: options.pr === true,
162
+ merge: options.merge === true,
163
+ base: options.base,
164
+ onStep: (name) => io.log(` → ${name}`),
155
165
  onWait: ({ attempts, waitedMs }) => {
156
166
  io.log(` … ${name}@${version} not on the registry yet (${attempts} checks, ${Math.round(waitedMs / 1000)}s)`);
157
167
  },
@@ -164,12 +174,30 @@ export async function run(argv, io = console) {
164
174
  }
165
175
 
166
176
  io.log(`tagged ${result.tag}, npm served ${name}@${version} after ${Math.round(result.waitedMs / 1000)}s`);
167
- if (result.indexUpdate !== null) {
177
+ if (result.indexUpdate !== null && result.indexUpdate.unchanged === true) {
178
+ io.log(`${result.indexUpdate.file} already carried ${version}; nothing to commit`);
179
+ } else if (result.indexUpdate !== null && result.pullRequest === undefined) {
168
180
  io.log(`updated ${result.indexUpdate.file} — commit it and open the PR:`);
169
181
  io.log(` git add ${result.indexUpdate.file} && git commit -m "market: ${version}" && gh pr create --fill`);
182
+ } else if (result.indexUpdate !== null) {
183
+ io.log(`updated ${result.indexUpdate.file} on ${result.pullRequest.branch}`);
170
184
  } else {
171
185
  io.log('no market/index.json here, so only the tag and the publish happened');
172
186
  }
187
+
188
+ if (result.pullRequest !== undefined) {
189
+ if (result.pullRequest.ok !== true) {
190
+ io.error(`the pull request was not opened (${result.pullRequest.stage})`);
191
+ return 1;
192
+ }
193
+ io.log(`opened ${result.pullRequest.url}`);
194
+ }
195
+ if (result.checks !== undefined) {
196
+ io.log(`checks settled after ${Math.round(result.checks.waitedMs / 1000)}s (${result.checks.attempts} look(s))`);
197
+ }
198
+ if (result.merged !== undefined && result.merged.ok === true) {
199
+ io.log('merged');
200
+ }
173
201
  return 0;
174
202
  }
175
203
 
@@ -34,8 +34,15 @@ export function addVersion(index, name, entry) {
34
34
  throw new Error(`the index has no plugin called "${name}"`);
35
35
  }
36
36
  const versions = Array.isArray(plugin.versions) ? plugin.versions : [];
37
- if (versions.some((existing) => existing.version === entry.version)) {
38
- throw new Error(`${name}@${entry.version} is already in the index`);
37
+ const existing = versions.find((candidate) => candidate.version === entry.version);
38
+ if (existing !== undefined) {
39
+ // Re-running a release after a failure is normal, so the same version with
40
+ // the same publish time is a no-op; a different timestamp means the index
41
+ // and the registry disagree, and that is worth stopping for.
42
+ if (existing.publishTimestamp === entry.publishTimestamp) return index;
43
+ throw new Error(
44
+ `${name}@${entry.version} is already in the index with publishTimestamp ${existing.publishTimestamp}, not ${entry.publishTimestamp}`,
45
+ );
39
46
  }
40
47
  return {
41
48
  ...index,
package/lib/pr.mjs ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The last mile of a release: the index change is one line, but it still needs a
3
+ * branch, a pull request, a green CI, and a merge.
4
+ *
5
+ * Every side effect goes through an injected runner, so the sequence can be
6
+ * asserted without a network, a repository, or a `gh` login.
7
+ */
8
+ import { execFileSync } from 'node:child_process';
9
+
10
+ // stderr is piped, not inherited: git's "fatal: ambiguous argument" for a tag
11
+ // that does not exist yet and gh's "no checks reported" both arrive there, and
12
+ // both are expected misses that must not read as failures — but they still have
13
+ // to be readable by the caller.
14
+ export function defaultGit(dir) {
15
+ return (args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
16
+ }
17
+
18
+ export function defaultGh(dir) {
19
+ return (args) => execFileSync('gh', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
20
+ }
21
+
22
+ export function branchName(version, prefix = 'market/') {
23
+ return `${prefix}${version}`;
24
+ }
25
+
26
+ /**
27
+ * Commit the index on its own branch, push it, and open the pull request.
28
+ * Returns `{ok, branch, url}` or `{ok:false, stage, reason}`.
29
+ */
30
+ export async function openIndexPr(options) {
31
+ const {
32
+ dir,
33
+ indexFile,
34
+ version,
35
+ base = 'main',
36
+ branch = branchName(version),
37
+ git = defaultGit(dir),
38
+ gh = defaultGh(dir),
39
+ title,
40
+ body,
41
+ onStep,
42
+ } = options;
43
+ const step = (name) => {
44
+ if (typeof onStep === 'function') onStep(name);
45
+ };
46
+
47
+ try {
48
+ step('branch');
49
+ git(['checkout', '-b', branch]);
50
+ } catch {
51
+ // An existing branch means a previous run got this far; reuse it rather than
52
+ // failing on the one step that is safe to repeat.
53
+ try {
54
+ git(['checkout', branch]);
55
+ } catch (error) {
56
+ return { ok: false, stage: 'branch', reason: `could not create or check out ${branch}: ${error.message}` };
57
+ }
58
+ }
59
+
60
+ try {
61
+ step('commit');
62
+ git(['add', indexFile]);
63
+ git(['commit', '-m', `market: ${version}`]);
64
+ } catch (error) {
65
+ return { ok: false, stage: 'commit', reason: error.message };
66
+ }
67
+
68
+ try {
69
+ step('push');
70
+ git(['push', '-u', 'origin', branch]);
71
+ } catch (error) {
72
+ return { ok: false, stage: 'push', reason: error.message };
73
+ }
74
+
75
+ try {
76
+ step('pr');
77
+ const url = gh([
78
+ 'pr',
79
+ 'create',
80
+ '--base',
81
+ base,
82
+ '--head',
83
+ branch,
84
+ '--title',
85
+ title ?? `market: ${version}`,
86
+ '--body',
87
+ body ?? `Adds ${version} to the market index, with the publish time the registry reported.`,
88
+ ]);
89
+ return { ok: true, branch, url: url.split('\n').pop() ?? url };
90
+ } catch (error) {
91
+ return { ok: false, stage: 'pr', reason: error.message };
92
+ }
93
+ }
94
+
95
+ /** Poll `gh pr checks` until every check has settled. */
96
+ export async function waitForChecks(options) {
97
+ const {
98
+ url,
99
+ gh,
100
+ budgetMs = 30 * 60 * 1000,
101
+ intervalMs = 20 * 1000,
102
+ now = Date.now,
103
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
104
+ onWait,
105
+ } = options;
106
+ const started = now();
107
+ let attempts = 0;
108
+ for (;;) {
109
+ attempts += 1;
110
+ let output = '';
111
+ try {
112
+ output = gh(['pr', 'checks', url, '--json', 'state', '--jq', '.[].state']);
113
+ } catch (error) {
114
+ // `gh pr checks` exits non-zero while checks are still pending, and also
115
+ // when the repository has no CI at all — the second case is not a wait.
116
+ output = String(error.stdout ?? '');
117
+ // `gh` writes "no checks reported on the 'x' branch" to stdout and exits 1.
118
+ const message = `${error.message ?? ''}${error.stdout ?? ''}${error.stderr ?? ''}`;
119
+ if (/no checks reported/i.test(message) === true) {
120
+ return { ok: true, attempts, waitedMs: now() - started, states: [], noChecks: true };
121
+ }
122
+ }
123
+ const states = output.split('\n').map((line) => line.trim()).filter((line) => line !== '');
124
+ const pending = states.filter((state) => ['PENDING', 'QUEUED', 'IN_PROGRESS', ''].includes(state));
125
+ const failed = states.filter((state) => ['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED'].includes(state));
126
+ if (failed.length > 0) return { ok: false, attempts, waitedMs: now() - started, states, reason: `${failed.length} check(s) failed` };
127
+ if (states.length > 0 && pending.length === 0) return { ok: true, attempts, waitedMs: now() - started, states };
128
+ if (now() - started >= budgetMs) {
129
+ return { ok: false, attempts, waitedMs: now() - started, states, timedOut: true, reason: 'checks did not settle in time' };
130
+ }
131
+ if (typeof onWait === 'function') onWait({ attempts, waitedMs: now() - started, states });
132
+ await sleep(intervalMs);
133
+ }
134
+ }
135
+
136
+ export async function mergePr(options) {
137
+ const { url, gh, method = 'squash' } = options;
138
+ try {
139
+ gh(['pr', 'merge', url, `--${method}`]);
140
+ return { ok: true };
141
+ } catch (error) {
142
+ return { ok: false, reason: error.message };
143
+ }
144
+ }
package/lib/registry.mjs CHANGED
@@ -60,6 +60,11 @@ export async function waitForVersion(name, version, options = {}) {
60
60
  for (;;) {
61
61
  attempts += 1;
62
62
  const document = await packument(name, options);
63
+ if (document === null && options.requirePackage !== false) {
64
+ // A package that does not exist will not appear by waiting: say so now
65
+ // instead of polling for eight minutes.
66
+ return { document, attempts, waitedMs: now() - started, missingPackage: true };
67
+ }
63
68
  if (ready(document) === true) {
64
69
  return { document, attempts, waitedMs: now() - started };
65
70
  }
package/lib/release.mjs CHANGED
@@ -12,34 +12,35 @@ import path from 'node:path';
12
12
  import { doctor } from './doctor.mjs';
13
13
  import { addVersion, buildEntry, readIndex, writeIndex } from './index-file.mjs';
14
14
  import { publishedAt, waitForVersion } from './registry.mjs';
15
+ import { defaultGh, mergePr, openIndexPr, waitForChecks } from './pr.mjs';
15
16
 
16
17
  function git(dir, args) {
17
18
  // stderr is dropped on purpose: `rev-parse` on a tag that does not exist yet is
18
19
  // an expected miss, and git prints "fatal: ambiguous argument" for it, which
19
20
  // reads like a failure in the middle of a successful release.
20
- return execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
21
+ return execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
21
22
  }
22
23
 
23
24
  export function tagName(version, prefix = 'plugin-v') {
24
25
  return `${prefix}${version}`;
25
26
  }
26
27
 
27
- export function tagExists(dir, tag) {
28
+ export function tagExists(dir, tag, run) {
28
29
  try {
29
- git(dir, ['rev-parse', `refs/tags/${tag}`]);
30
+ (run ?? ((args) => git(dir, args)))(['rev-parse', `refs/tags/${tag}`]);
30
31
  return true;
31
32
  } catch {
32
33
  return false;
33
34
  }
34
35
  }
35
36
 
36
- export function workingTreeIsClean(dir) {
37
- return git(dir, ['status', '--porcelain']) === '';
37
+ export function workingTreeIsClean(dir, run) {
38
+ return (run ?? ((args) => git(dir, args)))(['status', '--porcelain']) === '';
38
39
  }
39
40
 
40
41
  /** The paths git would carry into the tag, so the message can name them. */
41
- export function dirtyPaths(dir, limit = 3) {
42
- const status = git(dir, ['status', '--porcelain']);
42
+ export function dirtyPaths(dir, limit = 3, run) {
43
+ const status = (run ?? ((args) => git(dir, args)))(['status', '--porcelain']);
43
44
  if (status === '') return [];
44
45
  const paths = status.split('\n').map((line) => line.slice(3).trim()).filter((line) => line !== '');
45
46
  return paths.slice(0, limit);
@@ -80,6 +81,8 @@ export async function release(options) {
80
81
  onChange,
81
82
  } = options;
82
83
 
84
+ const gitRun = options.git ?? ((args) => git(dir, args));
85
+
83
86
  let report;
84
87
  try {
85
88
  report = doctor(dir);
@@ -96,11 +99,11 @@ export async function release(options) {
96
99
  const version = report.manifest.version;
97
100
  const tag = tagName(version, tagPrefix);
98
101
 
99
- if (tagExists(dir, tag) === true) {
102
+ if (tagExists(dir, tag, gitRun) === true) {
100
103
  return { ok: false, stage: 'tag', failures: [{ check: 'tag', message: `${tag} already exists` }] };
101
104
  }
102
- if (workingTreeIsClean(dir) === false) {
103
- const dirty = dirtyPaths(dir);
105
+ if (workingTreeIsClean(dir, gitRun) === false) {
106
+ const dirty = dirtyPaths(dir, 3, gitRun);
104
107
  const listed = dirty.length > 0 ? ` (${dirty.join(', ')})` : '';
105
108
  return {
106
109
  ok: false,
@@ -109,13 +112,15 @@ export async function release(options) {
109
112
  };
110
113
  }
111
114
 
112
- git(dir, ['tag', tag]);
113
- git(dir, ['push', 'origin', tag]);
115
+ gitRun(['tag', tag]);
116
+ gitRun(['push', 'origin', tag]);
114
117
 
115
118
  const waited = await waitForVersion(name, version, {
116
119
  // Wait for the publish *time* as well: `versions` can appear a moment before
117
120
  // `time`, and an index entry with a null timestamp fails the market checker.
118
121
  requireTime: true,
122
+ // Forwarded so a caller (and the tests) can answer instead of npm.
123
+ fetch: options.fetch,
119
124
  registry,
120
125
  budgetMs,
121
126
  intervalMs,
@@ -123,6 +128,18 @@ export async function release(options) {
123
128
  now,
124
129
  onWait: options.onWait,
125
130
  });
131
+ if (waited.missingPackage === true) {
132
+ return {
133
+ ok: false,
134
+ stage: 'registry',
135
+ failures: [
136
+ {
137
+ check: 'registry',
138
+ message: `the registry has no package named ${name} (tag ${tag} is pushed; publish it, then re-run)`,
139
+ },
140
+ ],
141
+ };
142
+ }
126
143
  if (waited.timedOut === true) {
127
144
  return {
128
145
  ok: false,
@@ -141,14 +158,22 @@ export async function release(options) {
141
158
  const indexPath = path.join(dir, indexFile);
142
159
  let indexUpdate = null;
143
160
  if (fs.existsSync(indexPath) === true) {
144
- const before = readIndex(indexPath);
145
- const after = addVersion(before, name, entry);
146
- writeIndex(indexPath, after);
147
- indexUpdate = { file: indexFile, version, entry };
148
- if (typeof onChange === 'function') onChange(indexUpdate);
161
+ try {
162
+ const before = readIndex(indexPath);
163
+ const after = addVersion(before, name, entry);
164
+ if (after !== before) {
165
+ writeIndex(indexPath, after);
166
+ indexUpdate = { file: indexFile, version, entry };
167
+ if (typeof onChange === 'function') onChange(indexUpdate);
168
+ } else {
169
+ indexUpdate = { file: indexFile, version, entry, unchanged: true };
170
+ }
171
+ } catch (error) {
172
+ return { ok: false, stage: 'index', failures: [{ check: 'index', message: error.message }] };
173
+ }
149
174
  }
150
175
 
151
- return {
176
+ const result = {
152
177
  ok: true,
153
178
  stage: 'index',
154
179
  tag,
@@ -157,4 +182,50 @@ export async function release(options) {
157
182
  waitedMs: waited.waitedMs,
158
183
  indexUpdate,
159
184
  };
185
+
186
+ if (options.pr !== true || indexUpdate === null) return result;
187
+
188
+ // The index change is one line, but it still needs a branch, a pull request,
189
+ // a green CI and a merge; doing that by hand is where "forgot to open the PR"
190
+ // happens.
191
+ const ghRunner = options.gh ?? defaultGh(dir);
192
+ const pull = await openIndexPr({
193
+ dir,
194
+ indexFile,
195
+ version,
196
+ base: options.base ?? 'main',
197
+ branch: options.branch,
198
+ title: options.prTitle,
199
+ body: options.prBody,
200
+ git: options.git,
201
+ gh: ghRunner,
202
+ onStep: options.onStep,
203
+ });
204
+ result.pullRequest = pull;
205
+ if (pull.ok !== true) {
206
+ return { ...result, ok: false, stage: `pr:${pull.stage}`, failures: [{ check: 'pr', message: pull.reason }] };
207
+ }
208
+
209
+ if (options.merge !== true) return result;
210
+
211
+ const checks = await waitForChecks({
212
+ url: pull.url,
213
+ gh: ghRunner,
214
+ budgetMs: options.checksBudgetMs,
215
+ intervalMs: options.checksIntervalMs,
216
+ sleep: options.sleep,
217
+ now: options.now,
218
+ onWait: options.onChecks,
219
+ });
220
+ result.checks = checks;
221
+ if (checks.ok !== true) {
222
+ return { ...result, ok: false, stage: 'checks', failures: [{ check: 'checks', message: checks.reason }] };
223
+ }
224
+
225
+ const merged = await mergePr({ url: pull.url, gh: ghRunner, method: options.mergeMethod });
226
+ result.merged = merged;
227
+ if (merged.ok !== true) {
228
+ return { ...result, ok: false, stage: 'merge', failures: [{ check: 'merge', message: merged.reason }] };
229
+ }
230
+ return { ...result, stage: 'merged' };
160
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.3.5",
3
+ "version": "0.4.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": {