dsh-plugin-toolkit 0.4.1 → 0.6.3
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 +28 -0
- package/README.md +37 -0
- package/lib/cli.mjs +40 -0
- package/lib/exec.mjs +30 -0
- package/lib/init.mjs +171 -0
- package/lib/pr.mjs +3 -3
- package/lib/release.mjs +2 -2
- package/lib/verify.mjs +140 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.3
|
|
4
|
+
|
|
5
|
+
- `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.
|
|
6
|
+
|
|
7
|
+
## 0.6.2
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
## 0.6.1
|
|
12
|
+
|
|
13
|
+
- `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.
|
|
14
|
+
|
|
15
|
+
## 0.6.0
|
|
16
|
+
|
|
17
|
+
`dsh-plugin verify` — the check that reads the tarball instead of the working copy.
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
- 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.
|
|
21
|
+
- `--no-mount` stops after packing, for plugins whose panel needs a host to be meaningful.
|
|
22
|
+
|
|
23
|
+
## 0.5.0
|
|
24
|
+
|
|
25
|
+
`dsh-plugin init` — scaffolding that already passes the checks people hit after their first publish.
|
|
26
|
+
|
|
27
|
+
- Writes eight files: `package.json` (with `engines.dsh`, the patch file and a `files` list that actually ships the code), `cordis.patch.yml`, `index.js`, `client.js` (a sidebar tab), a testkit test for that tab, a CI workflow that runs both, `README.md` and `.gitignore`.
|
|
28
|
+
- Refuses a directory that is not empty unless `--force`, and refuses a name npm would refuse — the two mistakes that cost the most time later.
|
|
29
|
+
- The template is embedded, so `init` works offline.
|
|
30
|
+
|
|
3
31
|
## 0.4.1
|
|
4
32
|
|
|
5
33
|
- `examples/`: four runnable files (`mount-a-bundle`, `route-stubs`, `baseline`, `aria`), each showing one thing testkit does.
|
package/README.md
CHANGED
|
@@ -25,6 +25,25 @@ Releasing one plugin fifteen times taught me three things the ecosystem's toolin
|
|
|
25
25
|
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
26
|
|
|
27
27
|
|
|
28
|
+
## Start from a plugin that already passes
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
$ npx dsh-plugin-toolkit@0.5.0 init ./my-panel --name my-panel --title "My panel"
|
|
32
|
+
created 8 file(s) in /Users/you/my-panel
|
|
33
|
+
.github/workflows/ci.yml
|
|
34
|
+
.gitignore
|
|
35
|
+
README.md
|
|
36
|
+
client.js
|
|
37
|
+
cordis.patch.yml
|
|
38
|
+
index.js
|
|
39
|
+
package.json
|
|
40
|
+
test/panel.test.mjs
|
|
41
|
+
|
|
42
|
+
next: npm install && npm test && npm run doctor
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
What you get is not a snippet: it is a plugin whose `doctor` already passes (`engines.dsh`, the patch file, the `files` list), with a client bundle that registers a sidebar tab, a testkit test for it, and a CI workflow that runs both. `init` refuses a directory that is not empty unless you pass `--force`, and refuses a name npm would refuse — those are the two mistakes that cost the most time later.
|
|
46
|
+
|
|
28
47
|
## Test a panel without a browser
|
|
29
48
|
|
|
30
49
|
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` supplies the missing half: a DOM, the real React, and a host context that records what the panel registers.
|
|
@@ -179,6 +198,24 @@ The same checks run as a GitHub Action, so a tag cannot go out on a plugin that
|
|
|
179
198
|
|
|
180
199
|
Exit code is `0` when the plugin can be installed and `1` when it cannot, so it drops into CI unchanged.
|
|
181
200
|
|
|
201
|
+
## Prove the tarball works, not just the repository
|
|
202
|
+
|
|
203
|
+
`doctor` reads your working copy. Users install the tarball, and a `files` list can ship everything except the panel — `doctor` will not notice.
|
|
204
|
+
|
|
205
|
+
```sh
|
|
206
|
+
$ npx dsh-plugin-toolkit@0.6.0 verify .
|
|
207
|
+
ok packed 5 file(s), installed the tarball, mounted the panel
|
|
208
|
+
My panel This panel came from dsh-plugin init. Clicks: 0
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
It packs, installs that tarball into a throwaway directory, then mounts the bundle **from the installed copy**. When the `files` list drops the panel:
|
|
212
|
+
|
|
213
|
+
```sh
|
|
214
|
+
$ npx dsh-plugin-toolkit@0.6.0 verify .
|
|
215
|
+
dsh-plugin verify: stopped at pack
|
|
216
|
+
✗ pack: the tarball does not contain client.js, so the installed plugin has no panel
|
|
217
|
+
```
|
|
218
|
+
|
|
182
219
|
## What `release` does
|
|
183
220
|
|
|
184
221
|
`release` runs the sequence in the order that fails cheapest first:
|
package/lib/cli.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
8
|
import { doctor } from './doctor.mjs';
|
|
9
|
+
import { init } from './init.mjs';
|
|
10
|
+
import { verify } from './verify.mjs';
|
|
9
11
|
import { plan, release } from './release.mjs';
|
|
10
12
|
import { auditVersions, readIndex } from './index-file.mjs';
|
|
11
13
|
|
|
@@ -16,6 +18,8 @@ export const HELP = `dsh-plugin — preflight and release DeepSeek Harness plugi
|
|
|
16
18
|
dsh-plugin index [dir] audit every version in market/index.json
|
|
17
19
|
dsh-plugin mount <client.js> render a client bundle in Node and print what a user would see
|
|
18
20
|
dsh-plugin dev [dir] [flags] copy a rebuilt bundle into the installed plugin and confirm the reload
|
|
21
|
+
dsh-plugin init <dir> --name <pkg> scaffold a plugin that already passes doctor
|
|
22
|
+
dsh-plugin verify [dir] pack it, install the tarball, mount the installed panel
|
|
19
23
|
|
|
20
24
|
Flags
|
|
21
25
|
--dry-run show the steps without touching anything
|
|
@@ -25,6 +29,9 @@ Flags
|
|
|
25
29
|
--pr commit the index on a branch, push it, open the pull request
|
|
26
30
|
--merge with --pr: wait for checks, then squash-merge
|
|
27
31
|
--base <branch> base branch for the pull request, default main
|
|
32
|
+
--name <package-name> with init: the npm name, required
|
|
33
|
+
--title <text> with init: the tab title, default "My panel"
|
|
34
|
+
--force with init: write into a directory that is not empty
|
|
28
35
|
`;
|
|
29
36
|
|
|
30
37
|
function flags(argv) {
|
|
@@ -38,6 +45,10 @@ function flags(argv) {
|
|
|
38
45
|
else if (token === '--slot') parsed.slot = argv[++index];
|
|
39
46
|
else if (token === '--surface') parsed.surface = argv[++index];
|
|
40
47
|
else if (token === '--html') parsed.html = true;
|
|
48
|
+
else if (token === '--name') parsed.name = argv[++index];
|
|
49
|
+
else if (token === '--title') parsed.title = argv[++index];
|
|
50
|
+
else if (token === '--force') parsed.force = true;
|
|
51
|
+
else if (token === '--no-mount') parsed.noMount = true;
|
|
41
52
|
else if (token === '--dest') parsed.dest = argv[++index];
|
|
42
53
|
else if (token === '--entry') parsed.entry = argv[++index];
|
|
43
54
|
else if (token === '--build') parsed.build = argv[++index];
|
|
@@ -81,6 +92,35 @@ export async function run(argv, io = console) {
|
|
|
81
92
|
return 1;
|
|
82
93
|
}
|
|
83
94
|
|
|
95
|
+
if (command === 'verify') {
|
|
96
|
+
const result = await verify({ dir, mount: options.noMount !== true });
|
|
97
|
+
if (result.ok === true) {
|
|
98
|
+
io.log(`ok packed ${result.files.length} file(s), installed the tarball, mounted the panel`);
|
|
99
|
+
if (typeof result.text === 'string') io.log(` ${result.text}`);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
io.error(`dsh-plugin verify: stopped at ${result.stage}`);
|
|
103
|
+
printFailures(result.failures, io);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (command === 'init') {
|
|
108
|
+
if (options.name === undefined) {
|
|
109
|
+
io.error('dsh-plugin init: --name <package-name> is required');
|
|
110
|
+
return 2;
|
|
111
|
+
}
|
|
112
|
+
const result = init({ dir, name: options.name, title: options.title, force: options.force === true });
|
|
113
|
+
if (result.ok !== true) {
|
|
114
|
+
io.error(`dsh-plugin init: ${result.failures[0].message}`);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
io.log(`created ${result.files.length} file(s) in ${result.dir}`);
|
|
118
|
+
for (const file of result.files) io.log(` ${file}`);
|
|
119
|
+
io.log('');
|
|
120
|
+
io.log('next: npm install && npm test && npm run doctor');
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
84
124
|
if (command === 'index') {
|
|
85
125
|
const target = options.index ?? (dir.endsWith('.json') === true ? dir : path.join(dir, 'market/index.json'));
|
|
86
126
|
const index = readIndex(target);
|
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/init.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffolding, so the first ten minutes are not spent discovering that the
|
|
3
|
+
* market index rejects a plugin without `engines.dsh`.
|
|
4
|
+
*
|
|
5
|
+
* The template is embedded, not fetched: `init` has to work on a plane.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
11
|
+
|
|
12
|
+
export function checkName(name) {
|
|
13
|
+
if (typeof name !== 'string' || NPM_NAME.test(name) === false) {
|
|
14
|
+
return 'name must be a valid npm package name, e.g. my-plugin or @me/my-plugin';
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function manifest({ name, title, version }) {
|
|
20
|
+
return `${JSON.stringify(
|
|
21
|
+
{
|
|
22
|
+
name,
|
|
23
|
+
version,
|
|
24
|
+
description: `${title} — a panel for DeepSeek Harness`,
|
|
25
|
+
license: 'MIT',
|
|
26
|
+
type: 'module',
|
|
27
|
+
engines: { node: '>=18', dsh: '>=0.2.0' },
|
|
28
|
+
main: 'index.js',
|
|
29
|
+
files: ['index.js', 'client.js', 'cordis.patch.yml', 'README.md'],
|
|
30
|
+
scripts: { test: 'node --test', doctor: 'dsh-plugin doctor .' },
|
|
31
|
+
devDependencies: {
|
|
32
|
+
'dsh-plugin-toolkit': '^0.5.0',
|
|
33
|
+
jsdom: '^26.1.0',
|
|
34
|
+
react: '^18.3.1',
|
|
35
|
+
'react-dom': '^18.3.1',
|
|
36
|
+
},
|
|
37
|
+
dsh: {
|
|
38
|
+
bundle: { patch: './cordis.patch.yml' },
|
|
39
|
+
client: {
|
|
40
|
+
platform: 'web',
|
|
41
|
+
immediately: true,
|
|
42
|
+
inject: ['@deepseek-ai/dsh-client-ui-conversation'],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
null,
|
|
47
|
+
2,
|
|
48
|
+
)}\n`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function client({ name, title }) {
|
|
52
|
+
return `window.__ModuleLoader__.load({
|
|
53
|
+
id: '${name}',
|
|
54
|
+
factory: (require) => {
|
|
55
|
+
const React = require('react');
|
|
56
|
+
const { useState } = React;
|
|
57
|
+
const h = React.createElement;
|
|
58
|
+
const NS = '${name}';
|
|
59
|
+
const KIND = '${name}';
|
|
60
|
+
|
|
61
|
+
const COPY = {
|
|
62
|
+
en: { tabTitle: '${title}', title: '${title}', body: 'This panel came from dsh-plugin init.', clicks: 'Clicks' },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function Panel() {
|
|
66
|
+
const [clicks, setClicks] = useState(0);
|
|
67
|
+
return h(
|
|
68
|
+
'div',
|
|
69
|
+
{ className: 'panel' },
|
|
70
|
+
h('h1', { className: 'panel-title' }, COPY.en.title),
|
|
71
|
+
h('p', { className: 'panel-body' }, COPY.en.body),
|
|
72
|
+
h(
|
|
73
|
+
'button',
|
|
74
|
+
{ type: 'button', className: 'panel-button', onClick: () => setClicks((value) => value + 1) },
|
|
75
|
+
\`\${COPY.en.clicks}: \${clicks}\`,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
apply(ctx) {
|
|
82
|
+
ctx.locale.register(NS, COPY);
|
|
83
|
+
const dispose = ctx.inject(['sidebarRightTabs', 'sidebarRight'], (tabCtx) => {
|
|
84
|
+
if (tabCtx.slots === undefined) return;
|
|
85
|
+
const tr = tabCtx.locale.bind(NS);
|
|
86
|
+
const offType = tabCtx.sidebarRightTabs.register({
|
|
87
|
+
id: NS,
|
|
88
|
+
kind: KIND,
|
|
89
|
+
title: () => tr('tabTitle'),
|
|
90
|
+
guide: [{ id: 'panel', order: 40, title: () => tr('title'), description: () => tr('body') }],
|
|
91
|
+
});
|
|
92
|
+
const offBody = tabCtx.slots.register({ name: 'sidebar.right.pane.tab', key: NS, locale: NS }, Panel);
|
|
93
|
+
return () => {
|
|
94
|
+
offBody?.();
|
|
95
|
+
offType?.();
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
return () => dispose?.();
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function test({ name, title }) {
|
|
107
|
+
return `import assert from 'node:assert/strict';
|
|
108
|
+
import path from 'node:path';
|
|
109
|
+
import test from 'node:test';
|
|
110
|
+
import { fileURLToPath } from 'node:url';
|
|
111
|
+
|
|
112
|
+
import { mountClient } from 'dsh-plugin-toolkit/testkit';
|
|
113
|
+
|
|
114
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
115
|
+
|
|
116
|
+
test('the panel renders and its button counts', async () => {
|
|
117
|
+
const panel = await mountClient({
|
|
118
|
+
file: path.join(here, '..', 'client.js'),
|
|
119
|
+
copy: { '${name}': { tabTitle: '${title}', title: '${title}', body: 'This panel came from dsh-plugin init.', clicks: 'Clicks' } },
|
|
120
|
+
});
|
|
121
|
+
assert.match(panel.text(), /${title}/);
|
|
122
|
+
await panel.click('button');
|
|
123
|
+
assert.match(panel.text(), /Clicks: 1/);
|
|
124
|
+
await panel.unmount();
|
|
125
|
+
});
|
|
126
|
+
`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function files({ name, title = 'My panel', version = '0.1.0' }) {
|
|
130
|
+
const slug = name.includes('/') ? name.split('/')[1] : name;
|
|
131
|
+
return {
|
|
132
|
+
'package.json': manifest({ name, title, version }),
|
|
133
|
+
'cordis.patch.yml': `- insert:\n - id: ${name}\n name: '${name}'\n config: {}\n`,
|
|
134
|
+
'index.js': `export const name = '${name}';\n\n// The host half runs in Node. Most panels need nothing here.\nexport function apply() {}\n`,
|
|
135
|
+
'client.js': client({ name, title }),
|
|
136
|
+
'test/panel.test.mjs': test({ name, title }),
|
|
137
|
+
'README.md': `# ${slug}\n\n${title} — a panel for DeepSeek Harness.\n\n## Requirements\n\n| What | Version | Why |\n| --- | --- | --- |\n| DeepSeek Harness | >= 0.2.0 | provides \`sidebarRightTabs\` |\n| Node | >= 18 | the host half |\n\n## Develop\n\n\`\`\`sh\nnpm install\nnpm test # mounts client.js in Node\nnpm run doctor # checks the manifest before you publish\n\`\`\`\n\n## Install\n\n\`\`\`sh\ndsh plugin add ${name}\n\`\`\`\n`,
|
|
138
|
+
'.gitignore': 'node_modules/\n',
|
|
139
|
+
'.github/workflows/ci.yml': `name: ci\n\non:\n push:\n branches: [main]\n pull_request:\n\njobs:\n test:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n node: [18, 20, 22]\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: \${{ matrix.node }}\n - run: npm install\n - run: npm test\n - run: npx --yes dsh-plugin-toolkit@0.5.0 doctor .\n`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function init({ dir, name, title, force = false }) {
|
|
144
|
+
const problem = checkName(name);
|
|
145
|
+
if (problem !== null) return { ok: false, failures: [{ check: 'name', message: problem }] };
|
|
146
|
+
|
|
147
|
+
const target = path.resolve(dir);
|
|
148
|
+
if (fs.existsSync(target) === true) {
|
|
149
|
+
const entries = fs.readdirSync(target).filter((entry) => entry !== '.git');
|
|
150
|
+
if (entries.length > 0 && force !== true) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
failures: [
|
|
154
|
+
{
|
|
155
|
+
check: 'dir',
|
|
156
|
+
message: `${target} is not empty (${entries.slice(0, 3).join(', ')}); pass --force to write into it anyway`,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const written = [];
|
|
164
|
+
for (const [relative, content] of Object.entries(files({ name, title }))) {
|
|
165
|
+
const file = path.join(target, relative);
|
|
166
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
167
|
+
fs.writeFileSync(file, content);
|
|
168
|
+
written.push(relative);
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, dir: target, files: written.sort() };
|
|
171
|
+
}
|
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 {
|
|
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) =>
|
|
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) =>
|
|
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 {
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.6.3",
|
|
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": {
|