dsh-plugin-toolkit 0.12.0 → 0.13.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 +34 -0
- package/README.md +34 -4
- package/README.zh.md +12 -0
- package/client.js +80 -19
- package/docs/badge.svg +19 -0
- package/index.js +26 -3
- package/lib/github.mjs +94 -0
- package/lib/init.mjs +1 -1
- package/package.json +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
## 0.13.1
|
|
2
|
+
|
|
3
|
+
- **A badge you can put on a plugin that passes.** `docs/badge.svg` plus the
|
|
4
|
+
snippet in both READMEs. It is a claim, not a decoration: the README says to
|
|
5
|
+
add it only when `verify` exits 0.
|
|
6
|
+
- **Every scaffolded plugin links back.** `init` now writes the badge into the
|
|
7
|
+
generated README, so a plugin created with the toolkit advertises the toolkit
|
|
8
|
+
it was checked with.
|
|
9
|
+
- **README badges** for version, downloads, CI and license, and the repository
|
|
10
|
+
homepage now points at the npm page.
|
|
11
|
+
|
|
12
|
+
## 0.13.0
|
|
13
|
+
|
|
14
|
+
Three more rounds of the panel, and one new thing to point it at.
|
|
15
|
+
|
|
16
|
+
- **The language switch is a real segmented control.** It used to be a bordered
|
|
17
|
+
box with `overflow: hidden` and a filled button inside it: the fill was clipped
|
|
18
|
+
by the container's radius and the 0.5px border left a hairline of background
|
|
19
|
+
along two edges, which read as a missing colour block. Now the track is inset
|
|
20
|
+
and the selected pill is its own rounded shape, so the fill is complete at
|
|
21
|
+
every size and in both themes.
|
|
22
|
+
- **A repository name is enough.** The input takes a folder, `owner/repo`, or a
|
|
23
|
+
GitHub URL. A bare name searches GitHub, lists the matches with their stars
|
|
24
|
+
and descriptions, and checks the one you click — the picked repository is
|
|
25
|
+
fetched as a tarball into a temp folder, so nothing is cloned and nothing is
|
|
26
|
+
installed. Results name the repository and link to it instead of showing a
|
|
27
|
+
temp path.
|
|
28
|
+
- **Candidates are a list you can act on.** Owner is muted, the repository name
|
|
29
|
+
carries the weight, an arrow appears on the row, and the rows are keyboard
|
|
30
|
+
reachable with a visible focus ring.
|
|
31
|
+
- **The guide says what changed.** The "before you trust it" note no longer
|
|
32
|
+
claims there are no network calls: a local folder is still read-only, a
|
|
33
|
+
repository is downloaded once, and the note now says so.
|
|
34
|
+
|
|
1
35
|
## 0.12.0
|
|
2
36
|
|
|
3
37
|
Five rounds of the panel's own UI, each one driven by looking at the rendered
|
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# dsh-plugin-toolkit
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-plugin-toolkit)
|
|
4
|
+
[](https://www.npmjs.com/package/dsh-plugin-toolkit)
|
|
5
|
+
[](https://github.com/WJDdidi-1/dsh-plugin-toolkit/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
|
|
3
9
|

|
|
4
10
|
|
|
5
11
|
English · [中文](./README.zh.md)
|
|
@@ -20,10 +26,13 @@ npx dsh-plugin-toolkit@latest release --pr --merge # tag, wait for
|
|
|
20
26
|
| changing a panel that is already installed | [`dev`](#2-develop-a-panel) | `mount`, the testkit, `audit` |
|
|
21
27
|
| shipping a version | [`verify`](#3-ship-a-version) | `release`, `index` |
|
|
22
28
|
|
|
23
|
-
It also installs as a plugin. `dsh plugin add dsh-plugin-toolkit`
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
It also installs as a plugin. `dsh plugin add dsh-plugin-toolkit` puts a
|
|
30
|
+
**插件体检 / Plugin doctor** panel under the conversation and a tab in the right
|
|
31
|
+
sidebar. Give it a folder that holds a plugin's `package.json`, or just
|
|
32
|
+
`owner/repo` — or a repository name, which it searches GitHub for and lists the
|
|
33
|
+
candidates to pick from. It then runs the same checks `verify` runs and says
|
|
34
|
+
which one would break the install, with the reason on the line — the same
|
|
35
|
+
answers as the CLI, without leaving the app.
|
|
27
36
|
|
|
28
37
|
```
|
|
29
38
|
$ dsh plugin add dsh-plugin-toolkit
|
|
@@ -207,6 +216,16 @@ $ npx dsh-plugin mount ../ai-context-doctor/plugin/client.js
|
|
|
207
216
|
AIignore No workspace yet Could not read the report …
|
|
208
217
|
```
|
|
209
218
|
|
|
219
|
+
## Put the badge on a plugin that passes
|
|
220
|
+
|
|
221
|
+
If `verify` passes on your plugin, this badge says so and links back here:
|
|
222
|
+
|
|
223
|
+
```md
|
|
224
|
+
[](https://github.com/WJDdidi-1/dsh-plugin-toolkit)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
It is a claim, not a decoration: add it only when `dsh-plugin verify .` exits 0.
|
|
228
|
+
|
|
210
229
|
## Start from the example
|
|
211
230
|
|
|
212
231
|
[`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 `verify` 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. It is a GitHub template too: **Use this template** starts you on a plugin whose first publish already passes.
|
|
@@ -297,6 +316,17 @@ forgets `client.js` passes it and fails only after `npm i`.
|
|
|
297
316
|
cannot state a host range. Pin `version:` if you would rather not track the
|
|
298
317
|
latest release.
|
|
299
318
|
|
|
319
|
+
## Check a plugin you have not cloned
|
|
320
|
+
|
|
321
|
+
The panel takes three kinds of input: a folder, `owner/repo`, or a GitHub URL.
|
|
322
|
+
A bare name is a search, not a guess — it asks GitHub, lists the matches with
|
|
323
|
+
their stars, and checks the one you pick. A repository is downloaded once as a
|
|
324
|
+
tarball into a temp folder, so nothing is cloned into your workspace and
|
|
325
|
+
nothing is installed.
|
|
326
|
+
|
|
327
|
+
The CLI checks local folders only; for a repository, clone it or let the panel
|
|
328
|
+
do the download.
|
|
329
|
+
|
|
300
330
|
## What `doctor` checks
|
|
301
331
|
|
|
302
332
|
| Check | Why it matters |
|
package/README.zh.md
CHANGED
|
@@ -36,6 +36,16 @@ npx dsh-plugin-toolkit@latest release --pr --merge # 打 tag、等
|
|
|
36
36
|
- Node.js 18 或更新(CI 在 18/20/22 × ubuntu/macos/windows 上跑)。
|
|
37
37
|
- 要发布时需要一个能写 `market/index.json` 的仓库和 `gh`(已登录)。
|
|
38
38
|
|
|
39
|
+
## 通过检查就挂个徽章
|
|
40
|
+
|
|
41
|
+
`verify` 过了,可以用这个徽章,点回这里:
|
|
42
|
+
|
|
43
|
+
```md
|
|
44
|
+
[](https://github.com/WJDdidi-1/dsh-plugin-toolkit)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
这是声明不是装饰:`dsh-plugin verify .` 退出码为 0 才挂。
|
|
48
|
+
|
|
39
49
|
## 从一个已经能通过检查的插件开始
|
|
40
50
|
|
|
41
51
|
```sh
|
|
@@ -57,6 +67,8 @@ cd my-panel && npm install && npm test
|
|
|
57
67
|
|
|
58
68
|
检查器固定在**对话栏下方、输入框上方**:默认收起成一行,点开就是完整指南——这是给谁用的、怎么用、每一项检查拦什么、以及它证明不了什么——下面才是文件夹输入框和结果。同一个面板也在右侧栏有一个 **Plugin doctor** 标签页,对话区忙的时候用那个。
|
|
59
69
|
|
|
70
|
+
输入框接受三种东西:本地文件夹、`owner/repo`、GitHub 链接。**只写一个名字会去 GitHub 搜**,列出候选仓库(带星数),点哪个查哪个;查仓库时只下载一次 tarball 到临时目录,不 clone 进你的工作区、不安装任何东西。
|
|
71
|
+
|
|
60
72
|
面板中英双语,默认中文;**中文 / EN** 开关就在对话栏那一行的右侧(右侧栏标签里也有),选过之后会记住。
|
|
61
73
|
|
|
62
74
|
不开应用也能看:
|
package/client.js
CHANGED
|
@@ -20,9 +20,10 @@ window.__ModuleLoader__.load({
|
|
|
20
20
|
fail: 'This would not install cleanly.',
|
|
21
21
|
counts: (failed, total) => `${failed} of ${total} checks failed`,
|
|
22
22
|
passedLabel: (n) => `${n} passed`,
|
|
23
|
-
empty: '
|
|
23
|
+
empty: 'A folder path, owner/repo, or a GitHub link. A bare name searches GitHub.',
|
|
24
24
|
guideTitle: 'How to use this',
|
|
25
25
|
checkingNow: 'Checking…',
|
|
26
|
+
candidates: (n) => `${n} repositories match`,
|
|
26
27
|
chipFail: (n) => `${n} failed`,
|
|
27
28
|
chipPass: 'passes',
|
|
28
29
|
checked: 'Checked',
|
|
@@ -43,9 +44,10 @@ window.__ModuleLoader__.load({
|
|
|
43
44
|
fail: '这样装上去会出问题。',
|
|
44
45
|
counts: (failed, total) => `${total} 项里 ${failed} 项没过`,
|
|
45
46
|
passedLabel: (n) => `${n} 项通过`,
|
|
46
|
-
empty: '
|
|
47
|
+
empty: '填文件夹路径、owner/repo 或 GitHub 链接;只写名字会去 GitHub 搜。',
|
|
47
48
|
guideTitle: '使用说明',
|
|
48
49
|
checkingNow: '正在检查…',
|
|
50
|
+
candidates: (n) => `找到 ${n} 个仓库`,
|
|
49
51
|
chipFail: (n) => `${n} 项没过`,
|
|
50
52
|
chipPass: '通过',
|
|
51
53
|
checked: '检查的是',
|
|
@@ -71,7 +73,7 @@ window.__ModuleLoader__.load({
|
|
|
71
73
|
steps: {
|
|
72
74
|
title: 'How to use it',
|
|
73
75
|
items: [
|
|
74
|
-
'Paste
|
|
76
|
+
'Paste a folder, or type owner/repo or a GitHub link. A bare name searches GitHub and lists what it found.',
|
|
75
77
|
'Press Check. Nothing is installed and none of your files are touched.',
|
|
76
78
|
'Read the list: ✓ passes, ✗ would break the install, with one line saying why.',
|
|
77
79
|
],
|
|
@@ -91,7 +93,7 @@ window.__ModuleLoader__.load({
|
|
|
91
93
|
notes: {
|
|
92
94
|
title: 'Before you trust it',
|
|
93
95
|
items: [
|
|
94
|
-
'
|
|
96
|
+
'It never installs or edits your project. A local folder is read only; a GitHub repository is downloaded once as a tarball and checked in a temp folder.',
|
|
95
97
|
'It looks at a folder, not at what you have installed — use dsh-plugin audit --host … for that.',
|
|
96
98
|
'engines.dsh and README are market-index requirements; a plugin missing them still runs.',
|
|
97
99
|
'It cannot prove runtime behaviour. A panel can still fail inside the real app when a service arrives late or a registration throws; those failures are appended to ~/.dsh/toolkit-client-report.ndjson.',
|
|
@@ -110,7 +112,7 @@ window.__ModuleLoader__.load({
|
|
|
110
112
|
steps: {
|
|
111
113
|
title: '怎么用',
|
|
112
114
|
items: [
|
|
113
|
-
'
|
|
115
|
+
'填本地文件夹,或 owner/repo、GitHub 链接;只写名字会去 GitHub 搜,列出候选让你挑。',
|
|
114
116
|
'按「检查」。不会安装任何东西,也不会改你的文件。',
|
|
115
117
|
'看列表:✓ 通过,✗ 是装上去会坏的地方,右边一行说明原因。',
|
|
116
118
|
],
|
|
@@ -130,7 +132,7 @@ window.__ModuleLoader__.load({
|
|
|
130
132
|
notes: {
|
|
131
133
|
title: '用之前要知道的',
|
|
132
134
|
items: [
|
|
133
|
-
'
|
|
135
|
+
'不安装、不改你的项目。查本地文件夹时只读;查 GitHub 仓库时会下载一次 tarball,在临时目录里检查。',
|
|
134
136
|
'它看的是文件夹,不是你已装的插件——查已装的用 dsh-plugin audit --host …。',
|
|
135
137
|
'engines.dsh 与 README 是市场索引的要求,缺了插件照样能跑。',
|
|
136
138
|
'它证明不了运行期行为。面板在真实应用里仍可能因为服务来晚了、注册抛错而失败;那些失败会写进 ~/.dsh/toolkit-client-report.ndjson。',
|
|
@@ -250,6 +252,25 @@ window.__ModuleLoader__.load({
|
|
|
250
252
|
'.tk-passes .tk-rows{margin-top:4px}',
|
|
251
253
|
'.tk-summary{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}',
|
|
252
254
|
'.tk-count{font-size:11px;color:var(--dsw-alias-label-tertiary,#888);font-variant-numeric:tabular-nums}',
|
|
255
|
+
'.tk-candidates{display:flex;flex-direction:column;gap:6px}',
|
|
256
|
+
'.tk-candidate-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}',
|
|
257
|
+
'.tk-candidate{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;width:100%;text-align:left;',
|
|
258
|
+
'padding:7px 9px;border:0.5px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:6px;',
|
|
259
|
+
'background:var(--dsw-alias-bg-body,#fff);font:inherit;cursor:pointer;transition:border-color .12s ease,background .12s ease}',
|
|
260
|
+
'.tk-candidate:hover{border-color:var(--dsw-alias-state-business-primary,#4176e6);',
|
|
261
|
+
'background:var(--dsw-alias-bg-layer-1,#fafbfc)}',
|
|
262
|
+
'.tk-candidate:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:1px}',
|
|
263
|
+
'.tk-candidate:hover{border-color:var(--dsw-alias-state-business-primary,#4176e6)}',
|
|
264
|
+
'.tk-candidate-name{font-size:12px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis}',
|
|
265
|
+
'.tk-candidate-owner{font-weight:400;color:var(--dsw-alias-label-tertiary,#888)}',
|
|
266
|
+
'.tk-candidate-go{margin-left:auto;font-size:12px;color:var(--dsw-alias-label-tertiary,#888)}',
|
|
267
|
+
'.tk-candidate:hover .tk-candidate-go{color:var(--dsw-alias-state-business-primary,#4176e6)}',
|
|
268
|
+
'.tk-candidate-stars{font-size:11px;color:var(--dsw-alias-label-tertiary,#888)}',
|
|
269
|
+
'.tk-candidate-desc{flex-basis:100%;font-size:11px;color:var(--dsw-alias-label-secondary,#555);',
|
|
270
|
+
'overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
271
|
+
'.tk-repo{margin:0;font-size:12px}',
|
|
272
|
+
'.tk-repo a{color:var(--dsw-alias-state-business-primary,#4176e6);text-decoration:none}',
|
|
273
|
+
'.tk-repo a:hover{text-decoration:underline}',
|
|
253
274
|
'.tk-checked{margin:0;font-size:11px;color:var(--dsw-alias-label-tertiary,#888);overflow-wrap:anywhere}',
|
|
254
275
|
'.tk-checked code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}',
|
|
255
276
|
'.tk-verdict{margin:0;font-size:12px;font-weight:600}',
|
|
@@ -269,11 +290,14 @@ window.__ModuleLoader__.load({
|
|
|
269
290
|
'color:var(--dsw-alias-label-tertiary,#888)}',
|
|
270
291
|
'.tk-message{font-size:12px;overflow-wrap:anywhere}',
|
|
271
292
|
'.tk-panel-head{display:flex;align-items:center;gap:8px}',
|
|
272
|
-
'.tk-lang{display:inline-flex;flex:none;
|
|
273
|
-
'
|
|
274
|
-
'.tk-lang-button{height:
|
|
275
|
-
'color:var(--dsw-alias-label-secondary,#555);cursor:pointer
|
|
276
|
-
'.
|
|
293
|
+
'.tk-lang{display:inline-flex;flex:none;gap:2px;padding:2px;border-radius:7px;',
|
|
294
|
+
'background:var(--dsw-alias-bg-layer-1,#f2f3f5)}',
|
|
295
|
+
'.tk-lang-button{height:20px;min-width:34px;padding:0 8px;border:0;border-radius:5px;background:none;',
|
|
296
|
+
'font:inherit;font-size:11px;line-height:20px;color:var(--dsw-alias-label-secondary,#555);cursor:pointer;',
|
|
297
|
+
'transition:background .12s ease,color .12s ease}',
|
|
298
|
+
'.tk-lang-button[data-on="true"]{background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff;',
|
|
299
|
+
'box-shadow:0 0 0 0.5px var(--dsw-alias-state-business-primary,#4176e6)}',
|
|
300
|
+
'.tk-lang-button[data-on="false"]:hover{color:var(--dsw-alias-label-primary,#1f2329)}',
|
|
277
301
|
'.tk-lang-button:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:-1px}',
|
|
278
302
|
'.tk-dock{border-top:0.5px solid var(--dsw-alias-border-l2,#e5e5e5);margin:0 12px}',
|
|
279
303
|
'.tk-dock-head{display:flex}',
|
|
@@ -284,7 +308,10 @@ window.__ModuleLoader__.load({
|
|
|
284
308
|
'.tk-dock-title{font-weight:600}',
|
|
285
309
|
'.tk-dock-sub{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
|
|
286
310
|
'color:var(--dsw-alias-label-tertiary,#888)}',
|
|
287
|
-
'.tk-dock-chevron{flex:none;
|
|
311
|
+
'.tk-dock-chevron{flex:none;width:6px;height:6px;margin:0 2px 0 0;border-right:1.5px solid currentColor;',
|
|
312
|
+
'border-bottom:1.5px solid currentColor;transform:rotate(-45deg);color:var(--dsw-alias-label-tertiary,#888);',
|
|
313
|
+
'transition:transform .15s ease}',
|
|
314
|
+
'.tk-dock-chevron[data-open="true"]{transform:rotate(45deg);margin-bottom:3px}',
|
|
288
315
|
'.tk-summary:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);',
|
|
289
316
|
'outline-offset:2px;border-radius:4px}',
|
|
290
317
|
'.tk-dock-body{display:flex;flex-direction:column;gap:10px;max-width:76ch;padding:2px 2px 14px;',
|
|
@@ -338,14 +365,17 @@ window.__ModuleLoader__.load({
|
|
|
338
365
|
const [busy, setBusy] = useState(false);
|
|
339
366
|
const [failed, setFailed] = useState(null);
|
|
340
367
|
|
|
341
|
-
const check = useCallback(() => {
|
|
342
|
-
|
|
368
|
+
const check = useCallback((explicit) => {
|
|
369
|
+
// A candidate button checks a name that is not in the input yet, so the
|
|
370
|
+
// target can be passed in instead of read from state.
|
|
371
|
+
const target = typeof explicit === 'string' ? explicit : dir;
|
|
372
|
+
if (target.trim().length === 0) {
|
|
343
373
|
setFailed(translate('noDir'));
|
|
344
374
|
return;
|
|
345
375
|
}
|
|
346
376
|
setBusy(true);
|
|
347
377
|
setFailed(null);
|
|
348
|
-
fetch(`/toolkit/check?dir=${encodeURIComponent(
|
|
378
|
+
fetch(`/toolkit/check?dir=${encodeURIComponent(target.trim())}`)
|
|
349
379
|
.then((response) => (response.ok === true ? response.json() : Promise.reject(new Error(String(response.status)))))
|
|
350
380
|
.then((payload) => setResult(payload))
|
|
351
381
|
.catch((error) => setFailed(`${translate('failed')} · ${error.message}`))
|
|
@@ -385,11 +415,33 @@ window.__ModuleLoader__.load({
|
|
|
385
415
|
? h('p', { className: 'tk-empty' }, tr('empty'))
|
|
386
416
|
: null,
|
|
387
417
|
checker.result !== null
|
|
388
|
-
? h(ResultBody, { result: checker.result, tr, lang })
|
|
418
|
+
? h(ResultBody, { result: checker.result, tr, lang, checker })
|
|
389
419
|
: null);
|
|
390
420
|
}
|
|
391
421
|
|
|
392
|
-
function
|
|
422
|
+
function Candidates({ result, tr, lang, checker }) {
|
|
423
|
+
return h('div', { className: 'tk-candidates' },
|
|
424
|
+
h('p', { className: 'tk-empty' }, COPY[lang].candidates(result.candidates.length)),
|
|
425
|
+
h('ul', { className: 'tk-candidate-list' }, result.candidates.map((item) => h('li', { key: item.fullName },
|
|
426
|
+
h('button', {
|
|
427
|
+
className: 'tk-candidate',
|
|
428
|
+
type: 'button',
|
|
429
|
+
onClick: () => {
|
|
430
|
+
// Picking a candidate is the same request, with a name the host
|
|
431
|
+
// can resolve without searching again.
|
|
432
|
+
checker.setDir(item.fullName);
|
|
433
|
+
checker.check(item.fullName);
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
h('span', { className: 'tk-candidate-name' },
|
|
437
|
+
h('span', { className: 'tk-candidate-owner' }, `${item.fullName.split('/')[0]}/`),
|
|
438
|
+
item.fullName.split('/')[1]),
|
|
439
|
+
item.stars > 0 ? h('span', { className: 'tk-candidate-stars' }, `★ ${item.stars}`) : null,
|
|
440
|
+
h('span', { className: 'tk-candidate-go', 'aria-hidden': 'true' }, '→'),
|
|
441
|
+
item.description !== '' ? h('span', { className: 'tk-candidate-desc' }, item.description) : null)))));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function ResultBody({ result, tr, lang, checker }) {
|
|
393
445
|
const summary = useRef(null);
|
|
394
446
|
// After a check the answer is the thing to read; leaving focus in the
|
|
395
447
|
// input means a screen reader announces nothing.
|
|
@@ -402,6 +454,9 @@ window.__ModuleLoader__.load({
|
|
|
402
454
|
// Failures first: a list that opens with six green lines buries the two
|
|
403
455
|
// that matter below the fold of a docked panel.
|
|
404
456
|
const ordered = [...failed, ...passed];
|
|
457
|
+
if (result.kind === 'candidates') {
|
|
458
|
+
return h(Candidates, { result, tr, lang, checker });
|
|
459
|
+
}
|
|
405
460
|
return h('div', { className: 'tk-result-body' },
|
|
406
461
|
h('div', { className: 'tk-summary', ref: summary, tabIndex: -1,
|
|
407
462
|
'data-ok': result.ok === true ? 'true' : 'false', role: 'status' },
|
|
@@ -410,7 +465,13 @@ window.__ModuleLoader__.load({
|
|
|
410
465
|
failed.length > 0
|
|
411
466
|
? h('span', { className: 'tk-count' }, COPY[lang].counts(failed.length, checks.length))
|
|
412
467
|
: null),
|
|
413
|
-
|
|
468
|
+
result.repo !== undefined
|
|
469
|
+
? h('p', { className: 'tk-repo' },
|
|
470
|
+
h('a', { href: result.repo.url, target: '_blank', rel: 'noreferrer noopener' }, result.repo.fullName))
|
|
471
|
+
: null,
|
|
472
|
+
// A repository's temp folder is an implementation detail; a local path is
|
|
473
|
+
// the thing the person typed, so that one stays.
|
|
474
|
+
result.repo === undefined && typeof result.dir === 'string' && result.dir !== ''
|
|
414
475
|
? h('p', { className: 'tk-checked' },
|
|
415
476
|
h('span', {}, `${tr('checked')} `),
|
|
416
477
|
h('code', {}, result.dir))
|
|
@@ -468,7 +529,7 @@ window.__ModuleLoader__.load({
|
|
|
468
529
|
},
|
|
469
530
|
h('span', { className: 'tk-dock-title' }, translate('dockTitle', lang)),
|
|
470
531
|
h('span', { className: 'tk-dock-sub' }, translate('dockSub', lang)),
|
|
471
|
-
h('span', { className: 'tk-dock-chevron', '
|
|
532
|
+
h('span', { className: 'tk-dock-chevron', 'data-open': open === true ? 'true' : 'false', 'aria-hidden': 'true' })),
|
|
472
533
|
open !== true && checker.result !== null
|
|
473
534
|
? h('span', {
|
|
474
535
|
className: 'tk-chip',
|
package/docs/badge.svg
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="196" height="20" role="img" aria-label="dsh plugin: verify passed">
|
|
2
|
+
<title>dsh plugin: verify passed</title>
|
|
3
|
+
<linearGradient id="s" x2="0" y2="100%">
|
|
4
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
5
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<clipPath id="r"><rect width="196" height="20" rx="3" fill="#fff"/></clipPath>
|
|
8
|
+
<g clip-path="url(#r)">
|
|
9
|
+
<rect width="82" height="20" fill="#2f3542"/>
|
|
10
|
+
<rect x="82" width="114" height="20" fill="#1a7f37"/>
|
|
11
|
+
<rect width="196" height="20" fill="url(#s)"/>
|
|
12
|
+
</g>
|
|
13
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
|
14
|
+
<text x="41" y="15" fill="#010101" fill-opacity=".3">dsh plugin</text>
|
|
15
|
+
<text x="41" y="14">dsh plugin</text>
|
|
16
|
+
<text x="139" y="15" fill="#010101" fill-opacity=".3">✓ verify passed</text>
|
|
17
|
+
<text x="139" y="14">✓ verify passed</text>
|
|
18
|
+
</g>
|
|
19
|
+
</svg>
|
package/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import os from 'node:os';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
|
|
13
13
|
import { doctor } from './lib/doctor.mjs';
|
|
14
|
+
import { resolveTarget } from './lib/github.mjs';
|
|
14
15
|
import { verify } from './lib/verify.mjs';
|
|
15
16
|
|
|
16
17
|
export const name = 'dsh-plugin-toolkit';
|
|
@@ -115,7 +116,7 @@ async function readBody(req) {
|
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
export function apply(ctx) {
|
|
119
|
+
export function apply(ctx, options = {}) {
|
|
119
120
|
const reportHandler = async (req, res) => {
|
|
120
121
|
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method-not-allowed' });
|
|
121
122
|
const body = await readBody(req);
|
|
@@ -133,10 +134,32 @@ export function apply(ctx) {
|
|
|
133
134
|
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method-not-allowed' });
|
|
134
135
|
if (sameOrigin(req) === false) return sendJson(res, 403, { error: 'cross-origin' });
|
|
135
136
|
const url = new URL(req.url, 'http://localhost');
|
|
137
|
+
const input = url.searchParams.get('dir');
|
|
136
138
|
try {
|
|
137
|
-
|
|
139
|
+
// A folder path, `owner/repo`, a GitHub URL, or a bare name to search for.
|
|
140
|
+
const target = await resolveTarget(input, options.github);
|
|
141
|
+
if (target.kind === 'empty') {
|
|
142
|
+
return sendJson(res, 200, { ok: false, kind: 'empty', checks: [{ check: 'dir', ok: false, message: 'nothing to check' }] });
|
|
143
|
+
}
|
|
144
|
+
if (target.kind === 'candidates') {
|
|
145
|
+
return sendJson(res, 200, { ok: false, kind: 'candidates', query: target.query, candidates: target.candidates });
|
|
146
|
+
}
|
|
147
|
+
const report = await checkPlugin(target.dir);
|
|
148
|
+
const answer = { ...report, kind: target.kind };
|
|
149
|
+
if (target.repo !== undefined) answer.repo = target.repo;
|
|
150
|
+
return sendJson(res, 200, answer);
|
|
138
151
|
} catch (error) {
|
|
139
|
-
|
|
152
|
+
const message = String(error && error.message);
|
|
153
|
+
// A repository that cannot be fetched is a check result, not a server
|
|
154
|
+
// fault: the panel has to be able to say which one and why.
|
|
155
|
+
if (/github|answered \d{3}|rate limit/i.test(message) === true) {
|
|
156
|
+
return sendJson(res, 200, {
|
|
157
|
+
ok: false,
|
|
158
|
+
kind: 'github',
|
|
159
|
+
checks: [{ check: 'github', ok: false, message }],
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return sendJson(res, 500, { error: 'check-failed', message });
|
|
140
163
|
}
|
|
141
164
|
};
|
|
142
165
|
|
package/lib/github.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check a plugin without cloning it first.
|
|
3
|
+
*
|
|
4
|
+
* A folder path is not the only thing people have in hand when they wonder
|
|
5
|
+
* whether a plugin works: they have a name, or a GitHub URL they just saw. This
|
|
6
|
+
* module turns either into a folder, and says which repositories a bare name
|
|
7
|
+
* could mean before anything is downloaded.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { run } from './exec.mjs';
|
|
13
|
+
|
|
14
|
+
const UA = 'dsh-plugin-toolkit';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What the input is asking for. A local path wins over everything: an existing
|
|
18
|
+
* directory is never a repository name, and guessing otherwise would send a
|
|
19
|
+
* private folder to github.com.
|
|
20
|
+
*/
|
|
21
|
+
export function parseTarget(input) {
|
|
22
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
23
|
+
if (raw === '') return { kind: 'empty' };
|
|
24
|
+
// `~/kit` is how a person writes a home folder; `fs.existsSync` would call it
|
|
25
|
+
// a search and send the name to github.com.
|
|
26
|
+
const expanded = raw === '~' || raw.startsWith('~/') === true ? path.join(os.homedir(), raw.slice(1)) : raw;
|
|
27
|
+
if (fs.existsSync(expanded) === true) return { kind: 'local', dir: path.resolve(expanded) };
|
|
28
|
+
|
|
29
|
+
const url = raw.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#?]+)(?:\/.*)?$/i);
|
|
30
|
+
if (url !== null) {
|
|
31
|
+
const repo = url[2].replace(/\.git$/i, '');
|
|
32
|
+
return { kind: 'repo', owner: url[1], repo };
|
|
33
|
+
}
|
|
34
|
+
const slug = raw.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/);
|
|
35
|
+
if (slug !== null) return { kind: 'repo', owner: slug[1], repo: slug[2].replace(/\.git$/i, '') };
|
|
36
|
+
// A bare name is a search, not a guess: `dsh-plugin-example` is a repository
|
|
37
|
+
// name and also a word that matches forty of them.
|
|
38
|
+
return { kind: 'search', query: raw };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function json(response, what) {
|
|
42
|
+
if (response.ok !== true) {
|
|
43
|
+
throw new Error(`${what} answered ${response.status}${response.status === 403 ? ' (GitHub rate limit: unauthenticated requests are limited per hour)' : ''}`);
|
|
44
|
+
}
|
|
45
|
+
return response.json();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function searchRepos(query, { fetchImpl = fetch, limit = 5 } = {}) {
|
|
49
|
+
const url = `https://api.github.com/search/repositories?q=${encodeURIComponent(query)}+in:name&sort=stars&per_page=${limit}`;
|
|
50
|
+
const body = await json(await fetchImpl(url, { headers: { 'User-Agent': UA, Accept: 'application/vnd.github+json' } }), 'GitHub search');
|
|
51
|
+
return (body.items ?? []).map((item) => ({
|
|
52
|
+
fullName: item.full_name,
|
|
53
|
+
description: item.description ?? '',
|
|
54
|
+
stars: item.stargazers_count ?? 0,
|
|
55
|
+
url: item.html_url,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function fetchRepo({ owner, repo }, { fetchImpl = fetch, dir, extract = extractTarball } = {}) {
|
|
60
|
+
const target = dir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-toolkit-gh-'));
|
|
61
|
+
const tarball = path.join(target, 'repo.tar.gz');
|
|
62
|
+
const response = await fetchImpl(`https://codeload.github.com/${owner}/${repo}/tar.gz/HEAD`, {
|
|
63
|
+
headers: { 'User-Agent': UA },
|
|
64
|
+
redirect: 'follow',
|
|
65
|
+
});
|
|
66
|
+
if (response.ok !== true) throw new Error(`github.com/${owner}/${repo} answered ${response.status}`);
|
|
67
|
+
fs.writeFileSync(tarball, Buffer.from(await response.arrayBuffer()));
|
|
68
|
+
const root = extract(tarball, path.join(target, 'repo'));
|
|
69
|
+
fs.rmSync(tarball, { force: true });
|
|
70
|
+
return { dir: root, repo: { owner, repo, fullName: `${owner}/${repo}`, url: `https://github.com/${owner}/${repo}` } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** GitHub tarballs carry one top-level directory; strip it so `package.json` is at the root. */
|
|
74
|
+
function extractTarball(file, into) {
|
|
75
|
+
fs.mkdirSync(into, { recursive: true });
|
|
76
|
+
run('tar', ['-xzf', file, '-C', into, '--strip-components=1'], { stdio: 'pipe' });
|
|
77
|
+
return into;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Everything the check route needs: a folder to check, or the candidates a name
|
|
82
|
+
* could mean.
|
|
83
|
+
*/
|
|
84
|
+
export async function resolveTarget(input, options = {}) {
|
|
85
|
+
const parsed = parseTarget(input);
|
|
86
|
+
if (parsed.kind === 'empty') return { kind: 'empty' };
|
|
87
|
+
if (parsed.kind === 'local') return { kind: 'local', dir: parsed.dir };
|
|
88
|
+
if (parsed.kind === 'repo') {
|
|
89
|
+
const fetched = await fetchRepo(parsed, options);
|
|
90
|
+
return { kind: 'github', ...fetched };
|
|
91
|
+
}
|
|
92
|
+
const candidates = await searchRepos(parsed.query, options);
|
|
93
|
+
return { kind: 'candidates', query: parsed.query, candidates };
|
|
94
|
+
}
|
package/lib/init.mjs
CHANGED
|
@@ -150,7 +150,7 @@ export function files({ name, title = 'My panel', version = '0.1.0' }) {
|
|
|
150
150
|
'index.js': `export const name = '${name}';\n\n// The host half runs in Node. Most panels need nothing here.\nexport function apply() {}\n`,
|
|
151
151
|
'client.js': client({ name, title }),
|
|
152
152
|
'test/panel.test.mjs': test({ name, title }),
|
|
153
|
-
'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`,
|
|
153
|
+
'README.md': `# ${slug}\n\n${title} — a panel for DeepSeek Harness.\n\n[](https://github.com/WJDdidi-1/dsh-plugin-toolkit)\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`,
|
|
154
154
|
'.gitignore': 'node_modules/\n',
|
|
155
155
|
'.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 # verify, not doctor: doctor reads the working copy, so a \`files\` list\n # that forgets client.js passes it and the installed plugin has no panel.\n - uses: WJDdidi-1/dsh-plugin-toolkit@v1\n with:\n dir: .\n version: ${VERSION}\n`,
|
|
156
156
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-toolkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Preflight, release and test DeepSeek Harness plugins:
|
|
3
|
+
"version": "0.13.1",
|
|
4
|
+
"description": "Preflight, release and test DeepSeek Harness plugins: check a folder or any GitHub repository, wait for npm to actually serve the version, and keep the market index honest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"dsh-plugin": "bin/dsh-plugin.mjs"
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"README.md",
|
|
22
22
|
"README.zh.md",
|
|
23
23
|
"LICENSE",
|
|
24
|
-
"CHANGELOG.md"
|
|
24
|
+
"CHANGELOG.md",
|
|
25
|
+
"docs/badge.svg"
|
|
25
26
|
],
|
|
26
27
|
"engines": {
|
|
27
28
|
"node": ">=18",
|
|
@@ -33,7 +34,8 @@
|
|
|
33
34
|
"dsh",
|
|
34
35
|
"plugin-tooling",
|
|
35
36
|
"release",
|
|
36
|
-
"preflight"
|
|
37
|
+
"preflight",
|
|
38
|
+
"github"
|
|
37
39
|
],
|
|
38
40
|
"license": "Apache-2.0",
|
|
39
41
|
"repository": {
|