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

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.9",
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" }, item.description) : 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,29 +271,119 @@ 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];
296
+ var _restarting = useState(false);
297
+ var restarting = _restarting[0];
298
+ var setRestarting = _restarting[1];
248
299
 
249
300
  var call = useCallback(function (endpoint, payload) {
250
301
  return rpc.call("/market", endpoint, payload || {}).then(function (res) {
251
- if (!res || res.ok !== true) throw new Error((res && res.error) || "market request failed");
302
+ if (!res || res.ok !== true) {
303
+ var err = res && res.error;
304
+ var msg = err && typeof err === "object" ? (err.message || JSON.stringify(err)) : (err || "market request failed");
305
+ throw new Error(msg);
306
+ }
252
307
  return res.value;
253
308
  });
254
309
  }, [rpc]);
255
310
 
311
+ // 每页搜索结果到货后批量验证(node 侧 raw CDN,带缓存),失败静默——
312
+ // 徽章缺失可接受,不该打断浏览。
313
+ var verifyPage = useCallback(function (items) {
314
+ var repos = (items || []).map(function (it) { return it.fullName; });
315
+ if (repos.length === 0) return;
316
+ call("verify", { repos: repos }).then(function (value) {
317
+ setVerified(function (prev) { return Object.assign({}, prev, value.results); });
318
+ }).catch(function () { /* 徽章缺失即可 */ });
319
+ }, [call]);
320
+
256
321
  var doSearch = useCallback(function () {
257
322
  setLoading(true);
258
323
  setError(null);
324
+ setPage(1);
325
+ setReachedLimit(false);
326
+ setRetryAt(0);
259
327
  call("search", { query: query, sort: sort, perPage: 20 }).then(function (value) {
260
328
  setResults(value);
329
+ setVerified({});
330
+ verifyPage(value.items);
261
331
  }).catch(function (e) {
262
332
  setError(errorText(e));
263
333
  }).finally(function () {
264
334
  setLoading(false);
265
335
  });
266
- }, [call, query, sort]);
336
+ }, [call, query, sort, verifyPage]);
337
+
338
+ // 无限滚动:哨兵进入视口时拉下一页并追加。GitHub topic 的翻页间数据
339
+ // 可能移动造成重复,按 fullName 去重;已显示数追上 total 即到底。
340
+ var canLoadMore = results !== null && results.items.length < results.total && !reachedLimit;
341
+ var loadMore = useCallback(function () {
342
+ if (!canLoadMore || loading || loadingMore) return;
343
+ // 限流熔断:失败后 60s 内不再自动请求(哨兵重渲染会反复触发
344
+ // IntersectionObserver,不熔断会连环 500 直到限流窗口过去)。
345
+ if (Date.now() < retryAt) return;
346
+ setLoadingMore(true);
347
+ call("search", { query: query, sort: sort, perPage: 20, page: page + 1 }).then(function (value) {
348
+ if (value.truncated === true) { setReachedLimit(true); return; }
349
+ setPage(page + 1);
350
+ verifyPage(value.items);
351
+ setResults(function (prev) {
352
+ if (prev === null) return value;
353
+ var seen = {};
354
+ var merged = [];
355
+ prev.items.concat(value.items).forEach(function (it) {
356
+ if (seen[it.fullName] === true) return;
357
+ seen[it.fullName] = true;
358
+ merged.push(it);
359
+ });
360
+ return { total: value.total, items: merged };
361
+ });
362
+ }).catch(function (e) {
363
+ setError(errorText(e));
364
+ setRetryAt(Date.now() + 60000);
365
+ }).finally(function () {
366
+ setLoadingMore(false);
367
+ });
368
+ }, [call, canLoadMore, loading, loadingMore, page, query, sort, retryAt]);
369
+
370
+ useEffect(function () {
371
+ var node = sentinelRef.current;
372
+ if (node === null || node === undefined) return undefined;
373
+ if (typeof IntersectionObserver === "undefined") return undefined;
374
+ var observer = new IntersectionObserver(function (entries) {
375
+ for (var index = 0; index < entries.length; index++) {
376
+ if (entries[index].isIntersecting) { loadMore(); break; }
377
+ }
378
+ }, { rootMargin: "300px" });
379
+ observer.observe(node);
380
+ return function () { observer.disconnect(); };
381
+ }, [loadMore]);
267
382
 
268
383
  var refreshInstalled = useCallback(function () {
269
384
  call("installed", {}).then(function (value) {
270
385
  setInstalled(value);
386
+ call("updates", {}).then(setUpdates).catch(function () { setUpdates(null); });
271
387
  }).catch(function (e) {
272
388
  setInstalled({ error: errorText(e) });
273
389
  });
@@ -283,10 +399,12 @@ window.__ModuleLoader__.load({
283
399
  // eslint-disable-next-line react-hooks/exhaustive-deps
284
400
  }, []);
285
401
 
286
- var doInstall = useCallback(function (repo) {
287
- var spec = "github:" + repo;
402
+ // 通用安装入口:spec 可以是 github:owner/repo(卡片按钮)或 npm 包名
403
+ // (已装面板的更新按钮)。后端会把同源发布的 github spec 改写为 npm
404
+ // tarball 安装(更快),job 里记录的是最终 spec。
405
+ var doInstallSpec = useCallback(function (spec) {
288
406
  setInstalling(function (prev) {
289
- var next = Object.assign({}, prev, { [repo]: true });
407
+ var next = Object.assign({}, prev, { [spec]: true });
290
408
  return next;
291
409
  });
292
410
  setError(null);
@@ -297,12 +415,40 @@ window.__ModuleLoader__.load({
297
415
  }).finally(function () {
298
416
  setInstalling(function (prev) {
299
417
  var next = Object.assign({}, prev);
300
- delete next[repo];
418
+ delete next[spec];
301
419
  return next;
302
420
  });
303
421
  });
304
422
  }, [call, track]);
305
423
 
424
+ var doInstall = useCallback(function (repo) {
425
+ doInstallSpec("github:" + repo);
426
+ }, [doInstallSpec]);
427
+
428
+ // 一键重启:loopback-only 的 /market restart 端点会 detached 拉起新的
429
+ // dsh 进程后退出当前进程;这里轮询 host 恢复后自动刷新页面。
430
+ var doRestart = useCallback(function () {
431
+ if (typeof window !== "undefined" && typeof window.confirm === "function") {
432
+ if (window.confirm("重启 dsh?正在进行的任务会中断。") !== true) return;
433
+ }
434
+ setRestarting(true);
435
+ setError(null);
436
+ call("restart", {}).then(function () {
437
+ var tries = 0;
438
+ var ping = setInterval(function () {
439
+ tries++;
440
+ if (tries > 40) { clearInterval(ping); return; }
441
+ rpc.call("/market", "installed", {}).then(function () {
442
+ clearInterval(ping);
443
+ window.location.reload();
444
+ }).catch(function () { /* host 还在重启,继续等 */ });
445
+ }, 3000);
446
+ }).catch(function (e) {
447
+ setRestarting(false);
448
+ setError(errorText(e));
449
+ });
450
+ }, [call, rpc]);
451
+
306
452
  var doUninstall = useCallback(function (name) {
307
453
  setRemoving(function (prev) {
308
454
  var next = Object.assign({}, prev, { [name]: true });
@@ -330,6 +476,15 @@ window.__ModuleLoader__.load({
330
476
  var jobsActive = false;
331
477
  for (var jid in jobs) { if (jobs[jid] && jobs[jid].status === "running") { jobsActive = true; break; } }
332
478
 
479
+ // "只看已验证"开关下的可见项:verify 判定 bundle/client 的才算插件。
480
+ // verifyPending:当前加载的仓库里还有未验证完的(verified map 尚未覆盖)。
481
+ var visibleItems = results === null ? [] : results.items.filter(function (it) {
482
+ if (verifiedOnly !== true) return true;
483
+ var v = verified[it.fullName];
484
+ return v !== undefined && (v.kind === "bundle" || v.kind === "client");
485
+ });
486
+ var verifyPending = results !== null && results.items.some(function (it) { return verified[it.fullName] === undefined; });
487
+
333
488
  return h("div", { className: "mkt_root" },
334
489
  h("div", { className: "mkt_head" },
335
490
  h("div", { className: "mkt_title" }, "插件市场"),
@@ -353,10 +508,14 @@ window.__ModuleLoader__.load({
353
508
  h("option", { value: "forks" }, "按 fork")
354
509
  ),
355
510
  h("button", { className: "mkt_btn mkt_btnPrimary", disabled: loading, onClick: doSearch }, loading ? "搜索中…" : "搜索"),
356
- h("button", { className: "mkt_btn", onClick: refreshInstalled }, "刷新已装")
511
+ h("button", { className: "mkt_btn", onClick: refreshInstalled }, "刷新已装"),
512
+ h("button", { className: "mkt_btn mkt_btnDanger", disabled: restarting, onClick: doRestart }, restarting ? "重启中…" : "重启 dsh"),
513
+ h("label", { className: "mkt_check" },
514
+ h("input", { type: "checkbox", checked: verifiedOnly, onChange: function (e) { setVerifiedOnly(e.target.checked); } }),
515
+ "只看已验证插件")
357
516
  ),
358
517
  error ? h("div", { className: "mkt_error" }, error) : null,
359
- h(InstalledPanel, { installed: installed, removing: removing, onUninstall: doUninstall }),
518
+ h(InstalledPanel, { installed: installed, removing: removing, updates: updates, onUninstall: doUninstall, onInstallSpec: doInstallSpec }),
360
519
  jobsActive ? h(JobsPanel, { jobs: jobs }) : null,
361
520
  h("div", { className: "mkt_list" },
362
521
  results == null
@@ -364,8 +523,13 @@ window.__ModuleLoader__.load({
364
523
  : results.items.length === 0
365
524
  ? h("div", { className: "mkt_meta mkt_listHead" }, "没有匹配的仓库。")
366
525
  : h(React.Fragment, null,
367
- h("div", { className: "mkt_meta mkt_listHead" }, "共 " + results.total + " 个仓库(显示 " + results.items.length + " 个)"),
368
- results.items.map(function (item) {
526
+ h("div", { className: "mkt_meta mkt_listHead" }, verifiedOnly
527
+ ? "已验证插件 " + visibleItems.length + " 个 · 已加载 " + results.items.length + "/" + results.total + " 个仓库(star≥1)" + (verifyPending ? " · 验证中…" : "")
528
+ : "共 " + results.total + " 个仓库(star≥1)· 已显示 " + results.items.length + " 个"),
529
+ visibleItems.length === 0 && verifiedOnly
530
+ ? h("div", { className: "mkt_meta mkt_listHead" }, verifyPending ? "正在验证仓库是否为 dsh 插件…" : "当前加载的结果里没有已验证的 dsh 插件,下滑加载更多。")
531
+ : null,
532
+ visibleItems.map(function (item) {
369
533
  var installJob = null;
370
534
  for (var jobId in jobs) {
371
535
  if (jobs[jobId] && jobs[jobId].spec === "github:" + item.fullName) { installJob = jobs[jobId]; break; }
@@ -373,11 +537,15 @@ window.__ModuleLoader__.load({
373
537
  return h(RepoCard, {
374
538
  key: item.fullName,
375
539
  item: item,
376
- installing: installing[item.fullName] === true,
540
+ installing: installing[item.fullName] === true || installing["github:" + item.fullName] === true,
377
541
  installJob: installJob,
542
+ verified: verified[item.fullName],
378
543
  onInstall: doInstall,
379
544
  });
380
- })
545
+ }),
546
+ canLoadMore
547
+ ? h("div", { className: "mkt_loadMore", ref: sentinelRef }, loadingMore ? "加载中…" : Date.now() < retryAt ? "GitHub 限流中,稍后再下滑加载" : "下滑加载更多")
548
+ : h("div", { className: "mkt_loadMore" }, reachedLimit ? "已达 GitHub 搜索上限(前 1000 个结果)" : "已显示全部 " + results.items.length + " 个")
381
549
  )
382
550
  ),
383
551
  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
@@ -17,8 +17,9 @@ import z from "@deepseek-ai/schemastery";
17
17
  import { defineTool } from "@deepseek-ai/dsh-tools";
18
18
  import { existsSync, readFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
+ import { spawn } from "node:child_process";
20
21
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
21
- import { repoInfo, searchPlugins } from "./github.js";
22
+ import { repoInfo, searchPlugins, verifyPlugins, preferNpmSpec, npmPackageInfo, compareVersions } from "./github.js";
22
23
  import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker } from "./installer.js";
23
24
 
24
25
  export const name = "@1e0zj/dsh-plugin-mall";
@@ -28,6 +29,7 @@ export const Config = z.object({
28
29
  defaultProfile: z.string().default("web"),
29
30
  apiBase: z.string().default("https://api.github.com"),
30
31
  perPageMax: z.number().default(30),
32
+ allowRestart: z.boolean().default(true),
31
33
  });
32
34
 
33
35
  /** Clip long strings for compact model-facing output. */
@@ -128,7 +130,10 @@ function rpcOk(value) {
128
130
  }
129
131
 
130
132
  function rpcFail(error) {
131
- return { ok: false, error: error?.message ?? String(error) };
133
+ // dsh connection RPC 的响应信封校验(dsh-client-connection rpcResultSchema)
134
+ // 要求 error 为 discriminated object:{code, message, details}。code 取
135
+ // 通用 "internal",否则整条错误会被 zod 以 invalid_union 吞掉。
136
+ return { ok: false, error: { code: "internal", message: error?.message ?? String(error), details: {} } };
132
137
  }
133
138
 
134
139
  /**
@@ -145,7 +150,7 @@ function rpcFail(error) {
145
150
  * @returns the {ok, value|error} envelope.
146
151
  */
147
152
  async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
148
- const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30 } = config;
153
+ const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, allowRestart = true } = config;
149
154
  switch (endpoint) {
150
155
  case "search": {
151
156
  const perPage = Math.min(Math.max(Math.trunc(payload?.perPage ?? 10) || 10, 1), Math.trunc(perPageMax) || 30);
@@ -154,11 +159,35 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
154
159
  sort: payload?.sort ?? "stars",
155
160
  perPage,
156
161
  page: payload?.page ?? 1,
162
+ minStars: payload?.minStars,
157
163
  apiBase,
158
164
  token,
159
165
  });
160
166
  return rpcOk(result);
161
167
  }
168
+ case "verify": {
169
+ const result = await verifyPlugins({ repos: payload?.repos });
170
+ return rpcOk(result);
171
+ }
172
+ case "updates": {
173
+ const profile = String(payload?.profile ?? defaultProfile).trim();
174
+ let deps;
175
+ try {
176
+ resolveProfileDir(profile);
177
+ deps = listInstalled(profile).deps;
178
+ } catch (error) {
179
+ return rpcFail(new Error(`invalid profile: ${error.message}`));
180
+ }
181
+ const results = {};
182
+ await Promise.all(deps.map(async (dep) => {
183
+ if (dep.kind === "missing") { results[dep.name] = { latest: null }; return; }
184
+ const info = await npmPackageInfo(dep.name);
185
+ results[dep.name] = info === null
186
+ ? { latest: null }
187
+ : { latest: info.latest, hasUpdate: compareVersions(info.latest, dep.version) > 0 };
188
+ }));
189
+ return rpcOk(results);
190
+ }
162
191
  case "info": {
163
192
  const result = await repoInfo({ repo: payload?.repo, apiBase, token });
164
193
  return rpcOk(result);
@@ -180,6 +209,9 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
180
209
  } catch (error) {
181
210
  return rpcFail(error);
182
211
  }
212
+ // npm tarball 优先(小而快、带 integrity);registry 条目不同源的包名
213
+ // 视为抢注,回退 github: 全仓库 spec。
214
+ spec = await preferNpmSpec({ spec });
183
215
  try {
184
216
  const profileDir = resolveProfileDir(profile);
185
217
  if (!existsSync(join(profileDir, "package.json"))) ensureProfile(profile);
@@ -219,6 +251,25 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
219
251
  return rpcFail(error);
220
252
  }
221
253
  }
254
+ case "restart": {
255
+ // 一键重启:detached 拉起新 dsh 进程(用当前进程的 argv 重建启动命令)
256
+ // 后退出自己。仅 loopback 直连可调(channel 级 authority 已限制);
257
+ // allowRestart:false 时禁用(进程由 systemd/pm2 等托管时接管重启)。
258
+ if (allowRestart !== true) return rpcFail(new Error("restart disabled by config (allowRestart: false)"));
259
+ const script = process.argv[1];
260
+ const scriptArgs = process.argv.slice(2);
261
+ if (typeof script !== "string" || script.length === 0 || !existsSync(script)) {
262
+ return rpcFail(new Error("cannot determine the dsh launch command for an automatic restart — please restart manually"));
263
+ }
264
+ const relaunch = `"${process.execPath}" "${script}"${scriptArgs.length > 0 ? ` ${scriptArgs.map((arg) => `"${arg}"`).join(" ")}` : ""}`;
265
+ const launcher = process.platform === "win32"
266
+ ? `timeout /t 2 /nobreak >nul & ${relaunch}`
267
+ : `sleep 2 && ${relaunch}`;
268
+ const child = spawn(launcher, { shell: true, detached: true, stdio: "ignore", cwd: process.cwd(), windowsHide: true });
269
+ child.unref();
270
+ setTimeout(() => process.exit(0), 800);
271
+ return rpcOk({ restarting: true });
272
+ }
222
273
  case "jobCancel": {
223
274
  try {
224
275
  return rpcOk({ result: tracker.cancel(payload?.jobId) });
@@ -243,7 +294,17 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
243
294
  function registerRpcChannel(ctx, config, token) {
244
295
  const tracker = createJobTracker();
245
296
  ctx.inject(["connection"], (connectionCtx) => {
246
- connectionCtx.connection.rpc.handle("/market", (endpoint, payload, signal) => rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker), { authority: "loopback" });
297
+ connectionCtx.connection.rpc.handle("/market", async (endpoint, payload, signal) => {
298
+ try {
299
+ return await rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker);
300
+ } catch (error) {
301
+ // 没有这层兜底时连接层只会回一个 HTTP 500 "transport failure",
302
+ // 真实异常既到不了浏览器也不留痕。透传错误文本,同时把堆栈
303
+ // 打进 dsh 进程的 stderr(前台运行时可见)。
304
+ console.error(`[dsh-plugin-mall] /market/${String(endpoint)} failed:`, error);
305
+ return rpcFail(error);
306
+ }
307
+ }, { authority: "loopback" });
247
308
  });
248
309
  }
249
310
 
@@ -357,7 +418,7 @@ export function apply(ctx, config = {}) {
357
418
  },
358
419
  async execute(args, exec) {
359
420
  const profile = String(args.profile ?? defaultProfile).trim();
360
- const spec = normalizeSpec(args.spec);
421
+ const spec = await preferNpmSpec({ spec: normalizeSpec(args.spec) });
361
422
  let profileDir;
362
423
  try {
363
424
  profileDir = resolveProfileDir(profile);
package/src/installer.js CHANGED
@@ -361,6 +361,35 @@ export function createJobTracker() {
361
361
  };
362
362
  }
363
363
 
364
+ // ── pnpm self-heal (corepack) ───────────────────────────────────────────────
365
+
366
+ /**
367
+ * Try to provision pnpm once via `corepack enable pnpm` (corepack ships with
368
+ * Node). Output lands in the caller's job log; returns whether a retry of the
369
+ * pnpm spawn is worth attempting.
370
+ */
371
+ async function enablePnpmViaCorepack(push) {
372
+ push("\n[dsh-plugin-mall] pnpm not found on PATH — trying `corepack enable pnpm` once\n");
373
+ return await new Promise((resolve) => {
374
+ let proc;
375
+ try {
376
+ proc = spawn("corepack", ["enable", "pnpm"], {
377
+ env: process.env,
378
+ shell: process.platform === "win32",
379
+ stdio: ["ignore", "pipe", "pipe"],
380
+ windowsHide: true,
381
+ });
382
+ } catch {
383
+ resolve(false);
384
+ return;
385
+ }
386
+ proc.on("error", () => resolve(false));
387
+ proc.stdout?.on("data", (data) => push(data.toString()));
388
+ proc.stderr?.on("data", (data) => push(data.toString()));
389
+ proc.on("close", (code) => resolve(code === 0));
390
+ });
391
+ }
392
+
364
393
  // ── the background install job ──────────────────────────────────────────────
365
394
 
366
395
  /**
@@ -381,6 +410,7 @@ export function runInstall({ profile, spec }) {
381
410
  deltaQueue.push(text);
382
411
  };
383
412
  let current = undefined;
413
+ let pnpmSelfHealed = false;
384
414
 
385
415
  const spawnAdd = () => {
386
416
  const proc = spawn("pnpm", ["add", spec, "--reporter=append-only"], {
@@ -427,6 +457,17 @@ export function runInstall({ profile, spec }) {
427
457
 
428
458
  const settle = async (outcome) => {
429
459
  if (outcome.spawnError !== undefined) {
460
+ // pnpm 缺失时先尝试 corepack 自愈一次,成功则重跑安装。
461
+ if (outcome.spawnError.code === "ENOENT" && !pnpmSelfHealed) {
462
+ pnpmSelfHealed = true;
463
+ const healed = await enablePnpmViaCorepack(push);
464
+ if (healed) {
465
+ const retry = spawnAdd();
466
+ current = retry.proc;
467
+ return settle(await retry.done);
468
+ }
469
+ return { status: "failed", detail: "pnpm not found on PATH and `corepack enable pnpm` could not provision it — install pnpm (e.g. `npm i -g pnpm`) to manage profile plugins" };
470
+ }
430
471
  const hint = outcome.spawnError.code === "ENOENT"
431
472
  ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
432
473
  : `could not start pnpm: ${outcome.spawnError.message}`;
@@ -490,7 +531,7 @@ function failedNow(detail) {
490
531
  * `dsh.profile.bundles` (the removed dependency's bundle entry drops out) and
491
532
  * deletes the client loader row `ensureClientRow` had registered for it.
492
533
  */
493
- export function runRemove({ profile, packageName }) {
534
+ export function runRemove({ profile, packageName }, selfHealed = false) {
494
535
  let profileDir;
495
536
  try {
496
537
  profileDir = resolveProfileDir(profile);
@@ -522,8 +563,13 @@ export function runRemove({ profile, packageName }) {
522
563
  const done = new Promise((resolve) => {
523
564
  proc.on("error", (error) => resolve({ spawnError: error }));
524
565
  proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
525
- }).then((outcome) => {
566
+ }).then(async (outcome) => {
526
567
  if (outcome.spawnError !== undefined) {
568
+ // pnpm 缺失时先 corepack 自愈一次再重试(重试在新 producer 里跑)。
569
+ if (outcome.spawnError.code === "ENOENT" && selfHealed !== true) {
570
+ const healed = await enablePnpmViaCorepack(() => {});
571
+ if (healed) return runRemove({ profile, packageName }, true);
572
+ }
527
573
  const hint = outcome.spawnError.code === "ENOENT"
528
574
  ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
529
575
  : `could not start pnpm: ${outcome.spawnError.message}`;