@1e0zj/dsh-plugin-mall 0.1.7 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -33,8 +33,10 @@ window.__ModuleLoader__.load({
33
33
  ".mkt_list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;align-items:stretch;min-width:0}",
34
34
  "@media (max-width:820px){.mkt_list{grid-template-columns:minmax(0,1fr)}}",
35
35
  ".mkt_listHead{grid-column:1/-1;font-size:12px;color:var(--dsw-alias-label-tertiary)}",
36
+ ".mkt_loadMore{grid-column:1/-1;text-align:center;font-size:12px;color:var(--dsw-alias-label-tertiary);padding:10px 0}",
36
37
  ".mkt_card{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;background:var(--dsw-alias-bg-primary,#fff);display:flex;flex-direction:column;gap:6px;min-width:0}",
37
38
  ".mkt_cardHead{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}",
39
+ ".mkt_metaRow{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}",
38
40
  ".mkt_name{font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere}",
39
41
  ".mkt_meta{font-size:12px;color:var(--dsw-alias-label-tertiary)}",
40
42
  ".mkt_desc{font-size:12.5px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}",
@@ -44,12 +46,15 @@ window.__ModuleLoader__.load({
44
46
  ".mkt_pre{font-family:Consolas,Monaco,monospace;font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-secondary,#f6f7f8);border-radius:6px;padding:8px;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all}",
45
47
  ".mkt_ok{color:var(--dsw-alias-state-success-primary,#2f855a)}",
46
48
  ".mkt_badge{display:inline-block;border-radius:999px;padding:1px 8px;font-size:11px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary)}",
49
+ ".mkt_badgeOk{border-color:var(--dsw-alias-state-success-primary,#2f855a);color:var(--dsw-alias-state-success-primary,#2f855a)}",
50
+ ".mkt_check{display:flex;align-items:center;gap:4px;font-size:12.5px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap}",
47
51
  ".mkt_link{color:var(--dsw-alias-state-business-primary);font-size:12px;text-decoration:none;cursor:pointer}",
48
52
  ".mkt_installedHead{display:flex;align-items:center;gap:8px;width:100%;background:none;border:0;padding:0;margin:0;cursor:pointer;font:inherit;text-align:left}",
49
53
  ".mkt_installedHead:hover .mkt_panelTitle{color:var(--dsw-alias-state-business-primary)}",
50
54
  ".mkt_installedToggle{margin-left:auto}",
51
55
  ".mkt_depList{display:flex;flex-direction:column;gap:6px;margin-top:8px}",
52
56
  ".mkt_depRow{display:flex;justify-content:space-between;align-items:center;gap:8px}",
57
+ ".mkt_depActions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}",
53
58
  ].join("\n");
54
59
  var tagId = "@1e0zj/dsh-plugin-mall/market-tab.css";
55
60
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
@@ -125,6 +130,18 @@ window.__ModuleLoader__.load({
125
130
  return { jobs: jobs, track: track };
126
131
  }
127
132
 
133
+ // ── plugin verification badge ───────────────────────────────────────────
134
+ // verified 来自 /market verify 端点(node 侧拉 raw package.json 判定
135
+ // dsh.bundle/dsh.client 声明,进程内缓存)。unknown 不显示徽章。
136
+ function verifyBadge(verified) {
137
+ if (verified === undefined || verified === null) return null;
138
+ if (verified.kind === "bundle") return h("span", { className: "mkt_badge mkt_badgeOk" }, "宿主插件");
139
+ if (verified.kind === "client") return h("span", { className: "mkt_badge mkt_badgeOk" }, "UI插件");
140
+ if (verified.kind === "plain") return h("span", { className: "mkt_badge" }, "未声明");
141
+ if (verified.kind === "no-manifest") return h("span", { className: "mkt_badge" }, "无package.json");
142
+ return null;
143
+ }
144
+
128
145
  // ── repo card ───────────────────────────────────────────────────────────
129
146
  function RepoCard(props) {
130
147
  var item = props.item;
@@ -134,24 +151,27 @@ window.__ModuleLoader__.load({
134
151
  var installDisabled = installing;
135
152
  if (!installing && job) {
136
153
  if (job.status === "running") { installLabel = "安装中…"; installDisabled = true; }
137
- else if (job.status === "completed") { installLabel = "已装 · 重启生效"; installDisabled = true; }
154
+ else if (job.status === "completed") { installLabel = "已装 · 重启生效"; installDisabled = true; }
138
155
  else if (job.status === "failed") { installLabel = "安装失败 · 见任务日志"; }
139
156
  else { installLabel = "已取消 · 重试"; }
140
157
  }
141
158
  return h("div", { className: "mkt_card" },
142
159
  h("div", { className: "mkt_cardHead" },
143
160
  h("span", { className: "mkt_name" }, item.fullName),
161
+ verifyBadge(props.verified),
162
+ item.archived ? h("span", { className: "mkt_badge" }, "archived") : null
163
+ ),
164
+ item.description ? h("div", { className: "mkt_desc" }, clip(item.description, 180)) : null,
165
+ h("div", { className: "mkt_metaRow" },
144
166
  h("span", { className: "mkt_meta" }, "★" + item.stars),
145
167
  item.language ? h("span", { className: "mkt_meta" }, item.language) : null,
146
168
  item.license ? h("span", { className: "mkt_meta" }, item.license) : null,
147
- item.archived ? h("span", { className: "mkt_badge" }, "archived") : null
169
+ h("span", { className: "mkt_meta" }, "更新 " + (item.updatedAt || "").slice(0, 10))
148
170
  ),
149
- item.description ? h("div", { className: "mkt_desc" }, clip(item.description, 180)) : null,
150
- h("div", { className: "mkt_meta" }, "更新 " + (item.updatedAt || "").slice(0, 10)),
151
171
  h("div", { className: "mkt_cardActions" },
152
172
  h("button", { className: "mkt_btn mkt_btnPrimary", disabled: installDisabled, onClick: function () { props.onInstall(item.fullName); } },
153
173
  installLabel),
154
- h("a", { className: "mkt_btn", href: item.htmlUrl, target: "_blank", rel: "noreferrer" }, "前往仓库")
174
+ h("a", { className: "mkt_btn", href: item.htmlUrl, target: "_blank", rel: "noreferrer" }, "前往仓库")
155
175
  )
156
176
  );
157
177
  }
@@ -170,7 +190,7 @@ window.__ModuleLoader__.load({
170
190
  id + " · " + clip(job.spec || "", 40) + " · " + (job.status || "running")),
171
191
  job.detail ? h("div", { className: "mkt_desc" }, job.detail) : null,
172
192
  done && job.status === "completed"
173
- ? h("div", { className: "mkt_ok" }, "完成 — 重启 dsh 后生效")
193
+ ? h("div", { className: "mkt_ok" }, "完成 — 重启 dsh 后生效")
174
194
  : job.status === "failed"
175
195
  ? h("div", { className: "mkt_error" }, "失败,见下方输出")
176
196
  : null,
@@ -197,7 +217,7 @@ window.__ModuleLoader__.load({
197
217
  },
198
218
  h("span", { className: "mkt_panelTitle" }, "已装插件"),
199
219
  h("span", { className: "mkt_badge" }, count + " 个"),
200
- h("span", { className: "mkt_meta mkt_installedToggle" }, open ? "收起" : "展开")
220
+ h("span", { className: "mkt_meta mkt_installedToggle" }, open ? "收起" : "展开")
201
221
  ),
202
222
  installed.error
203
223
  ? h("div", { className: "mkt_error" }, installed.error)
@@ -206,14 +226,20 @@ window.__ModuleLoader__.load({
206
226
  ? h("div", { className: "mkt_meta" }, "还没有装过插件")
207
227
  : h("div", { className: "mkt_depList" }, (installed.deps || []).map(function (dep) {
208
228
  var busy = (props.removing || {})[dep.name] === true;
229
+ var upd = (props.updates || {})[dep.name];
209
230
  return h("div", { key: dep.name, className: "mkt_depRow" },
210
231
  h("span", { className: "mkt_desc" }, dep.name + "@" + dep.version),
211
- h("span", { className: "mkt_badge" }, kindLabel(dep.kind)),
212
- h("button", {
213
- className: "mkt_btn mkt_btnDanger mkt_btnSm",
214
- disabled: busy,
215
- onClick: function () { props.onUninstall(dep.name); },
216
- }, busy ? "卸载中…" : "卸载"));
232
+ h("span", { className: "mkt_depActions" },
233
+ h("span", { className: "mkt_badge" }, kindLabel(dep.kind)),
234
+ upd && upd.hasUpdate ? h("button", {
235
+ className: "mkt_btn mkt_btnSm",
236
+ onClick: function () { props.onInstallSpec(dep.name); },
237
+ }, "更新至 " + upd.latest) : null,
238
+ h("button", {
239
+ className: "mkt_btn mkt_btnDanger mkt_btnSm",
240
+ disabled: busy,
241
+ onClick: function () { props.onUninstall(dep.name); },
242
+ }, busy ? "卸载中…" : "卸载")));
217
243
  }))
218
244
  );
219
245
  }
@@ -245,6 +271,28 @@ window.__ModuleLoader__.load({
245
271
  var _removing = useState({});
246
272
  var removing = _removing[0];
247
273
  var setRemoving = _removing[1];
274
+ var _page = useState(1);
275
+ var page = _page[0];
276
+ var setPage = _page[1];
277
+ var _loadingMore = useState(false);
278
+ var loadingMore = _loadingMore[0];
279
+ var setLoadingMore = _loadingMore[1];
280
+ var sentinelRef = useRef(null);
281
+ var _verified = useState({});
282
+ var verified = _verified[0];
283
+ var setVerified = _verified[1];
284
+ var _verifiedOnly = useState(true);
285
+ var verifiedOnly = _verifiedOnly[0];
286
+ var setVerifiedOnly = _verifiedOnly[1];
287
+ var _updates = useState(null);
288
+ var updates = _updates[0];
289
+ var setUpdates = _updates[1];
290
+ var _reachedLimit = useState(false);
291
+ var reachedLimit = _reachedLimit[0];
292
+ var setReachedLimit = _reachedLimit[1];
293
+ var _retryAt = useState(0);
294
+ var retryAt = _retryAt[0];
295
+ var setRetryAt = _retryAt[1];
248
296
 
249
297
  var call = useCallback(function (endpoint, payload) {
250
298
  return rpc.call("/market", endpoint, payload || {}).then(function (res) {
@@ -253,21 +301,82 @@ window.__ModuleLoader__.load({
253
301
  });
254
302
  }, [rpc]);
255
303
 
304
+ // 每页搜索结果到货后批量验证(node 侧 raw CDN,带缓存),失败静默——
305
+ // 徽章缺失可接受,不该打断浏览。
306
+ var verifyPage = useCallback(function (items) {
307
+ var repos = (items || []).map(function (it) { return it.fullName; });
308
+ if (repos.length === 0) return;
309
+ call("verify", { repos: repos }).then(function (value) {
310
+ setVerified(function (prev) { return Object.assign({}, prev, value.results); });
311
+ }).catch(function () { /* 徽章缺失即可 */ });
312
+ }, [call]);
313
+
256
314
  var doSearch = useCallback(function () {
257
315
  setLoading(true);
258
316
  setError(null);
317
+ setPage(1);
318
+ setReachedLimit(false);
319
+ setRetryAt(0);
259
320
  call("search", { query: query, sort: sort, perPage: 20 }).then(function (value) {
260
321
  setResults(value);
322
+ setVerified({});
323
+ verifyPage(value.items);
261
324
  }).catch(function (e) {
262
325
  setError(errorText(e));
263
326
  }).finally(function () {
264
327
  setLoading(false);
265
328
  });
266
- }, [call, query, sort]);
329
+ }, [call, query, sort, verifyPage]);
330
+
331
+ // 无限滚动:哨兵进入视口时拉下一页并追加。GitHub topic 的翻页间数据
332
+ // 可能移动造成重复,按 fullName 去重;已显示数追上 total 即到底。
333
+ var canLoadMore = results !== null && results.items.length < results.total && !reachedLimit;
334
+ var loadMore = useCallback(function () {
335
+ if (!canLoadMore || loading || loadingMore) return;
336
+ // 限流熔断:失败后 60s 内不再自动请求(哨兵重渲染会反复触发
337
+ // IntersectionObserver,不熔断会连环 500 直到限流窗口过去)。
338
+ if (Date.now() < retryAt) return;
339
+ setLoadingMore(true);
340
+ call("search", { query: query, sort: sort, perPage: 20, page: page + 1 }).then(function (value) {
341
+ if (value.truncated === true) { setReachedLimit(true); return; }
342
+ setPage(page + 1);
343
+ verifyPage(value.items);
344
+ setResults(function (prev) {
345
+ if (prev === null) return value;
346
+ var seen = {};
347
+ var merged = [];
348
+ prev.items.concat(value.items).forEach(function (it) {
349
+ if (seen[it.fullName] === true) return;
350
+ seen[it.fullName] = true;
351
+ merged.push(it);
352
+ });
353
+ return { total: value.total, items: merged };
354
+ });
355
+ }).catch(function (e) {
356
+ setError(errorText(e));
357
+ setRetryAt(Date.now() + 60000);
358
+ }).finally(function () {
359
+ setLoadingMore(false);
360
+ });
361
+ }, [call, canLoadMore, loading, loadingMore, page, query, sort, retryAt]);
362
+
363
+ useEffect(function () {
364
+ var node = sentinelRef.current;
365
+ if (node === null || node === undefined) return undefined;
366
+ if (typeof IntersectionObserver === "undefined") return undefined;
367
+ var observer = new IntersectionObserver(function (entries) {
368
+ for (var index = 0; index < entries.length; index++) {
369
+ if (entries[index].isIntersecting) { loadMore(); break; }
370
+ }
371
+ }, { rootMargin: "300px" });
372
+ observer.observe(node);
373
+ return function () { observer.disconnect(); };
374
+ }, [loadMore]);
267
375
 
268
376
  var refreshInstalled = useCallback(function () {
269
377
  call("installed", {}).then(function (value) {
270
378
  setInstalled(value);
379
+ call("updates", {}).then(setUpdates).catch(function () { setUpdates(null); });
271
380
  }).catch(function (e) {
272
381
  setInstalled({ error: errorText(e) });
273
382
  });
@@ -283,10 +392,12 @@ window.__ModuleLoader__.load({
283
392
  // eslint-disable-next-line react-hooks/exhaustive-deps
284
393
  }, []);
285
394
 
286
- var doInstall = useCallback(function (repo) {
287
- var spec = "github:" + repo;
395
+ // 通用安装入口:spec 可以是 github:owner/repo(卡片按钮)或 npm 包名
396
+ // (已装面板的更新按钮)。后端会把同源发布的 github spec 改写为 npm
397
+ // tarball 安装(更快),job 里记录的是最终 spec。
398
+ var doInstallSpec = useCallback(function (spec) {
288
399
  setInstalling(function (prev) {
289
- var next = Object.assign({}, prev, { [repo]: true });
400
+ var next = Object.assign({}, prev, { [spec]: true });
290
401
  return next;
291
402
  });
292
403
  setError(null);
@@ -297,12 +408,16 @@ window.__ModuleLoader__.load({
297
408
  }).finally(function () {
298
409
  setInstalling(function (prev) {
299
410
  var next = Object.assign({}, prev);
300
- delete next[repo];
411
+ delete next[spec];
301
412
  return next;
302
413
  });
303
414
  });
304
415
  }, [call, track]);
305
416
 
417
+ var doInstall = useCallback(function (repo) {
418
+ doInstallSpec("github:" + repo);
419
+ }, [doInstallSpec]);
420
+
306
421
  var doUninstall = useCallback(function (name) {
307
422
  setRemoving(function (prev) {
308
423
  var next = Object.assign({}, prev, { [name]: true });
@@ -330,6 +445,15 @@ window.__ModuleLoader__.load({
330
445
  var jobsActive = false;
331
446
  for (var jid in jobs) { if (jobs[jid] && jobs[jid].status === "running") { jobsActive = true; break; } }
332
447
 
448
+ // "只看已验证"开关下的可见项:verify 判定 bundle/client 的才算插件。
449
+ // verifyPending:当前加载的仓库里还有未验证完的(verified map 尚未覆盖)。
450
+ var visibleItems = results === null ? [] : results.items.filter(function (it) {
451
+ if (verifiedOnly !== true) return true;
452
+ var v = verified[it.fullName];
453
+ return v !== undefined && (v.kind === "bundle" || v.kind === "client");
454
+ });
455
+ var verifyPending = results !== null && results.items.some(function (it) { return verified[it.fullName] === undefined; });
456
+
333
457
  return h("div", { className: "mkt_root" },
334
458
  h("div", { className: "mkt_head" },
335
459
  h("div", { className: "mkt_title" }, "插件市场"),
@@ -353,10 +477,13 @@ window.__ModuleLoader__.load({
353
477
  h("option", { value: "forks" }, "按 fork")
354
478
  ),
355
479
  h("button", { className: "mkt_btn mkt_btnPrimary", disabled: loading, onClick: doSearch }, loading ? "搜索中…" : "搜索"),
356
- h("button", { className: "mkt_btn", onClick: refreshInstalled }, "刷新已装")
480
+ h("button", { className: "mkt_btn", onClick: refreshInstalled }, "刷新已装"),
481
+ h("label", { className: "mkt_check" },
482
+ h("input", { type: "checkbox", checked: verifiedOnly, onChange: function (e) { setVerifiedOnly(e.target.checked); } }),
483
+ "只看已验证插件")
357
484
  ),
358
485
  error ? h("div", { className: "mkt_error" }, error) : null,
359
- h(InstalledPanel, { installed: installed, removing: removing, onUninstall: doUninstall }),
486
+ h(InstalledPanel, { installed: installed, removing: removing, updates: updates, onUninstall: doUninstall, onInstallSpec: doInstallSpec }),
360
487
  jobsActive ? h(JobsPanel, { jobs: jobs }) : null,
361
488
  h("div", { className: "mkt_list" },
362
489
  results == null
@@ -364,8 +491,13 @@ window.__ModuleLoader__.load({
364
491
  : results.items.length === 0
365
492
  ? h("div", { className: "mkt_meta mkt_listHead" }, "没有匹配的仓库。")
366
493
  : h(React.Fragment, null,
367
- h("div", { className: "mkt_meta mkt_listHead" }, "共 " + results.total + " 个仓库(显示 " + results.items.length + " 个)"),
368
- results.items.map(function (item) {
494
+ h("div", { className: "mkt_meta mkt_listHead" }, verifiedOnly
495
+ ? "已验证插件 " + visibleItems.length + " 个 · 已加载 " + results.items.length + "/" + results.total + " 个仓库(star≥1)" + (verifyPending ? " · 验证中…" : "")
496
+ : "共 " + results.total + " 个仓库(star≥1)· 已显示 " + results.items.length + " 个"),
497
+ visibleItems.length === 0 && verifiedOnly
498
+ ? h("div", { className: "mkt_meta mkt_listHead" }, verifyPending ? "正在验证仓库是否为 dsh 插件…" : "当前加载的结果里没有已验证的 dsh 插件,下滑加载更多。")
499
+ : null,
500
+ visibleItems.map(function (item) {
369
501
  var installJob = null;
370
502
  for (var jobId in jobs) {
371
503
  if (jobs[jobId] && jobs[jobId].spec === "github:" + item.fullName) { installJob = jobs[jobId]; break; }
@@ -373,11 +505,15 @@ window.__ModuleLoader__.load({
373
505
  return h(RepoCard, {
374
506
  key: item.fullName,
375
507
  item: item,
376
- installing: installing[item.fullName] === true,
508
+ installing: installing[item.fullName] === true || installing["github:" + item.fullName] === true,
377
509
  installJob: installJob,
510
+ verified: verified[item.fullName],
378
511
  onInstall: doInstall,
379
512
  });
380
- })
513
+ }),
514
+ canLoadMore
515
+ ? h("div", { className: "mkt_loadMore", ref: sentinelRef }, loadingMore ? "加载中…" : Date.now() < retryAt ? "GitHub 限流中,稍后再下滑加载" : "下滑加载更多")
516
+ : h("div", { className: "mkt_loadMore" }, reachedLimit ? "已达 GitHub 搜索上限(前 1000 个结果)" : "已显示全部 " + results.items.length + " 个")
381
517
  )
382
518
  ),
383
519
  jobsActive ? null : h(JobsPanel, { jobs: jobs })
package/src/github.js CHANGED
@@ -3,6 +3,8 @@
3
3
  // standalone (node src/github.js --self-test).
4
4
 
5
5
  const SEARCH_TOPIC = "topic:dsh-plugin";
6
+ /** GitHub search never serves past the first 1000 results. */
7
+ const SEARCH_WINDOW = 1000;
6
8
 
7
9
  export function buildHeaders(token) {
8
10
  const headers = {
@@ -14,8 +16,11 @@ export function buildHeaders(token) {
14
16
  return headers;
15
17
  }
16
18
 
19
+ const DEFAULT_API_BASE = "https://api.github.com";
20
+
17
21
  function apiUrl(apiBase, path) {
18
- const base = apiBase.endsWith("/") ? apiBase : `${apiBase}/`;
22
+ const raw = typeof apiBase === "string" && apiBase.length > 0 ? apiBase : DEFAULT_API_BASE;
23
+ const base = raw.endsWith("/") ? raw : `${raw}/`;
19
24
  return `${base}${path.replace(/^\//, "")}`;
20
25
  }
21
26
 
@@ -54,6 +59,7 @@ function pickRepo(item) {
54
59
  description: item.description ?? "",
55
60
  stars: item.stargazers_count ?? 0,
56
61
  forks: item.forks_count ?? 0,
62
+ isFork: item.fork === true,
57
63
  language: item.language,
58
64
  license: item.license?.spdx_id,
59
65
  topics: item.topics ?? [],
@@ -66,14 +72,32 @@ function pickRepo(item) {
66
72
  /**
67
73
  * Search repositories tagged `topic:dsh-plugin`, optionally narrowed by
68
74
  * keywords (name/description/readme match), star-ranked by default.
75
+ * `minStars` (default 1) is pushed into the query as `stars:>=N` so the
76
+ * topic's noise (empty/demo repos riding the tag) is filtered server-side
77
+ * and `total` stays accurate; pass 0 to disable.
69
78
  */
70
- export async function searchPlugins({ query, sort = "stars", perPage = 10, page = 1, apiBase, token, signal }) {
79
+ export async function searchPlugins({ query, sort = "stars", perPage = 10, page = 1, minStars, apiBase, token, signal }) {
71
80
  const trimmed = typeof query === "string" ? query.trim() : "";
72
- const q = trimmed.length > 0 ? `${SEARCH_TOPIC} ${trimmed}` : SEARCH_TOPIC;
81
+ const parts = [SEARCH_TOPIC];
82
+ if (trimmed.length > 0) parts.push(trimmed);
83
+ const safeMinStars = Math.max(Math.trunc(Number(minStars ?? 1)) || 0, 0);
84
+ if (safeMinStars > 0) parts.push(`stars:>=${safeMinStars}`);
85
+ const q = parts.join(" ");
73
86
  const safePerPage = Math.min(Math.max(Math.trunc(perPage) || 10, 1), 100);
74
87
  const safePage = Math.max(Math.trunc(page) || 1, 1);
75
88
  const path = `/search/repositories?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&order=desc&per_page=${safePerPage}&page=${safePage}`;
76
- const body = await requestJson(path, { apiBase, token, signal });
89
+ let body;
90
+ try {
91
+ body = await requestJson(path, { apiBase, token, signal });
92
+ } catch (error) {
93
+ // Past the first 1000 results GitHub 422s with "Only the first 1000
94
+ // search results are available" — surface that as a clean empty
95
+ // truncated page instead of a hard error.
96
+ if (/first 1000 search results/i.test(String(error?.message ?? ""))) {
97
+ return { total: SEARCH_WINDOW, page: safePage, perPage: safePerPage, items: [], truncated: true };
98
+ }
99
+ throw error;
100
+ }
77
101
  return {
78
102
  total: body.total_count ?? 0,
79
103
  page: safePage,
@@ -82,6 +106,154 @@ export async function searchPlugins({ query, sort = "stars", perPage = 10, page
82
106
  };
83
107
  }
84
108
 
109
+ // ── npm registry (prefer-npm installs + update checks) ──────────────────────
110
+ //
111
+ // npm tarballs beat GitHub whole-repo tarballs: smaller (files field only),
112
+ // faster, integrity-checked. `preferNpmSpec` rewrites a github: install spec
113
+ // to its npm package name — but only when the registry entry's repository URL
114
+ // points back at that GitHub repo, which doubles as an anti-squatting check
115
+ // (an unrelated package squatting the name never matches, install falls back
116
+ // to the explicit github: spec).
117
+
118
+ const NPM_REGISTRY = "https://registry.npmjs.org";
119
+ const npmCache = new Map(); // name -> {latest, repositoryUrl} | null (unknown/not found)
120
+
121
+ /**
122
+ * Look up a package on the npm registry (abbreviated metadata). Cached for
123
+ * the process lifetime; null means "not on npm / unreachable".
124
+ */
125
+ export async function npmPackageInfo(name) {
126
+ const clean = String(name ?? "").trim();
127
+ if (clean.length === 0 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(clean)) return null;
128
+ if (npmCache.has(clean)) return npmCache.get(clean);
129
+ let info = null;
130
+ try {
131
+ const response = await fetch(`${NPM_REGISTRY}/${clean.replace("/", "%2F")}`, {
132
+ headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/vnd.npm.install-v1+json" },
133
+ });
134
+ if (response.ok) {
135
+ const body = await response.json();
136
+ const latest = body?.["dist-tags"]?.latest;
137
+ if (typeof latest === "string") {
138
+ const rawRepository = body?.repository;
139
+ const repositoryUrl = typeof rawRepository === "string" ? rawRepository : rawRepository?.url;
140
+ info = { latest, repositoryUrl: typeof repositoryUrl === "string" ? repositoryUrl : undefined };
141
+ }
142
+ }
143
+ } catch {
144
+ info = null; // registry unreachable — caller falls back
145
+ }
146
+ npmCache.set(clean, info);
147
+ return info;
148
+ }
149
+
150
+ /**
151
+ * Rewrite "github:owner/repo" (or "owner/repo") to the npm package name when
152
+ * that package exists on npm AND its repository URL points back at the repo
153
+ * (anti-squatting). Anything else passes through untouched.
154
+ */
155
+ export async function preferNpmSpec({ spec }) {
156
+ const raw = String(spec ?? "");
157
+ const githubMatch = /^(?:github:)?([^/\s]+\/[^/\s]+?)(?:\.git)?$/i.exec(raw);
158
+ if (githubMatch === null) return raw;
159
+ const repo = githubMatch[1];
160
+ const { results } = await verifyPlugins({ repos: [repo] }); // cache hit after first verify
161
+ const declaredName = results[repo]?.name;
162
+ if (typeof declaredName !== "string") return raw;
163
+ const info = await npmPackageInfo(declaredName);
164
+ if (info === null || info.repositoryUrl === undefined) return raw;
165
+ const pointsBack = new RegExp(`github\\.com[/:]${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/|\\.git|$)`, "i").test(info.repositoryUrl);
166
+ return pointsBack ? declaredName : raw;
167
+ }
168
+
169
+ /** Loose semver-ish comparison: "0.2.10" vs "0.2.9" → 1. Non-numeric parts read as 0. */
170
+ export function compareVersions(a, b) {
171
+ const pa = String(a ?? "").split(".");
172
+ const pb = String(b ?? "").split(".");
173
+ for (let index = 0; index < Math.max(pa.length, pb.length); index++) {
174
+ const na = Number(pa[index]) || 0;
175
+ const nb = Number(pb[index]) || 0;
176
+ if (na !== nb) return na < nb ? -1 : 1;
177
+ }
178
+ return 0;
179
+ }
180
+ // ── plugin verification (raw CDN, no API quota) ─────────────────────────────
181
+ //
182
+ // The topic carries thousands of repos that are not dsh plugins at all. The
183
+ // authoritative signal is a package.json declaring `dsh.bundle.patch` (host
184
+ // bundle) or `dsh.client` (browser UI plugin) — the same contract
185
+ // classifyPackage applies locally. package.json is fetched from
186
+ // raw.githubusercontent.com (a CDN that does not consume REST API quota), so a
187
+ // page of 20 verifies in one burst even without a token. Results are cached
188
+ // for the process lifetime; a fetch failure caches "unknown" rather than
189
+ // retrying forever.
190
+
191
+ const RAW_BASE = "https://raw.githubusercontent.com";
192
+ const VERIFY_CONCURRENCY = 8;
193
+ const verifyCache = new Map();
194
+
195
+ // package.json is fetched from CDNs, not the REST API, so verification never
196
+ // burns API quota. jsDelivr first (reachable where raw.githubusercontent.com
197
+ // is blocked), raw as fallback; a 404 only means "no manifest" once EVERY
198
+ // reachable source 404s (jsDelivr lags new pushes, so one 404 is not final).
199
+ const RAW_SOURCES = [
200
+ (repo) => `https://cdn.jsdelivr.net/gh/${repo}@HEAD/package.json`,
201
+ (repo) => `${RAW_BASE}/${repo}/HEAD/package.json`,
202
+ ];
203
+
204
+ async function fetchRawPackageJson(repo, signal) {
205
+ let saw404 = false;
206
+ let lastError;
207
+ for (const buildUrl of RAW_SOURCES) {
208
+ try {
209
+ const response = await fetch(buildUrl(repo), {
210
+ headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
211
+ signal,
212
+ });
213
+ if (response.status === 404) { saw404 = true; continue; }
214
+ if (!response.ok) throw new Error(`source returned ${response.status}`);
215
+ return await response.json();
216
+ } catch (error) {
217
+ if (error?.name === "AbortError") throw error;
218
+ lastError = error;
219
+ }
220
+ }
221
+ if (saw404) return undefined;
222
+ throw lastError ?? new Error("no raw source reachable");
223
+ }
224
+
225
+ /**
226
+ * Verify repositories as real dsh plugins by their package.json declaration.
227
+ * @param {{repos: string[], signal?: AbortSignal}} options - "owner/name" list.
228
+ * @returns the {results} map: fullName -> {kind: "bundle"|"client"|"plain"|"no-manifest"|"unknown", name?, version?}.
229
+ */
230
+ export async function verifyPlugins({ repos, signal }) {
231
+ const wanted = [...new Set((Array.isArray(repos) ? repos : []).map(String)
232
+ .filter((repo) => /^[^/\s]+\/[^/\s]+$/.test(repo) && !repo.includes("..")))];
233
+ const pending = wanted.filter((repo) => !verifyCache.has(repo));
234
+ let cursor = 0;
235
+ const worker = async () => {
236
+ while (cursor < pending.length) {
237
+ const repo = pending[cursor++];
238
+ try {
239
+ const pkg = await fetchRawPackageJson(repo, signal);
240
+ const kind = pkg === undefined ? "no-manifest"
241
+ : typeof pkg.dsh?.bundle?.patch === "string" ? "bundle"
242
+ : pkg.dsh?.client !== undefined ? "client"
243
+ : "plain";
244
+ verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version });
245
+ } catch (error) {
246
+ if (error?.name === "AbortError") throw error;
247
+ verifyCache.set(repo, { kind: "unknown" });
248
+ }
249
+ }
250
+ };
251
+ await Promise.all(Array.from({ length: Math.min(VERIFY_CONCURRENCY, pending.length) }, worker));
252
+ const results = {};
253
+ for (const repo of wanted) results[repo] = verifyCache.get(repo) ?? { kind: "unknown" };
254
+ return { results };
255
+ }
256
+
85
257
  /**
86
258
  * Fetch one repository's metadata plus its package.json (base64-decoded),
87
259
  * which is what tells us whether it declares a dsh bundle patch.
package/src/index.js CHANGED
@@ -18,7 +18,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
18
18
  import { existsSync, readFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
21
- import { repoInfo, searchPlugins } from "./github.js";
21
+ import { repoInfo, searchPlugins, verifyPlugins, preferNpmSpec, npmPackageInfo, compareVersions } from "./github.js";
22
22
  import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker } from "./installer.js";
23
23
 
24
24
  export const name = "@1e0zj/dsh-plugin-mall";
@@ -154,11 +154,35 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
154
154
  sort: payload?.sort ?? "stars",
155
155
  perPage,
156
156
  page: payload?.page ?? 1,
157
+ minStars: payload?.minStars,
157
158
  apiBase,
158
159
  token,
159
160
  });
160
161
  return rpcOk(result);
161
162
  }
163
+ case "verify": {
164
+ const result = await verifyPlugins({ repos: payload?.repos });
165
+ return rpcOk(result);
166
+ }
167
+ case "updates": {
168
+ const profile = String(payload?.profile ?? defaultProfile).trim();
169
+ let deps;
170
+ try {
171
+ resolveProfileDir(profile);
172
+ deps = listInstalled(profile).deps;
173
+ } catch (error) {
174
+ return rpcFail(new Error(`invalid profile: ${error.message}`));
175
+ }
176
+ const results = {};
177
+ await Promise.all(deps.map(async (dep) => {
178
+ if (dep.kind === "missing") { results[dep.name] = { latest: null }; return; }
179
+ const info = await npmPackageInfo(dep.name);
180
+ results[dep.name] = info === null
181
+ ? { latest: null }
182
+ : { latest: info.latest, hasUpdate: compareVersions(info.latest, dep.version) > 0 };
183
+ }));
184
+ return rpcOk(results);
185
+ }
162
186
  case "info": {
163
187
  const result = await repoInfo({ repo: payload?.repo, apiBase, token });
164
188
  return rpcOk(result);
@@ -180,6 +204,9 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
180
204
  } catch (error) {
181
205
  return rpcFail(error);
182
206
  }
207
+ // npm tarball 优先(小而快、带 integrity);registry 条目不同源的包名
208
+ // 视为抢注,回退 github: 全仓库 spec。
209
+ spec = await preferNpmSpec({ spec });
183
210
  try {
184
211
  const profileDir = resolveProfileDir(profile);
185
212
  if (!existsSync(join(profileDir, "package.json"))) ensureProfile(profile);
@@ -243,7 +270,17 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
243
270
  function registerRpcChannel(ctx, config, token) {
244
271
  const tracker = createJobTracker();
245
272
  ctx.inject(["connection"], (connectionCtx) => {
246
- connectionCtx.connection.rpc.handle("/market", (endpoint, payload, signal) => rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker), { authority: "loopback" });
273
+ connectionCtx.connection.rpc.handle("/market", async (endpoint, payload, signal) => {
274
+ try {
275
+ return await rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker);
276
+ } catch (error) {
277
+ // 没有这层兜底时连接层只会回一个 HTTP 500 "transport failure",
278
+ // 真实异常既到不了浏览器也不留痕。透传错误文本,同时把堆栈
279
+ // 打进 dsh 进程的 stderr(前台运行时可见)。
280
+ console.error(`[dsh-plugin-mall] /market/${String(endpoint)} failed:`, error);
281
+ return rpcFail(error);
282
+ }
283
+ }, { authority: "loopback" });
247
284
  });
248
285
  }
249
286
 
@@ -357,7 +394,7 @@ export function apply(ctx, config = {}) {
357
394
  },
358
395
  async execute(args, exec) {
359
396
  const profile = String(args.profile ?? defaultProfile).trim();
360
- const spec = normalizeSpec(args.spec);
397
+ const spec = await preferNpmSpec({ spec: normalizeSpec(args.spec) });
361
398
  let profileDir;
362
399
  try {
363
400
  profileDir = resolveProfileDir(profile);