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 +54 -0
- package/README.md +24 -4
- package/README.zh.md +8 -0
- package/client.js +206 -39
- package/index.js +26 -3
- package/lib/github.mjs +94 -0
- package/lib/testkit/mount.mjs +4 -0
- package/package.json +4 -3
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`
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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:
|
|
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:
|
|
223
|
-
'
|
|
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;
|
|
232
|
-
'.tk-lang{display:inline-flex;flex:none;
|
|
233
|
-
'
|
|
234
|
-
'.tk-lang-button{height:
|
|
235
|
-
'color:var(--dsw-alias-label-secondary,#555);cursor:pointer
|
|
236
|
-
'.
|
|
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:
|
|
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;
|
|
248
|
-
'
|
|
249
|
-
'
|
|
250
|
-
'.tk-
|
|
251
|
-
'.tk-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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(
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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', {
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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/testkit/mount.mjs
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "Preflight, release and test DeepSeek Harness plugins:
|
|
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": {
|