@1e0zj/dsh-plugin-mall 0.1.6 → 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 +177 -83
- package/src/github.js +176 -4
- package/src/index.js +40 -3
- package/src/installer.js +23 -12
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -33,25 +33,28 @@ 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}",
|
|
41
43
|
".mkt_cardActions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:auto;padding-top:4px}",
|
|
42
|
-
".
|
|
44
|
+
".mkt_cardActions .mkt_btn{text-decoration:none}",
|
|
43
45
|
".mkt_panelTitle{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}",
|
|
44
|
-
".mkt_panelRow{display:flex;align-items:center;gap:8px}",
|
|
45
|
-
".mkt_panelRow .mkt_link{margin-left:auto}",
|
|
46
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}",
|
|
47
47
|
".mkt_ok{color:var(--dsw-alias-state-success-primary,#2f855a)}",
|
|
48
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}",
|
|
49
51
|
".mkt_link{color:var(--dsw-alias-state-business-primary);font-size:12px;text-decoration:none;cursor:pointer}",
|
|
50
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}",
|
|
51
53
|
".mkt_installedHead:hover .mkt_panelTitle{color:var(--dsw-alias-state-business-primary)}",
|
|
52
54
|
".mkt_installedToggle{margin-left:auto}",
|
|
53
55
|
".mkt_depList{display:flex;flex-direction:column;gap:6px;margin-top:8px}",
|
|
54
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}",
|
|
55
58
|
].join("\n");
|
|
56
59
|
var tagId = "@1e0zj/dsh-plugin-mall/market-tab.css";
|
|
57
60
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
@@ -127,61 +130,52 @@ window.__ModuleLoader__.load({
|
|
|
127
130
|
return { jobs: jobs, track: track };
|
|
128
131
|
}
|
|
129
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
|
+
|
|
130
145
|
// ── repo card ───────────────────────────────────────────────────────────
|
|
131
146
|
function RepoCard(props) {
|
|
132
147
|
var item = props.item;
|
|
133
148
|
var installing = props.installing === true;
|
|
149
|
+
var job = props.installJob;
|
|
150
|
+
var installLabel = "安装";
|
|
151
|
+
var installDisabled = installing;
|
|
152
|
+
if (!installing && job) {
|
|
153
|
+
if (job.status === "running") { installLabel = "安装中…"; installDisabled = true; }
|
|
154
|
+
else if (job.status === "completed") { installLabel = "已装 · 重启生效"; installDisabled = true; }
|
|
155
|
+
else if (job.status === "failed") { installLabel = "安装失败 · 见任务日志"; }
|
|
156
|
+
else { installLabel = "已取消 · 重试"; }
|
|
157
|
+
}
|
|
134
158
|
return h("div", { className: "mkt_card" },
|
|
135
159
|
h("div", { className: "mkt_cardHead" },
|
|
136
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" },
|
|
137
166
|
h("span", { className: "mkt_meta" }, "★" + item.stars),
|
|
138
167
|
item.language ? h("span", { className: "mkt_meta" }, item.language) : null,
|
|
139
168
|
item.license ? h("span", { className: "mkt_meta" }, item.license) : null,
|
|
140
|
-
|
|
169
|
+
h("span", { className: "mkt_meta" }, "更新 " + (item.updatedAt || "").slice(0, 10))
|
|
141
170
|
),
|
|
142
|
-
item.description ? h("div", { className: "mkt_desc" }, clip(item.description, 180)) : null,
|
|
143
|
-
h("div", { className: "mkt_meta" }, "更新 " + (item.updatedAt || "").slice(0, 10)),
|
|
144
171
|
h("div", { className: "mkt_cardActions" },
|
|
145
|
-
h("button", { className: "mkt_btn mkt_btnPrimary", disabled:
|
|
146
|
-
|
|
147
|
-
h("
|
|
172
|
+
h("button", { className: "mkt_btn mkt_btnPrimary", disabled: installDisabled, onClick: function () { props.onInstall(item.fullName); } },
|
|
173
|
+
installLabel),
|
|
174
|
+
h("a", { className: "mkt_btn", href: item.htmlUrl, target: "_blank", rel: "noreferrer" }, "前往仓库")
|
|
148
175
|
)
|
|
149
176
|
);
|
|
150
177
|
}
|
|
151
178
|
|
|
152
|
-
// ── info panel ──────────────────────────────────────────────────────────
|
|
153
|
-
function InfoPanel(props) {
|
|
154
|
-
var info = props.info;
|
|
155
|
-
if (!info) return null;
|
|
156
|
-
var data = info.data;
|
|
157
|
-
return h("div", { className: "mkt_card mkt_infoCard", ref: props.panelRef },
|
|
158
|
-
h("div", { className: "mkt_panelRow" },
|
|
159
|
-
h("p", { className: "mkt_panelTitle" }, "仓库详情"),
|
|
160
|
-
props.onClose ? h("span", { className: "mkt_link", onClick: props.onClose }, "✕ 关闭") : null
|
|
161
|
-
),
|
|
162
|
-
info.loading ? h("div", { className: "mkt_meta" }, "加载中…")
|
|
163
|
-
: info.error ? h("div", { className: "mkt_error" }, info.error)
|
|
164
|
-
: !data ? null
|
|
165
|
-
: h(React.Fragment, null,
|
|
166
|
-
h("div", { className: "mkt_name" }, data.meta.fullName),
|
|
167
|
-
h("div", { className: "mkt_desc" }, clip(data.meta.description, 240)),
|
|
168
|
-
h("div", { className: "mkt_meta" },
|
|
169
|
-
"★" + data.meta.stars + " · " + (data.meta.language || "-") + " · " + (data.meta.license || "-") + " · 分支 " + data.meta.defaultBranch),
|
|
170
|
-
data.packageJson == null
|
|
171
|
-
? h("div", { className: "mkt_meta" }, "package.json: 未找到(可能不是 npm 插件)")
|
|
172
|
-
: h(React.Fragment, null,
|
|
173
|
-
h("div", { className: "mkt_meta" }, "package.json: " + (data.packageJson.name || "?") + "@" + (data.packageJson.version || "?")),
|
|
174
|
-
data.packageJson.dshBundlePatch != null
|
|
175
|
-
? h("div", { className: "mkt_ok" }, "✓ dsh.bundle 宿主插件")
|
|
176
|
-
: data.packageJson.dshClientPlatform != null
|
|
177
|
-
? h("div", { className: "mkt_ok" }, "✓ dsh.client 浏览器UI插件")
|
|
178
|
-
: h("div", { className: "mkt_meta" }, "⚠ 无 bundle/client 声明,装上是普通依赖")),
|
|
179
|
-
h("button", { className: "mkt_btn mkt_btnPrimary", onClick: function () { props.onInstall(data.meta.fullName); } }, "安装 github:" + data.meta.fullName),
|
|
180
|
-
h("a", { className: "mkt_link", href: data.meta.htmlUrl, target: "_blank", rel: "noreferrer" }, "打开仓库 ↗")
|
|
181
|
-
)
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
179
|
// ── jobs panel ──────────────────────────────────────────────────────────
|
|
186
180
|
function JobsPanel(props) {
|
|
187
181
|
var ids = Object.keys(props.jobs);
|
|
@@ -196,7 +190,7 @@ window.__ModuleLoader__.load({
|
|
|
196
190
|
id + " · " + clip(job.spec || "", 40) + " · " + (job.status || "running")),
|
|
197
191
|
job.detail ? h("div", { className: "mkt_desc" }, job.detail) : null,
|
|
198
192
|
done && job.status === "completed"
|
|
199
|
-
? h("div", { className: "mkt_ok" }, "
|
|
193
|
+
? h("div", { className: "mkt_ok" }, "完成 — 重启 dsh 后生效")
|
|
200
194
|
: job.status === "failed"
|
|
201
195
|
? h("div", { className: "mkt_error" }, "失败,见下方输出")
|
|
202
196
|
: null,
|
|
@@ -223,7 +217,7 @@ window.__ModuleLoader__.load({
|
|
|
223
217
|
},
|
|
224
218
|
h("span", { className: "mkt_panelTitle" }, "已装插件"),
|
|
225
219
|
h("span", { className: "mkt_badge" }, count + " 个"),
|
|
226
|
-
h("span", { className: "mkt_meta mkt_installedToggle" }, open ? "收起
|
|
220
|
+
h("span", { className: "mkt_meta mkt_installedToggle" }, open ? "收起" : "展开")
|
|
227
221
|
),
|
|
228
222
|
installed.error
|
|
229
223
|
? h("div", { className: "mkt_error" }, installed.error)
|
|
@@ -232,14 +226,20 @@ window.__ModuleLoader__.load({
|
|
|
232
226
|
? h("div", { className: "mkt_meta" }, "还没有装过插件")
|
|
233
227
|
: h("div", { className: "mkt_depList" }, (installed.deps || []).map(function (dep) {
|
|
234
228
|
var busy = (props.removing || {})[dep.name] === true;
|
|
229
|
+
var upd = (props.updates || {})[dep.name];
|
|
235
230
|
return h("div", { key: dep.name, className: "mkt_depRow" },
|
|
236
231
|
h("span", { className: "mkt_desc" }, dep.name + "@" + dep.version),
|
|
237
|
-
h("span", { className: "
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 ? "卸载中…" : "卸载")));
|
|
243
243
|
}))
|
|
244
244
|
);
|
|
245
245
|
}
|
|
@@ -262,9 +262,6 @@ window.__ModuleLoader__.load({
|
|
|
262
262
|
var _error = useState(null);
|
|
263
263
|
var error = _error[0];
|
|
264
264
|
var setError = _error[1];
|
|
265
|
-
var _info = useState(null);
|
|
266
|
-
var info = _info[0];
|
|
267
|
-
var setInfo = _info[1];
|
|
268
265
|
var _installed = useState(null);
|
|
269
266
|
var installed = _installed[0];
|
|
270
267
|
var setInstalled = _installed[1];
|
|
@@ -274,7 +271,28 @@ window.__ModuleLoader__.load({
|
|
|
274
271
|
var _removing = useState({});
|
|
275
272
|
var removing = _removing[0];
|
|
276
273
|
var setRemoving = _removing[1];
|
|
277
|
-
var
|
|
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];
|
|
278
296
|
|
|
279
297
|
var call = useCallback(function (endpoint, payload) {
|
|
280
298
|
return rpc.call("/market", endpoint, payload || {}).then(function (res) {
|
|
@@ -283,41 +301,82 @@ window.__ModuleLoader__.load({
|
|
|
283
301
|
});
|
|
284
302
|
}, [rpc]);
|
|
285
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
|
+
|
|
286
314
|
var doSearch = useCallback(function () {
|
|
287
315
|
setLoading(true);
|
|
288
316
|
setError(null);
|
|
317
|
+
setPage(1);
|
|
318
|
+
setReachedLimit(false);
|
|
319
|
+
setRetryAt(0);
|
|
289
320
|
call("search", { query: query, sort: sort, perPage: 20 }).then(function (value) {
|
|
290
321
|
setResults(value);
|
|
322
|
+
setVerified({});
|
|
323
|
+
verifyPage(value.items);
|
|
291
324
|
}).catch(function (e) {
|
|
292
325
|
setError(errorText(e));
|
|
293
326
|
}).finally(function () {
|
|
294
327
|
setLoading(false);
|
|
295
328
|
});
|
|
296
|
-
}, [call, query, sort]);
|
|
329
|
+
}, [call, query, sort, verifyPage]);
|
|
297
330
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
+
});
|
|
302
355
|
}).catch(function (e) {
|
|
303
|
-
|
|
356
|
+
setError(errorText(e));
|
|
357
|
+
setRetryAt(Date.now() + 60000);
|
|
358
|
+
}).finally(function () {
|
|
359
|
+
setLoadingMore(false);
|
|
304
360
|
});
|
|
305
|
-
}, [call]);
|
|
361
|
+
}, [call, canLoadMore, loading, loadingMore, page, query, sort, retryAt]);
|
|
306
362
|
|
|
307
|
-
var doCloseInfo = useCallback(function () {
|
|
308
|
-
setInfo(null);
|
|
309
|
-
}, []);
|
|
310
|
-
|
|
311
|
-
// 详情面板在列表上方;点"详情"时平滑滚到面板,免得用户找不到。
|
|
312
363
|
useEffect(function () {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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]);
|
|
317
375
|
|
|
318
376
|
var refreshInstalled = useCallback(function () {
|
|
319
377
|
call("installed", {}).then(function (value) {
|
|
320
378
|
setInstalled(value);
|
|
379
|
+
call("updates", {}).then(setUpdates).catch(function () { setUpdates(null); });
|
|
321
380
|
}).catch(function (e) {
|
|
322
381
|
setInstalled({ error: errorText(e) });
|
|
323
382
|
});
|
|
@@ -333,10 +392,12 @@ window.__ModuleLoader__.load({
|
|
|
333
392
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
334
393
|
}, []);
|
|
335
394
|
|
|
336
|
-
|
|
337
|
-
|
|
395
|
+
// 通用安装入口:spec 可以是 github:owner/repo(卡片按钮)或 npm 包名
|
|
396
|
+
// (已装面板的更新按钮)。后端会把同源发布的 github spec 改写为 npm
|
|
397
|
+
// tarball 安装(更快),job 里记录的是最终 spec。
|
|
398
|
+
var doInstallSpec = useCallback(function (spec) {
|
|
338
399
|
setInstalling(function (prev) {
|
|
339
|
-
var next = Object.assign({}, prev, { [
|
|
400
|
+
var next = Object.assign({}, prev, { [spec]: true });
|
|
340
401
|
return next;
|
|
341
402
|
});
|
|
342
403
|
setError(null);
|
|
@@ -347,12 +408,16 @@ window.__ModuleLoader__.load({
|
|
|
347
408
|
}).finally(function () {
|
|
348
409
|
setInstalling(function (prev) {
|
|
349
410
|
var next = Object.assign({}, prev);
|
|
350
|
-
delete next[
|
|
411
|
+
delete next[spec];
|
|
351
412
|
return next;
|
|
352
413
|
});
|
|
353
414
|
});
|
|
354
415
|
}, [call, track]);
|
|
355
416
|
|
|
417
|
+
var doInstall = useCallback(function (repo) {
|
|
418
|
+
doInstallSpec("github:" + repo);
|
|
419
|
+
}, [doInstallSpec]);
|
|
420
|
+
|
|
356
421
|
var doUninstall = useCallback(function (name) {
|
|
357
422
|
setRemoving(function (prev) {
|
|
358
423
|
var next = Object.assign({}, prev, { [name]: true });
|
|
@@ -376,6 +441,19 @@ window.__ModuleLoader__.load({
|
|
|
376
441
|
refreshInstalled();
|
|
377
442
|
}, [refreshInstalled]);
|
|
378
443
|
|
|
444
|
+
// 活动任务置顶(紧跟已装面板,点安装立刻看到进度);全部静止后归档到底部当日志。
|
|
445
|
+
var jobsActive = false;
|
|
446
|
+
for (var jid in jobs) { if (jobs[jid] && jobs[jid].status === "running") { jobsActive = true; break; } }
|
|
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
|
+
|
|
379
457
|
return h("div", { className: "mkt_root" },
|
|
380
458
|
h("div", { className: "mkt_head" },
|
|
381
459
|
h("div", { className: "mkt_title" }, "插件市场"),
|
|
@@ -399,30 +477,46 @@ window.__ModuleLoader__.load({
|
|
|
399
477
|
h("option", { value: "forks" }, "按 fork")
|
|
400
478
|
),
|
|
401
479
|
h("button", { className: "mkt_btn mkt_btnPrimary", disabled: loading, onClick: doSearch }, loading ? "搜索中…" : "搜索"),
|
|
402
|
-
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
|
+
"只看已验证插件")
|
|
403
484
|
),
|
|
404
485
|
error ? h("div", { className: "mkt_error" }, error) : null,
|
|
405
|
-
h(InstalledPanel, { installed: installed, removing: removing, onUninstall: doUninstall }),
|
|
406
|
-
h(
|
|
486
|
+
h(InstalledPanel, { installed: installed, removing: removing, updates: updates, onUninstall: doUninstall, onInstallSpec: doInstallSpec }),
|
|
487
|
+
jobsActive ? h(JobsPanel, { jobs: jobs }) : null,
|
|
407
488
|
h("div", { className: "mkt_list" },
|
|
408
489
|
results == null
|
|
409
490
|
? h("div", { className: "mkt_meta mkt_listHead" }, loading ? "正在加载最热插件…" : "—")
|
|
410
491
|
: results.items.length === 0
|
|
411
492
|
? h("div", { className: "mkt_meta mkt_listHead" }, "没有匹配的仓库。")
|
|
412
493
|
: h(React.Fragment, null,
|
|
413
|
-
h("div", { className: "mkt_meta mkt_listHead" },
|
|
414
|
-
|
|
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) {
|
|
501
|
+
var installJob = null;
|
|
502
|
+
for (var jobId in jobs) {
|
|
503
|
+
if (jobs[jobId] && jobs[jobId].spec === "github:" + item.fullName) { installJob = jobs[jobId]; break; }
|
|
504
|
+
}
|
|
415
505
|
return h(RepoCard, {
|
|
416
506
|
key: item.fullName,
|
|
417
507
|
item: item,
|
|
418
|
-
installing: installing[item.fullName] === true,
|
|
508
|
+
installing: installing[item.fullName] === true || installing["github:" + item.fullName] === true,
|
|
509
|
+
installJob: installJob,
|
|
510
|
+
verified: verified[item.fullName],
|
|
419
511
|
onInstall: doInstall,
|
|
420
|
-
onInfo: doInfo,
|
|
421
512
|
});
|
|
422
|
-
})
|
|
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 + " 个")
|
|
423
517
|
)
|
|
424
518
|
),
|
|
425
|
-
h(JobsPanel, { jobs: jobs })
|
|
519
|
+
jobsActive ? null : h(JobsPanel, { jobs: jobs })
|
|
426
520
|
);
|
|
427
521
|
}
|
|
428
522
|
|
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);
|
package/src/installer.js
CHANGED
|
@@ -218,19 +218,26 @@ export function removeClientRow(profileDir, packageName) {
|
|
|
218
218
|
// ── build-script allow-listing ──────────────────────────────────────────────
|
|
219
219
|
|
|
220
220
|
/** Extract package names from pnpm's "Ignored build scripts: ..." output. */
|
|
221
|
+
/** Valid npm package name (scoped or bare) — anything else is not a name. */
|
|
222
|
+
const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
|
|
223
|
+
|
|
221
224
|
function parseIgnoredBuilds(output) {
|
|
222
225
|
const names = new Set();
|
|
223
|
-
|
|
226
|
+
// Only pnpm's own notice line is a parsing source. "allowBuilds" also
|
|
227
|
+
// appears in pnpm's advice/error text (never followed by a name list), and
|
|
228
|
+
// matching it fed error echoes into the allow-list, corrupting the YAML.
|
|
229
|
+
const pattern = /(?:Ignored build scripts|onlyBuiltDependencies)\s*:\s*([^\n]+)/gi;
|
|
224
230
|
let match;
|
|
225
231
|
while ((match = pattern.exec(output)) !== null) {
|
|
226
232
|
for (const raw of match[1].split(",")) {
|
|
227
|
-
const
|
|
228
|
-
if (
|
|
229
|
-
// Strip a trailing @version so `foo@1.2.3` -> `foo`,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
233
|
+
const candidate = raw.trim();
|
|
234
|
+
if (candidate.length === 0) continue;
|
|
235
|
+
// Strip a trailing @version or @tarball-url so `foo@1.2.3` -> `foo`,
|
|
236
|
+
// `@s/n@1.0.0` -> `@s/n`, `@s/n@https://…` -> `@s/n`. Whatever remains
|
|
237
|
+
// must be a valid npm name; pnpm error echoes ("9 | - pkg", advice
|
|
238
|
+
// sentences) are dropped instead of being written to the YAML.
|
|
239
|
+
const name = candidate.replace(/@(?:[\w.+-]+|https?:\/\/\S+|file:\S+|link:\S+|github:\S+)$/, "");
|
|
240
|
+
if (NPM_NAME_RE.test(name)) names.add(name);
|
|
234
241
|
}
|
|
235
242
|
}
|
|
236
243
|
return [...names];
|
|
@@ -238,6 +245,8 @@ function parseIgnoredBuilds(output) {
|
|
|
238
245
|
|
|
239
246
|
/** Merge new names into the profile's pnpm-workspace.yaml `allowBuilds` list. */
|
|
240
247
|
function ensureAllowBuilds(profileDir, names) {
|
|
248
|
+
const valid = names.filter((name) => NPM_NAME_RE.test(name));
|
|
249
|
+
if (valid.length === 0) return;
|
|
241
250
|
const workspacePath = join(profileDir, "pnpm-workspace.yaml");
|
|
242
251
|
let content = existsSync(workspacePath)
|
|
243
252
|
? readFileSync(workspacePath, "utf8")
|
|
@@ -246,22 +255,24 @@ function ensureAllowBuilds(profileDir, names) {
|
|
|
246
255
|
const keyIndex = lines.findIndex((line) => /^allowBuilds\s*:/.test(line));
|
|
247
256
|
if (keyIndex === -1) {
|
|
248
257
|
if (!content.endsWith("\n")) content += "\n";
|
|
249
|
-
|
|
258
|
+
// Quote every entry: a bare `@scope/name` starts with YAML's reserved
|
|
259
|
+
// `@` indicator and fails to parse.
|
|
260
|
+
content += `\nallowBuilds:\n${valid.map((name) => ` - '${name}'`).join("\n")}\n`;
|
|
250
261
|
} else {
|
|
251
262
|
const existing = new Set();
|
|
252
263
|
let insertIndex = keyIndex + 1;
|
|
253
264
|
for (let index = keyIndex + 1; index < lines.length; index++) {
|
|
254
265
|
const item = /^\s*-\s+(.+?)\s*$/.exec(lines[index]);
|
|
255
266
|
if (item) {
|
|
256
|
-
existing.add(item[1]);
|
|
267
|
+
existing.add(item[1].replace(/^['"](.*)['"]$/, "$1"));
|
|
257
268
|
insertIndex = index + 1;
|
|
258
269
|
continue;
|
|
259
270
|
}
|
|
260
271
|
if (/^\S/.test(lines[index])) break;
|
|
261
272
|
}
|
|
262
|
-
const additions =
|
|
273
|
+
const additions = valid.filter((name) => !existing.has(name));
|
|
263
274
|
if (additions.length > 0) {
|
|
264
|
-
lines.splice(insertIndex, 0, ...additions.map((name) => ` - ${name}`));
|
|
275
|
+
lines.splice(insertIndex, 0, ...additions.map((name) => ` - '${name}'`));
|
|
265
276
|
content = lines.join("\n");
|
|
266
277
|
}
|
|
267
278
|
}
|