@1e0zj/dsh-plugin-mall 0.1.11 → 0.1.12

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.11",
3
+ "version": "0.1.12",
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
@@ -351,11 +351,16 @@ window.__ModuleLoader__.load({
351
351
  // 无限滚动:哨兵进入视口时拉下一页并追加。GitHub topic 的翻页间数据
352
352
  // 可能移动造成重复,按 fullName 去重;已显示数追上 total 即到底。
353
353
  var canLoadMore = results !== null && results.items.length < results.total && !reachedLimit;
354
+ var loadMoreLock = useRef(false);
354
355
  var loadMore = useCallback(function () {
355
356
  if (!canLoadMore || loading || loadingMore) return;
357
+ // 同一 tick 内 observer 可能连续触发两次,state 更新不阻塞闭包读值,
358
+ // 用 ref 做重入闸。
359
+ if (loadMoreLock.current) return;
356
360
  // 限流熔断:失败后 60s 内不再自动请求(哨兵重渲染会反复触发
357
361
  // IntersectionObserver,不熔断会连环 500 直到限流窗口过去)。
358
362
  if (Date.now() < retryAt) return;
363
+ loadMoreLock.current = true;
359
364
  setLoadingMore(true);
360
365
  call("search", { query: query, sort: sort, perPage: 20, page: page + 1 }).then(function (value) {
361
366
  if (value.truncated === true) { setReachedLimit(true); return; }
@@ -376,6 +381,7 @@ window.__ModuleLoader__.load({
376
381
  setError(errorText(e));
377
382
  setRetryAt(Date.now() + 60000);
378
383
  }).finally(function () {
384
+ loadMoreLock.current = false;
379
385
  setLoadingMore(false);
380
386
  });
381
387
  }, [call, canLoadMore, loading, loadingMore, page, query, sort, retryAt]);
@@ -451,7 +457,12 @@ window.__ModuleLoader__.load({
451
457
  var tries = 0;
452
458
  var ping = setInterval(function () {
453
459
  tries++;
454
- if (tries > 40) { clearInterval(ping); return; }
460
+ if (tries > 40) {
461
+ clearInterval(ping);
462
+ setRestarting(false);
463
+ setError("2 分钟内未检测到 dsh 重启完成,请手动检查 dsh 状态后刷新页面。");
464
+ return;
465
+ }
455
466
  rpc.call("/market", "installed", {}).then(function () {
456
467
  clearInterval(ping);
457
468
  window.location.reload();
package/src/github.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // GitHub API helpers for the dsh plugin marketplace.
2
2
  // Pure functions with no harness imports, so this module is unit-testable
3
3
  // standalone (node src/github.js --self-test).
4
+ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
4
6
 
5
7
  const SEARCH_TOPIC = "topic:dsh-plugin";
6
8
  /** GitHub search never serves past the first 1000 results. */
@@ -116,34 +118,41 @@ export async function searchPlugins({ query, sort = "stars", perPage = 10, page
116
118
  // to the explicit github: spec).
117
119
 
118
120
  const NPM_REGISTRY = "https://registry.npmjs.org";
119
- const npmCache = new Map(); // name -> {latest, repositoryUrl} | null (unknown/not found)
121
+ const npmCache = new Map(); // name -> {info, at} null misses expire after 5 min
120
122
 
121
123
  /**
122
- * Look up a package on the npm registry (abbreviated metadata). Cached for
123
- * the process lifetime; null means "not on npm / unreachable".
124
+ * Look up a package on the npm registry (abbreviated metadata). Successful
125
+ * lookups cache for the process lifetime; "not found / unreachable" (null)
126
+ * expires after 5 minutes so a transient registry failure self-heals.
124
127
  */
125
128
  export async function npmPackageInfo(name) {
126
129
  const clean = String(name ?? "").trim();
127
130
  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);
131
+ const cached = npmCache.get(clean);
132
+ if (cached !== undefined && (cached.info !== null || Date.now() - cached.at < 300000)) return cached.info;
129
133
  let info = null;
130
134
  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" },
135
+ // 单版本端点返回 latest 的完整 manifest(dependencies + repository 都在)。
136
+ // abbreviated packument 不含 repository,防抢注比对会永远落空。
137
+ const response = await fetch(`${NPM_REGISTRY}/${clean.replace("/", "%2F")}/latest`, {
138
+ headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
133
139
  });
134
140
  if (response.ok) {
135
141
  const body = await response.json();
136
- const latest = body?.["dist-tags"]?.latest;
137
- if (typeof latest === "string") {
138
- const rawRepository = body?.repository;
142
+ if (typeof body?.version === "string") {
143
+ const rawRepository = body.repository;
139
144
  const repositoryUrl = typeof rawRepository === "string" ? rawRepository : rawRepository?.url;
140
- info = { latest, repositoryUrl: typeof repositoryUrl === "string" ? repositoryUrl : undefined, hostDeps: hostShadowDependencies(body) };
145
+ info = {
146
+ latest: body.version,
147
+ repositoryUrl: typeof repositoryUrl === "string" ? repositoryUrl : undefined,
148
+ hostDeps: hostShadowDependencies(body),
149
+ };
141
150
  }
142
151
  }
143
152
  } catch {
144
153
  info = null; // registry unreachable — caller falls back
145
154
  }
146
- npmCache.set(clean, info);
155
+ npmCache.set(clean, { info, at: Date.now() });
147
156
  return info;
148
157
  }
149
158
 
@@ -174,25 +183,56 @@ export async function preferNpmSpec({ spec }) {
174
183
  * Sources: registry abbreviated metadata for npm names, the verify cache
175
184
  * (raw package.json) for github: specs.
176
185
  */
186
+ /**
187
+ * Extract the bare npm package name: "name", "name@version", "@s/n@version"
188
+ * → "name"/"@s/n". Returns null for anything that is not an npm name shape.
189
+ */
190
+ function npmNameOf(raw) {
191
+ if (typeof raw !== "string" || raw.length === 0) return null;
192
+ if (raw.startsWith("@")) {
193
+ const match = /^(@[^/@\s]+\/[^/@\s]+?)(?:@[^/\s]+)?$/.exec(raw);
194
+ return match === null ? null : match[1];
195
+ }
196
+ const match = /^([^@\s]+?)(?:@[^/\s]+)?$/.exec(raw);
197
+ return match === null ? null : match[1];
198
+ }
199
+
177
200
  export async function assertSafeToInstall({ spec }) {
178
201
  const raw = String(spec ?? "");
179
202
  let hostDeps;
180
- if (/^(?:@[\w.-]+\/)?[\w.-]+$/.test(raw)) {
181
- const info = await npmPackageInfo(raw);
182
- hostDeps = info?.hostDeps;
203
+ if (/^(?:file:|link:)/i.test(raw)) {
204
+ // 本地路径直通:直接读它的 package.json dependencies。
205
+ const dir = raw.replace(/^(?:file:|link:)/i, "").replace(/[\\/]+$/, "");
206
+ try {
207
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
208
+ hostDeps = hostShadowDependencies(pkg);
209
+ } catch {
210
+ return; // 读不到清单——pnpm 会给出真实错误,这里放行
211
+ }
212
+ } else if (/^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.test(raw)) {
213
+ const repo = /^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.exec(raw)[1];
214
+ const { results } = await verifyPlugins({ repos: [repo] });
215
+ hostDeps = results[repo]?.hostDeps;
216
+ } else if (/^https?:\/\//i.test(raw)) {
217
+ return; // 远程 tarball 下载前无法廉价检查;罕见路径,放行
183
218
  } else {
184
- const match = /^github:([^/\s]+\/[^/\s]+?)(?:\.git)?$/i.exec(raw);
185
- if (match !== null) {
186
- const { results } = await verifyPlugins({ repos: [match[1]] });
187
- hostDeps = results[match[1]]?.hostDeps;
219
+ // npm 名(含带版本形式):剥掉 @version 再查。
220
+ const name = npmNameOf(raw);
221
+ if (name === null || name.length === 0) {
222
+ // 无法识别形状的 spec 一律拒绝,而不是静默跳过检查。
223
+ throw new Error(`cannot analyze install spec ${JSON.stringify(raw)} for host-shadow dependencies — refusing to install`);
188
224
  }
225
+ const info = await npmPackageInfo(name);
226
+ hostDeps = info?.hostDeps;
189
227
  }
190
- if (hostDeps !== undefined && hostDeps.length > 0) {
228
+ if (hostDeps !== undefined && hostDeps !== null && hostDeps.length > 0) {
191
229
  throw new Error(`${raw} declares ${hostDeps.length} host framework package(s) as dependencies (${hostDeps.slice(0, 3).join(", ")}${hostDeps.length > 3 ? ", …" : ""}) — installing it would duplicate host modules and crash dsh tool scheduling. Ask the plugin author to move @deepseek-ai/* to peerDependencies.`);
192
230
  }
193
231
  }
194
232
 
195
- /** Loose semver-ish comparison: "0.2.10" vs "0.2.9" → 1. Non-numeric parts read as 0. */
233
+ /** Loose semver-ish comparison: "0.2.10" vs "0.2.9" → 1. Non-numeric parts
234
+ * read as 0; numerically equal versions where one carries a pre-release
235
+ * segment ("-rc.1") sort below the plain release ("2.0.0-beta" < "2.0.0"). */
196
236
  export function compareVersions(a, b) {
197
237
  const pa = String(a ?? "").split(".");
198
238
  const pb = String(b ?? "").split(".");
@@ -201,6 +241,9 @@ export function compareVersions(a, b) {
201
241
  const nb = Number(pb[index]) || 0;
202
242
  if (na !== nb) return na < nb ? -1 : 1;
203
243
  }
244
+ const preA = String(a ?? "").includes("-");
245
+ const preB = String(b ?? "").includes("-");
246
+ if (preA !== preB) return preA ? -1 : 1;
204
247
  return 0;
205
248
  }
206
249
  // ── plugin verification (raw CDN, no API quota) ─────────────────────────────
@@ -273,7 +316,12 @@ function hostShadowDependencies(pkg) {
273
316
  export async function verifyPlugins({ repos, signal }) {
274
317
  const wanted = [...new Set((Array.isArray(repos) ? repos : []).map(String)
275
318
  .filter((repo) => /^[^/\s]+\/[^/\s]+$/.test(repo) && !repo.includes("..")))];
276
- const pending = wanted.filter((repo) => !verifyCache.has(repo));
319
+ // "unknown"(网络失败)60s 后过期重试;成功结果进程内永久缓存。
320
+ const pending = wanted.filter((repo) => {
321
+ const cached = verifyCache.get(repo);
322
+ if (cached === undefined) return true;
323
+ return cached.kind === "unknown" && Date.now() - (cached.ts ?? 0) > 60000;
324
+ });
277
325
  let cursor = 0;
278
326
  const worker = async () => {
279
327
  while (cursor < pending.length) {
@@ -287,13 +335,18 @@ export async function verifyPlugins({ repos, signal }) {
287
335
  verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg) });
288
336
  } catch (error) {
289
337
  if (error?.name === "AbortError") throw error;
290
- verifyCache.set(repo, { kind: "unknown" });
338
+ verifyCache.set(repo, { kind: "unknown", ts: Date.now() });
291
339
  }
292
340
  }
293
341
  };
294
342
  await Promise.all(Array.from({ length: Math.min(VERIFY_CONCURRENCY, pending.length) }, worker));
295
343
  const results = {};
296
- for (const repo of wanted) results[repo] = verifyCache.get(repo) ?? { kind: "unknown" };
344
+ for (const repo of wanted) {
345
+ const cached = verifyCache.get(repo);
346
+ results[repo] = cached === undefined || cached.ts !== undefined
347
+ ? { kind: cached?.kind ?? "unknown", name: cached?.name, version: cached?.version, hostDeps: cached?.hostDeps }
348
+ : cached;
349
+ }
297
350
  return { results };
298
351
  }
299
352
 
package/src/index.js CHANGED
@@ -20,7 +20,7 @@ import { join } from "node:path";
20
20
  import { spawn } from "node:child_process";
21
21
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
22
22
  import { repoInfo, searchPlugins, verifyPlugins, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall } from "./github.js";
23
- import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker } from "./installer.js";
23
+ import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker, assertSafeSpec } from "./installer.js";
24
24
 
25
25
  export const name = "@1e0zj/dsh-plugin-mall";
26
26
  export const inject = ["tools", "jobs", "systemPrompt"];
@@ -206,6 +206,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
206
206
  let spec;
207
207
  try {
208
208
  spec = normalizeSpec(payload?.spec);
209
+ assertSafeSpec(spec);
209
210
  } catch (error) {
210
211
  return rpcFail(error);
211
212
  }
@@ -236,6 +237,11 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
236
237
  const profile = String(payload?.profile ?? defaultProfile).trim();
237
238
  const packageName = String(payload?.package ?? "").trim();
238
239
  if (packageName.length === 0) return rpcFail(new Error("uninstall: package name is required"));
240
+ try {
241
+ assertSafeSpec(packageName);
242
+ } catch (error) {
243
+ return rpcFail(error);
244
+ }
239
245
  try {
240
246
  const profileDir = resolveProfileDir(profile);
241
247
  if (!existsSync(join(profileDir, "package.json"))) {
@@ -274,7 +280,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
274
280
  : `sleep 2 && ${relaunch}`;
275
281
  const child = spawn(launcher, { shell: true, detached: true, stdio: "ignore", cwd: process.cwd(), windowsHide: true });
276
282
  child.unref();
277
- setTimeout(() => process.exit(0), 800);
283
+ setTimeout(() => process.exit(0), 1500);
278
284
  return rpcOk({ restarting: true });
279
285
  }
280
286
  case "jobCancel": {
@@ -425,7 +431,9 @@ export function apply(ctx, config = {}) {
425
431
  },
426
432
  async execute(args, exec) {
427
433
  const profile = String(args.profile ?? defaultProfile).trim();
428
- const spec = await preferNpmSpec({ spec: normalizeSpec(args.spec) });
434
+ const normalized = normalizeSpec(args.spec);
435
+ assertSafeSpec(normalized);
436
+ const spec = await preferNpmSpec({ spec: normalized });
429
437
  await assertSafeToInstall({ spec });
430
438
  let profileDir;
431
439
  try {
@@ -481,6 +489,7 @@ export function apply(ctx, config = {}) {
481
489
  const profile = String(args.profile ?? defaultProfile).trim();
482
490
  const packageName = String(args.package ?? "").trim();
483
491
  if (packageName.length === 0) throw new Error("market_uninstall: package name is required");
492
+ assertSafeSpec(packageName);
484
493
  try {
485
494
  resolveProfileDir(profile);
486
495
  } catch (error) {
package/src/installer.js CHANGED
@@ -361,6 +361,20 @@ export function createJobTracker() {
361
361
  };
362
362
  }
363
363
 
364
+ // ── spec shape guard ────────────────────────────────────────────────────────
365
+
366
+ // Windows spawn 走 shell,spec 会被拼进 cmd 行;agent 传入的参数不可信。
367
+ // 合法的 npm 名 / github:owner\/repo / git·file·link·URL spec 都不含这些
368
+ // shell 元字符——出现即拒绝,宁可误杀不放开命令注入面。
369
+ const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
370
+
371
+ /** Reject install/remove specs carrying shell metacharacters. */
372
+ export function assertSafeSpec(spec) {
373
+ if (UNSAFE_SPEC_RE.test(String(spec ?? ""))) {
374
+ throw new Error(`spec contains characters that are not allowed in an install spec: ${JSON.stringify(String(spec))}`);
375
+ }
376
+ }
377
+
364
378
  // ── pnpm self-heal (corepack) ───────────────────────────────────────────────
365
379
 
366
380
  /**
@@ -565,10 +579,12 @@ export function runRemove({ profile, packageName }, selfHealed = false) {
565
579
  proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
566
580
  }).then(async (outcome) => {
567
581
  if (outcome.spawnError !== undefined) {
568
- // pnpm 缺失时先 corepack 自愈一次再重试(重试在新 producer 里跑)。
582
+ // pnpm 缺失时先 corepack 自愈一次再重试(重试在新 producer 里跑,
583
+ // 这里必须返回它的 done outcome——返回 producer 本体会让 tracker
584
+ // 把成功任务记成 failed)。
569
585
  if (outcome.spawnError.code === "ENOENT" && selfHealed !== true) {
570
- const healed = await enablePnpmViaCorepack(() => {});
571
- if (healed) return runRemove({ profile, packageName }, true);
586
+ const healed = await enablePnpmViaCorepack(push);
587
+ if (healed) return await runRemove({ profile, packageName }, true).done;
572
588
  }
573
589
  const hint = outcome.spawnError.code === "ENOENT"
574
590
  ? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"