dsh-plugin-toolkit 0.5.0 → 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 +20 -0
- package/README.md +18 -0
- package/lib/cli.mjs +15 -0
- package/lib/exec.mjs +30 -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,25 @@
|
|
|
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
|
+
|
|
3
23
|
## 0.5.0
|
|
4
24
|
|
|
5
25
|
`dsh-plugin init` — scaffolding that already passes the checks people hit after their first publish.
|
package/README.md
CHANGED
|
@@ -198,6 +198,24 @@ The same checks run as a GitHub Action, so a tag cannot go out on a plugin that
|
|
|
198
198
|
|
|
199
199
|
Exit code is `0` when the plugin can be installed and `1` when it cannot, so it drops into CI unchanged.
|
|
200
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
|
+
|
|
201
219
|
## What `release` does
|
|
202
220
|
|
|
203
221
|
`release` runs the sequence in the order that fails cheapest first:
|
package/lib/cli.mjs
CHANGED
|
@@ -7,6 +7,7 @@ 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
|
|
|
@@ -18,6 +19,7 @@ export const HELP = `dsh-plugin — preflight and release DeepSeek Harness plugi
|
|
|
18
19
|
dsh-plugin mount <client.js> render a client bundle in Node and print what a user would see
|
|
19
20
|
dsh-plugin dev [dir] [flags] copy a rebuilt bundle into the installed plugin and confirm the reload
|
|
20
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
|
|
21
23
|
|
|
22
24
|
Flags
|
|
23
25
|
--dry-run show the steps without touching anything
|
|
@@ -46,6 +48,7 @@ function flags(argv) {
|
|
|
46
48
|
else if (token === '--name') parsed.name = argv[++index];
|
|
47
49
|
else if (token === '--title') parsed.title = argv[++index];
|
|
48
50
|
else if (token === '--force') parsed.force = true;
|
|
51
|
+
else if (token === '--no-mount') parsed.noMount = true;
|
|
49
52
|
else if (token === '--dest') parsed.dest = argv[++index];
|
|
50
53
|
else if (token === '--entry') parsed.entry = argv[++index];
|
|
51
54
|
else if (token === '--build') parsed.build = argv[++index];
|
|
@@ -89,6 +92,18 @@ export async function run(argv, io = console) {
|
|
|
89
92
|
return 1;
|
|
90
93
|
}
|
|
91
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
|
+
|
|
92
107
|
if (command === 'init') {
|
|
93
108
|
if (options.name === undefined) {
|
|
94
109
|
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 {
|
|
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": {
|