dsh-plugin-toolkit 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,57 @@
1
+ ## 0.13.0
2
+
3
+ Three more rounds of the panel, and one new thing to point it at.
4
+
5
+ - **The language switch is a real segmented control.** It used to be a bordered
6
+ box with `overflow: hidden` and a filled button inside it: the fill was clipped
7
+ by the container's radius and the 0.5px border left a hairline of background
8
+ along two edges, which read as a missing colour block. Now the track is inset
9
+ and the selected pill is its own rounded shape, so the fill is complete at
10
+ every size and in both themes.
11
+ - **A repository name is enough.** The input takes a folder, `owner/repo`, or a
12
+ GitHub URL. A bare name searches GitHub, lists the matches with their stars
13
+ and descriptions, and checks the one you click — the picked repository is
14
+ fetched as a tarball into a temp folder, so nothing is cloned and nothing is
15
+ installed. Results name the repository and link to it instead of showing a
16
+ temp path.
17
+ - **Candidates are a list you can act on.** Owner is muted, the repository name
18
+ carries the weight, an arrow appears on the row, and the rows are keyboard
19
+ reachable with a visible focus ring.
20
+ - **The guide says what changed.** The "before you trust it" note no longer
21
+ claims there are no network calls: a local folder is still read-only, a
22
+ repository is downloaded once, and the note now says so.
23
+
24
+ ## 0.12.0
25
+
26
+ Five rounds of the panel's own UI, each one driven by looking at the rendered
27
+ thing rather than at the code.
28
+
29
+ - **The tool comes before the guide.** The input, the button and the result now
30
+ sit directly under the header, and the guide moved below a rule into a
31
+ foldable `使用说明`. It used to take a screen and a half of scrolling to reach
32
+ the one control the panel exists for. The docked body is capped at `76ch`, and
33
+ the language switch no longer wraps onto its own line in the sidebar tab.
34
+ - **Results answer first.** A summary bar states the verdict and the counts
35
+ (`3 项里 1 项没过`), the checked folder is shown in monospace, failures come
36
+ first with a tinted row and a left rule, and the passing checks fold into
37
+ `<details>` — closed when something failed, open when everything passed.
38
+ - **No state renders as nothing.** Before the first check the panel says what to
39
+ do; while a check runs the region is `aria-busy`; a request that never reaches
40
+ the host still shows the folder it was asked about.
41
+ - **Keyboard and screen reader.** `Escape` closes the dock from anywhere inside
42
+ it, the toggle carries `aria-controls`, and focus moves to the verdict when a
43
+ check lands, so the answer is announced instead of the input keeping focus.
44
+ - **The outcome survives collapsing.** A chip in the collapsed row reads
45
+ `1 项没过` / `通过`, so the result is visible without opening the panel again.
46
+ - `scripts/preview.mjs` renders the panel — collapsed, open, with a result, and
47
+ the sidebar tab — to one static HTML file, using the same testkit the tests
48
+ use. That is how these five rounds were reviewed; it reads the stylesheet from
49
+ the injected `<style>`, because a regex over the source truncates at the first
50
+ `;` inside a CSS string.
51
+ - Fixed in the testkit: passing both `host` and `fetch` to `mountClient` used to
52
+ ignore the stub, so every request answered 404 and the failure looked like the
53
+ panel's fault.
54
+
1
55
  ## 0.11.0
2
56
 
3
57
  - **A language switch, in the panel.** 中文 / EN sits in the dock's header row and
package/README.md CHANGED
@@ -20,10 +20,13 @@ npx dsh-plugin-toolkit@latest release --pr --merge # tag, wait for
20
20
  | changing a panel that is already installed | [`dev`](#2-develop-a-panel) | `mount`, the testkit, `audit` |
21
21
  | shipping a version | [`verify`](#3-ship-a-version) | `release`, `index` |
22
22
 
23
- It also installs as a plugin. `dsh plugin add dsh-plugin-toolkit` adds a
24
- **Plugin doctor** tab to the right sidebar: name the folder that holds a
25
- plugin's `package.json`, and it runs the same checks `verify` runs and lists
26
- what would fail — the same answers as the CLI, without leaving the app.
23
+ It also installs as a plugin. `dsh plugin add dsh-plugin-toolkit` puts a
24
+ **插件体检 / Plugin doctor** panel under the conversation and a tab in the right
25
+ sidebar. Give it a folder that holds a plugin's `package.json`, or just
26
+ `owner/repo` — or a repository name, which it searches GitHub for and lists the
27
+ candidates to pick from. It then runs the same checks `verify` runs and says
28
+ which one would break the install, with the reason on the line — the same
29
+ answers as the CLI, without leaving the app.
27
30
 
28
31
  ```
29
32
  $ dsh plugin add dsh-plugin-toolkit
@@ -101,6 +104,12 @@ in the right sidebar, for when the conversation is busy.
101
104
  The panel speaks both languages and opens in Chinese; the 中文 / EN switch sits
102
105
  in the dock's header row (and in the sidebar tab) and the choice is remembered.
103
106
 
107
+ Look at it without the app:
108
+
109
+ ```sh
110
+ node scripts/preview.mjs /tmp/panel.html # collapsed, open, with a result, and the tab
111
+ ```
112
+
104
113
  ## Test a panel without a browser
105
114
 
106
115
  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.
@@ -291,6 +300,17 @@ forgets `client.js` passes it and fails only after `npm i`.
291
300
  cannot state a host range. Pin `version:` if you would rather not track the
292
301
  latest release.
293
302
 
303
+ ## Check a plugin you have not cloned
304
+
305
+ The panel takes three kinds of input: a folder, `owner/repo`, or a GitHub URL.
306
+ A bare name is a search, not a guess — it asks GitHub, lists the matches with
307
+ their stars, and checks the one you pick. A repository is downloaded once as a
308
+ tarball into a temp folder, so nothing is cloned into your workspace and
309
+ nothing is installed.
310
+
311
+ The CLI checks local folders only; for a repository, clone it or let the panel
312
+ do the download.
313
+
294
314
  ## What `doctor` checks
295
315
 
296
316
  | Check | Why it matters |
package/README.zh.md CHANGED
@@ -57,8 +57,16 @@ cd my-panel && npm install && npm test
57
57
 
58
58
  检查器固定在**对话栏下方、输入框上方**:默认收起成一行,点开就是完整指南——这是给谁用的、怎么用、每一项检查拦什么、以及它证明不了什么——下面才是文件夹输入框和结果。同一个面板也在右侧栏有一个 **Plugin doctor** 标签页,对话区忙的时候用那个。
59
59
 
60
+ 输入框接受三种东西:本地文件夹、`owner/repo`、GitHub 链接。**只写一个名字会去 GitHub 搜**,列出候选仓库(带星数),点哪个查哪个;查仓库时只下载一次 tarball 到临时目录,不 clone 进你的工作区、不安装任何东西。
61
+
60
62
  面板中英双语,默认中文;**中文 / EN** 开关就在对话栏那一行的右侧(右侧栏标签里也有),选过之后会记住。
61
63
 
64
+ 不开应用也能看:
65
+
66
+ ```sh
67
+ node scripts/preview.mjs /tmp/panel.html # 收起、展开、有结果、侧栏标签
68
+ ```
69
+
62
70
  ## 不用浏览器测面板
63
71
 
64
72
  ```js
package/client.js CHANGED
@@ -3,7 +3,7 @@ window.__ModuleLoader__.load({
3
3
  factory: (require) => {
4
4
  const module = { exports: {} };
5
5
  const React = require('react');
6
- const { useState, useCallback, useEffect } = React;
6
+ const { useState, useCallback, useEffect, useRef } = React;
7
7
 
8
8
  const NS = 'dsh-plugin-toolkit';
9
9
  const KIND = 'toolkit';
@@ -18,6 +18,15 @@ window.__ModuleLoader__.load({
18
18
  checking: 'Checking…',
19
19
  pass: 'Everything the market checks passes.',
20
20
  fail: 'This would not install cleanly.',
21
+ counts: (failed, total) => `${failed} of ${total} checks failed`,
22
+ passedLabel: (n) => `${n} passed`,
23
+ empty: 'A folder path, owner/repo, or a GitHub link. A bare name searches GitHub.',
24
+ guideTitle: 'How to use this',
25
+ checkingNow: 'Checking…',
26
+ candidates: (n) => `${n} repositories match`,
27
+ chipFail: (n) => `${n} failed`,
28
+ chipPass: 'passes',
29
+ checked: 'Checked',
21
30
  noDir: 'Name the folder that holds the plugin\'s package.json.',
22
31
  failed: 'Could not run the checks',
23
32
  guideDescription: 'Run the install checks on a plugin folder',
@@ -33,6 +42,15 @@ window.__ModuleLoader__.load({
33
42
  checking: '检查中…',
34
43
  pass: '市场会检查的项都通过了。',
35
44
  fail: '这样装上去会出问题。',
45
+ counts: (failed, total) => `${total} 项里 ${failed} 项没过`,
46
+ passedLabel: (n) => `${n} 项通过`,
47
+ empty: '填文件夹路径、owner/repo 或 GitHub 链接;只写名字会去 GitHub 搜。',
48
+ guideTitle: '使用说明',
49
+ checkingNow: '正在检查…',
50
+ candidates: (n) => `找到 ${n} 个仓库`,
51
+ chipFail: (n) => `${n} 项没过`,
52
+ chipPass: '通过',
53
+ checked: '检查的是',
36
54
  noDir: '请填写放着 package.json 的插件文件夹。',
37
55
  failed: '检查没能跑起来',
38
56
  guideDescription: '对任意插件文件夹跑一遍安装前的检查',
@@ -55,7 +73,7 @@ window.__ModuleLoader__.load({
55
73
  steps: {
56
74
  title: 'How to use it',
57
75
  items: [
58
- 'Paste the folder that holds the plugin\'s package.json (a folder, not a file).',
76
+ 'Paste a folder, or type owner/repo or a GitHub link. A bare name searches GitHub and lists what it found.',
59
77
  'Press Check. Nothing is installed and none of your files are touched.',
60
78
  'Read the list: ✓ passes, ✗ would break the install, with one line saying why.',
61
79
  ],
@@ -75,7 +93,7 @@ window.__ModuleLoader__.load({
75
93
  notes: {
76
94
  title: 'Before you trust it',
77
95
  items: [
78
- 'Read-only: it does not install anything, write files, or make network calls beyond this one request.',
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.',
79
97
  'It looks at a folder, not at what you have installed — use dsh-plugin audit --host … for that.',
80
98
  'engines.dsh and README are market-index requirements; a plugin missing them still runs.',
81
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.',
@@ -94,7 +112,7 @@ window.__ModuleLoader__.load({
94
112
  steps: {
95
113
  title: '怎么用',
96
114
  items: [
97
- '粘贴放着 package.json 的那个文件夹(是文件夹,不是文件)。',
115
+ '填本地文件夹,或 owner/repo、GitHub 链接;只写名字会去 GitHub 搜,列出候选让你挑。',
98
116
  '按「检查」。不会安装任何东西,也不会改你的文件。',
99
117
  '看列表:✓ 通过,✗ 是装上去会坏的地方,右边一行说明原因。',
100
118
  ],
@@ -114,7 +132,7 @@ window.__ModuleLoader__.load({
114
132
  notes: {
115
133
  title: '用之前要知道的',
116
134
  items: [
117
- '只读:不安装、不写文件、除这一次请求外不联网。',
135
+ '不安装、不改你的项目。查本地文件夹时只读;查 GitHub 仓库时会下载一次 tarball,在临时目录里检查。',
118
136
  '它看的是文件夹,不是你已装的插件——查已装的用 dsh-plugin audit --host …。',
119
137
  'engines.dsh 与 README 是市场索引的要求,缺了插件照样能跑。',
120
138
  '它证明不了运行期行为。面板在真实应用里仍可能因为服务来晚了、注册抛错而失败;那些失败会写进 ~/.dsh/toolkit-client-report.ndjson。',
@@ -204,23 +222,66 @@ window.__ModuleLoader__.load({
204
222
 
205
223
  const CSS = [
206
224
  '.tk-panel{padding:16px;display:flex;flex-direction:column;gap:10px;font-size:13px}',
207
- '.tk-title{margin:0;font-size:14px;font-weight:600}',
225
+ '.tk-title{margin:0;flex:1;min-width:0;font-size:14px;font-weight:600}',
208
226
  '.tk-hint{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary,#888)}',
209
227
  '.tk-form{display:flex;gap:8px}',
210
228
  '.tk-input{flex:1;min-width:0;height:28px;padding:0 9px;font:inherit;font-size:12px;',
211
229
  'border:0.5px solid var(--dsw-alias-border-l2,#ccc);border-radius:6px;',
212
230
  'background:var(--dsw-alias-bg-layer-1,#fff);color:inherit}',
231
+ '.tk-input:hover{border-color:var(--dsw-alias-border-l3,#c9ccd1)}',
213
232
  '.tk-input:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:-1px}',
214
233
  '.tk-button{height:28px;padding:0 12px;border-radius:6px;border:0;cursor:pointer;font:inherit;',
215
234
  'background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff}',
216
- '.tk-button:disabled{opacity:.6;cursor:default}',
235
+ '.tk-button:hover{filter:brightness(1.06)}',
236
+ '.tk-button:disabled{opacity:.6;cursor:default;filter:none}',
237
+ '.tk-button:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:2px}',
238
+ '.tk-chip{flex:none;font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;',
239
+ 'font-variant-numeric:tabular-nums}',
240
+ '.tk-chip[data-ok="true"]{color:var(--dsw-alias-state-success-primary,#2f855a);',
241
+ 'background:var(--dsw-alias-state-success-bg,#eef8ee)}',
242
+ '.tk-chip[data-ok="false"]{color:var(--dsw-alias-state-error-primary,#c0392b);',
243
+ 'background:var(--dsw-alias-state-error-bg,#fdf3f2)}',
217
244
  '.tk-failed{margin:0;font-size:12px;color:var(--dsw-alias-state-error-primary,#c0392b)}',
245
+ '.tk-empty{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary,#888)}',
246
+ '.tk-passes{margin:0}',
247
+ '.tk-passes summary{cursor:pointer;font-size:11px;color:var(--dsw-alias-label-tertiary,#888);',
248
+ 'padding:2px 0;list-style:none}',
249
+ '.tk-passes summary::-webkit-details-marker{display:none}',
250
+ '.tk-passes summary::before{content:"▸ ";font-size:10px}',
251
+ '.tk-passes[open] summary::before{content:"▾ "}',
252
+ '.tk-passes .tk-rows{margin-top:4px}',
253
+ '.tk-summary{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}',
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}',
274
+ '.tk-checked{margin:0;font-size:11px;color:var(--dsw-alias-label-tertiary,#888);overflow-wrap:anywhere}',
275
+ '.tk-checked code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}',
218
276
  '.tk-verdict{margin:0;font-size:12px;font-weight:600}',
219
277
  '.tk-verdict[data-ok="true"]{color:var(--dsw-alias-state-success-primary,#2f855a)}',
220
278
  '.tk-verdict[data-ok="false"]{color:var(--dsw-alias-state-warn-primary,#b7791f)}',
221
279
  '.tk-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}',
222
- '.tk-row{display:flex;gap:8px;align-items:baseline;padding:5px 8px;border-radius:6px;',
223
- 'background:var(--dsw-alias-bg-layer-1,#fafafa)}',
280
+ '.tk-row{display:flex;gap:8px;align-items:baseline;padding:6px 8px;border-radius:6px;}',
281
+ '.tk-rows .tk-row + .tk-row{margin-top:3px}',
282
+ '.tk-row[data-ok="false"]{background:var(--dsw-alias-state-error-bg,#fdf3f2);',
283
+ 'box-shadow:inset 2px 0 0 var(--dsw-alias-state-error-primary,#c0392b)}',
284
+ '.tk-row[data-ok="true"] .tk-message{color:var(--dsw-alias-label-secondary,#555)}',
224
285
  '.tk-mark{width:12px;flex:none;font-weight:600}',
225
286
  '.tk-row[data-ok="true"] .tk-mark{color:var(--dsw-alias-state-success-primary,#2f855a)}',
226
287
  '.tk-row[data-ok="false"] .tk-mark{color:var(--dsw-alias-state-error-primary,#c0392b)}',
@@ -228,31 +289,47 @@ window.__ModuleLoader__.load({
228
289
  '.tk-check{font-size:11px;text-transform:uppercase;letter-spacing:.04em;',
229
290
  'color:var(--dsw-alias-label-tertiary,#888)}',
230
291
  '.tk-message{font-size:12px;overflow-wrap:anywhere}',
231
- '.tk-panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px}',
232
- '.tk-lang{display:inline-flex;flex:none;border:0.5px solid var(--dsw-alias-border-l2,#ccc);',
233
- 'border-radius:6px;overflow:hidden}',
234
- '.tk-lang-button{height:22px;padding:0 8px;border:0;background:none;font:inherit;font-size:11px;',
235
- 'color:var(--dsw-alias-label-secondary,#555);cursor:pointer}',
236
- '.tk-lang-button[data-on="true"]{background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff}',
292
+ '.tk-panel-head{display:flex;align-items:center;gap:8px}',
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)}',
237
301
  '.tk-lang-button:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:-1px}',
238
302
  '.tk-dock{border-top:0.5px solid var(--dsw-alias-border-l2,#e5e5e5);margin:0 12px}',
239
303
  '.tk-dock-head{display:flex}',
240
- '.tk-dock-toggle{display:flex;align-items:center;gap:8px;width:100%;padding:7px 2px;',
304
+ '.tk-dock-toggle{display:flex;align-items:center;gap:8px;flex:1;min-width:0;padding:7px 2px;',
241
305
  'border:0;background:none;font:inherit;font-size:12px;color:inherit;cursor:pointer;text-align:left}',
242
306
  '.tk-dock-toggle:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);',
243
307
  'outline-offset:-1px;border-radius:4px}',
244
308
  '.tk-dock-title{font-weight:600}',
245
309
  '.tk-dock-sub{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
246
310
  'color:var(--dsw-alias-label-tertiary,#888)}',
247
- '.tk-dock-chevron{flex:none;color:var(--dsw-alias-label-tertiary,#888)}',
248
- '.tk-dock-body{display:flex;flex-direction:column;gap:12px;padding:2px 2px 14px;',
249
- 'max-height:46vh;overflow:auto;font-size:12px}',
250
- '.tk-guide{margin:0;display:flex;flex-direction:column;gap:5px}',
251
- '.tk-guide h3{margin:0;font-size:12px;font-weight:600}',
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}',
315
+ '.tk-summary:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);',
316
+ 'outline-offset:2px;border-radius:4px}',
317
+ '.tk-dock-body{display:flex;flex-direction:column;gap:10px;max-width:76ch;padding:2px 2px 14px;',
318
+ 'max-height:60vh;overflow:auto;font-size:12px}',
319
+ '.tk-guide-all{margin-top:2px;padding-top:12px;border-top:0.5px solid var(--dsw-alias-border-l1,#ececec)}',
320
+ '.tk-guide-all[open]{display:flex;flex-direction:column;gap:14px}',
321
+ '.tk-guide-summary{cursor:pointer;font-size:11px;font-weight:600;list-style:none;',
322
+ 'color:var(--dsw-alias-label-secondary,#555);padding:2px 0}',
323
+ '.tk-guide-summary::-webkit-details-marker{display:none}',
324
+ '.tk-guide-summary::before{content:"▸ ";font-size:11px;color:var(--dsw-alias-label-tertiary,#888)}',
325
+ '.tk-guide-all[open] .tk-guide-summary::before{content:"▾ ";}',
326
+ '.tk-guide{margin:0;display:flex;flex-direction:column;gap:4px}',
327
+ '.tk-guide h3{margin:0;font-size:12px;font-weight:600;',
328
+ 'color:var(--dsw-alias-label-primary,#1f2329)}',
252
329
  '.tk-guide p{margin:0;color:var(--dsw-alias-label-secondary,#555)}',
253
330
  '.tk-guide ol,.tk-guide ul{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:3px;',
254
331
  'color:var(--dsw-alias-label-secondary,#555)}',
255
- '.tk-guide-rows{display:grid;grid-template-columns:auto 1fr;gap:3px 10px;margin:0}',
332
+ '.tk-guide-rows{display:grid;grid-template-columns:132px 1fr;gap:4px 12px;margin:0}',
256
333
  '.tk-guide-rows dt{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;',
257
334
  'color:var(--dsw-alias-label-primary,#222)}',
258
335
  '.tk-guide-rows dd{margin:0;color:var(--dsw-alias-label-secondary,#555)}',
@@ -288,14 +365,17 @@ window.__ModuleLoader__.load({
288
365
  const [busy, setBusy] = useState(false);
289
366
  const [failed, setFailed] = useState(null);
290
367
 
291
- const check = useCallback(() => {
292
- if (dir.trim().length === 0) {
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) {
293
373
  setFailed(translate('noDir'));
294
374
  return;
295
375
  }
296
376
  setBusy(true);
297
377
  setFailed(null);
298
- fetch(`/toolkit/check?dir=${encodeURIComponent(dir.trim())}`)
378
+ fetch(`/toolkit/check?dir=${encodeURIComponent(target.trim())}`)
299
379
  .then((response) => (response.ok === true ? response.json() : Promise.reject(new Error(String(response.status)))))
300
380
  .then((payload) => setResult(payload))
301
381
  .catch((error) => setFailed(`${translate('failed')} · ${error.message}`))
@@ -329,13 +409,78 @@ window.__ModuleLoader__.load({
329
409
  function Result({ checker }) {
330
410
  const lang = useLang();
331
411
  const tr = (key) => translate(key, lang);
332
- return h('div', { className: 'tk-result' },
412
+ return h('div', { className: 'tk-result', 'aria-busy': checker.busy === true ? 'true' : 'false' },
333
413
  checker.failed !== null ? h('p', { className: 'tk-failed', role: 'status' }, checker.failed) : null,
414
+ checker.failed === null && checker.result === null && checker.busy !== true
415
+ ? h('p', { className: 'tk-empty' }, tr('empty'))
416
+ : null,
334
417
  checker.result !== null
335
- ? h('div', { className: 'tk-result-body' },
336
- h('p', { className: 'tk-verdict', 'data-ok': checker.result.ok === true ? 'true' : 'false', role: 'status' },
337
- checker.result.ok === true ? tr('pass') : tr('fail')),
338
- h('ul', { className: 'tk-rows' }, checker.result.checks.map((entry) => h(Row, { key: entry.check, entry }))))
418
+ ? h(ResultBody, { result: checker.result, tr, lang, checker })
419
+ : null);
420
+ }
421
+
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 }) {
445
+ const summary = useRef(null);
446
+ // After a check the answer is the thing to read; leaving focus in the
447
+ // input means a screen reader announces nothing.
448
+ useEffect(() => {
449
+ summary.current?.focus();
450
+ }, [result]);
451
+ const checks = result.checks ?? [];
452
+ const failed = checks.filter((entry) => entry.ok !== true);
453
+ const passed = checks.filter((entry) => entry.ok === true);
454
+ // Failures first: a list that opens with six green lines buries the two
455
+ // that matter below the fold of a docked panel.
456
+ const ordered = [...failed, ...passed];
457
+ if (result.kind === 'candidates') {
458
+ return h(Candidates, { result, tr, lang, checker });
459
+ }
460
+ return h('div', { className: 'tk-result-body' },
461
+ h('div', { className: 'tk-summary', ref: summary, tabIndex: -1,
462
+ 'data-ok': result.ok === true ? 'true' : 'false', role: 'status' },
463
+ h('span', { className: 'tk-verdict' },
464
+ result.ok === true ? tr('pass') : tr('fail')),
465
+ failed.length > 0
466
+ ? h('span', { className: 'tk-count' }, COPY[lang].counts(failed.length, checks.length))
467
+ : null),
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 !== ''
475
+ ? h('p', { className: 'tk-checked' },
476
+ h('span', {}, `${tr('checked')} `),
477
+ h('code', {}, result.dir))
478
+ : null,
479
+ h('ul', { className: 'tk-rows' }, failed.map((entry) => h(Row, { key: entry.check, entry }))),
480
+ passed.length > 0
481
+ ? h('details', { className: 'tk-passes', open: failed.length === 0 ? true : undefined },
482
+ h('summary', {}, COPY[lang].passedLabel(passed.length)),
483
+ h('ul', { className: 'tk-rows' }, passed.map((entry) => h(Row, { key: entry.check, entry }))))
339
484
  : null);
340
485
  }
341
486
 
@@ -363,7 +508,15 @@ window.__ModuleLoader__.load({
363
508
  const lang = useLang();
364
509
  const guide = GUIDE[lang] ?? GUIDE.zh;
365
510
 
366
- return h('section', { className: 'tk-dock', 'data-open': open === true ? 'true' : 'false' },
511
+ return h('section', {
512
+ className: 'tk-dock',
513
+ 'data-open': open === true ? 'true' : 'false',
514
+ // Escape closes it wherever the focus is inside: a docked panel that
515
+ // traps the keyboard is worse than one that is hard to find.
516
+ onKeyDown: (event) => {
517
+ if (event.key === 'Escape' && open === true) setOpen(false);
518
+ },
519
+ },
367
520
  h('div', { className: 'tk-dock-head' },
368
521
  // The language switch is a sibling of the toggle, never inside it: a
369
522
  // button inside a button is invalid and React drops the inner one.
@@ -371,15 +524,31 @@ window.__ModuleLoader__.load({
371
524
  className: 'tk-dock-toggle',
372
525
  type: 'button',
373
526
  'aria-expanded': open === true ? 'true' : 'false',
527
+ 'aria-controls': 'dsh-toolkit-dock-body',
374
528
  onClick: () => setOpen(open !== true),
375
529
  },
376
530
  h('span', { className: 'tk-dock-title' }, translate('dockTitle', lang)),
377
531
  h('span', { className: 'tk-dock-sub' }, translate('dockSub', lang)),
378
- h('span', { className: 'tk-dock-chevron', 'aria-hidden': 'true' }, open === true ? '▾' : '▸')),
532
+ h('span', { className: 'tk-dock-chevron', 'data-open': open === true ? 'true' : 'false', 'aria-hidden': 'true' })),
533
+ open !== true && checker.result !== null
534
+ ? h('span', {
535
+ className: 'tk-chip',
536
+ 'data-ok': checker.result.ok === true ? 'true' : 'false',
537
+ }, checker.result.ok === true
538
+ ? COPY[lang].chipPass
539
+ : COPY[lang].chipFail((checker.result.checks ?? []).filter((entry) => entry.ok !== true).length))
540
+ : null,
379
541
  h(LangSwitch, { lang })),
380
542
  open === true
381
- ? h('div', { className: 'tk-dock-body' },
382
- h('div', { className: 'tk-guide' },
543
+ ? h('div', { className: 'tk-dock-body', id: 'dsh-toolkit-dock-body' },
544
+ // The input comes first: the guide is reference material, and making
545
+ // people scroll past a wall of prose to reach the one control they
546
+ // came for is how a docked panel stops being used.
547
+ h(Form, { checker }),
548
+ h(Result, { checker }),
549
+ h('details', { className: 'tk-guide-all', open: true },
550
+ h('summary', { className: 'tk-guide-summary' }, translate('guideTitle', lang)),
551
+ h('div', { className: 'tk-guide' },
383
552
  h('h3', {}, guide.who.title),
384
553
  h('p', {}, guide.who.body)),
385
554
  h('div', { className: 'tk-guide' },
@@ -395,11 +564,9 @@ window.__ModuleLoader__.load({
395
564
  h('div', { className: 'tk-guide' },
396
565
  h('h3', {}, guide.notes.title),
397
566
  h('ul', {}, guide.notes.items.map((item) => h('li', { key: item }, item)))),
398
- h('div', { className: 'tk-guide' },
399
- h('h3', {}, guide.cli.title),
400
- h('p', {}, h('code', {}, guide.cli.body))),
401
- h(Form, { checker }),
402
- h(Result, { checker }))
567
+ h('div', { className: 'tk-guide' },
568
+ h('h3', {}, guide.cli.title),
569
+ h('p', {}, h('code', {}, guide.cli.body)))))
403
570
  : null);
404
571
  }
405
572
 
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
- return sendJson(res, 200, await checkPlugin(url.searchParams.get('dir')));
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
- return sendJson(res, 500, { error: 'check-failed', message: String(error && error.message) });
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
+ }
@@ -69,6 +69,10 @@ export async function mountClient(options) {
69
69
  // host never throws, and "the service threw" is the failure mode that costs
70
70
  // the most to debug in the real app.
71
71
  const host = options.host ?? createHost({ locale: copy, fetch: fetchImpl, surface });
72
+ // A caller that passes both a host and a fetch stub means the stub for this
73
+ // mount: silently keeping the host's own fetch answers 404 to everything and
74
+ // the test then blames the panel.
75
+ if (options.host !== undefined && fetchImpl !== undefined) host.fetch = fetchImpl;
72
76
  // The panel calls the browser's fetch, not a service, so the stub has to be
73
77
  // the global one for the duration of the mount.
74
78
  const previousFetch = globalThis.fetch;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.11.0",
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.",
3
+ "version": "0.13.0",
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"
@@ -33,7 +33,8 @@
33
33
  "dsh",
34
34
  "plugin-tooling",
35
35
  "release",
36
- "preflight"
36
+ "preflight",
37
+ "github"
37
38
  ],
38
39
  "license": "Apache-2.0",
39
40
  "repository": {