dsh-plugin-toolkit 0.12.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,26 @@
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
+
1
24
  ## 0.12.0
2
25
 
3
26
  Five rounds of the panel's own UI, each one driven by looking at the rendered
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
@@ -297,6 +300,17 @@ forgets `client.js` passes it and fails only after `npm i`.
297
300
  cannot state a host range. Pin `version:` if you would rather not track the
298
301
  latest release.
299
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
+
300
314
  ## What `doctor` checks
301
315
 
302
316
  | Check | Why it matters |
package/README.zh.md CHANGED
@@ -57,6 +57,8 @@ 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
 
62
64
  不开应用也能看:
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: 'Paste the folder that holds package.json, then press Check.',
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: '粘贴放着 package.json 的文件夹,然后按「检查」。',
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 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.',
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
- '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.',
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
- '粘贴放着 package.json 的那个文件夹(是文件夹,不是文件)。',
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;border:0.5px solid var(--dsw-alias-border-l2,#ccc);',
273
- 'border-radius:6px;overflow:hidden}',
274
- '.tk-lang-button{height:22px;padding:0 8px;border:0;background:none;font:inherit;font-size:11px;',
275
- 'color:var(--dsw-alias-label-secondary,#555);cursor:pointer}',
276
- '.tk-lang-button[data-on="true"]{background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff}',
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;font-size:12px;color:var(--dsw-alias-label-tertiary,#888)}',
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
- 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) {
343
373
  setFailed(translate('noDir'));
344
374
  return;
345
375
  }
346
376
  setBusy(true);
347
377
  setFailed(null);
348
- fetch(`/toolkit/check?dir=${encodeURIComponent(dir.trim())}`)
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 ResultBody({ result, tr, lang }) {
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
- typeof result.dir === 'string' && result.dir !== ''
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', 'aria-hidden': 'true' }, open === true ? '▾' : '▸')),
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/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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.12.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": {