@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 +1 -1
- package/src/client.js +160 -24
- package/src/github.js +176 -4
- package/src/index.js +40 -3
package/package.json
CHANGED
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 = "
|
|
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
|
-
|
|
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" }, "
|
|
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: "
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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, { [
|
|
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[
|
|
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" },
|
|
368
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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);
|