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

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/src/github.js CHANGED
@@ -116,25 +116,50 @@ export async function searchPlugins({ query, sort = "stars", perPage = 10, page
116
116
  // points back at that GitHub repo, which doubles as an anti-squatting check
117
117
  // (an unrelated package squatting the name never matches, install falls back
118
118
  // to the explicit github: spec).
119
+ //
120
+ // The registry to query is the CALLER's business (see resolveRegistry in
121
+ // installer.js): it has to be the one pnpm installs from. Querying npmjs while
122
+ // pnpm installs from a mirror fails three ways at once and all of them are
123
+ // silent — anti-squatting never matches (every install degrades to a whole-repo
124
+ // GitHub clone plus a prepare build), `latest` comes back null (the update
125
+ // button disappears), and assertSafeToInstall sees no manifest, so the
126
+ // host-shadow guard stops guarding.
127
+
128
+ export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
129
+ // `${registry}|${name}` -> {info, at}. Keyed by registry so a config change
130
+ // cannot serve answers from a different registry.
131
+ const npmCache = new Map();
132
+ // Success and failure expire alike. A never-expiring success cache means
133
+ // `hasUpdate` is frozen for the process lifetime: the author publishes, and no
134
+ // amount of "刷新已装" shows it until dsh restarts — self-defeating for a
135
+ // marketplace whose selling point is update management.
136
+ const NPM_CACHE_TTL = 300000;
119
137
 
120
- const NPM_REGISTRY = "https://registry.npmjs.org";
121
- const npmCache = new Map(); // name -> {info, at} — null misses expire after 5 min
138
+ /** Strip trailing slashes so `${base}/${name}` never doubles up. */
139
+ function normalizeRegistry(registry) {
140
+ const value = String(registry ?? "").trim();
141
+ return (value.length > 0 ? value : DEFAULT_NPM_REGISTRY).replace(/\/+$/, "");
142
+ }
122
143
 
123
144
  /**
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.
145
+ * Look up a package's `latest` manifest on an npm registry.
146
+ * @param name - the npm package name.
147
+ * @param options - `registry` defaults to npmjs; pass what pnpm installs from.
148
+ * @returns `{latest, repositoryUrl, hostDeps}`, or null when unknown/unreachable.
127
149
  */
128
- export async function npmPackageInfo(name) {
150
+ export async function npmPackageInfo(name, { registry } = {}) {
129
151
  const clean = String(name ?? "").trim();
130
152
  if (clean.length === 0 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(clean)) return null;
131
- const cached = npmCache.get(clean);
132
- if (cached !== undefined && (cached.info !== null || Date.now() - cached.at < 300000)) return cached.info;
153
+ const base = normalizeRegistry(registry);
154
+ const key = `${base}|${clean}`;
155
+ const cached = npmCache.get(key);
156
+ if (cached !== undefined && Date.now() - cached.at < NPM_CACHE_TTL) return cached.info;
133
157
  let info = null;
134
158
  try {
135
159
  // 单版本端点返回 latest 的完整 manifest(dependencies + repository 都在)。
136
160
  // abbreviated packument 不含 repository,防抢注比对会永远落空。
137
- const response = await fetch(`${NPM_REGISTRY}/${clean.replace("/", "%2F")}/latest`, {
161
+ // 镜像(npmmirror 等)的同名端点响应格式一致,两个字段都保留。
162
+ const response = await fetch(`${base}/${clean.replace("/", "%2F")}/latest`, {
138
163
  headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
139
164
  });
140
165
  if (response.ok) {
@@ -152,7 +177,7 @@ export async function npmPackageInfo(name) {
152
177
  } catch {
153
178
  info = null; // registry unreachable — caller falls back
154
179
  }
155
- npmCache.set(clean, { info, at: Date.now() });
180
+ npmCache.set(key, { info, at: Date.now() });
156
181
  return info;
157
182
  }
158
183
 
@@ -161,15 +186,20 @@ export async function npmPackageInfo(name) {
161
186
  * that package exists on npm AND its repository URL points back at the repo
162
187
  * (anti-squatting). Anything else passes through untouched.
163
188
  */
164
- export async function preferNpmSpec({ spec }) {
189
+ export async function preferNpmSpec({ spec, registry, sources }) {
165
190
  const raw = String(spec ?? "");
191
+ // A scoped npm name ("@scope/name", "@scope/name@1.2.3") is shaped exactly
192
+ // like owner/repo and matches the regex below, sending every such install
193
+ // off to verify a repository that cannot exist — two wasted CDN requests and
194
+ // a bogus cache entry. Same guard normalizeSpec already uses.
195
+ if (raw.startsWith("@")) return raw;
166
196
  const githubMatch = /^(?:github:)?([^/\s]+\/[^/\s]+?)(?:\.git)?$/i.exec(raw);
167
197
  if (githubMatch === null) return raw;
168
198
  const repo = githubMatch[1];
169
- const { results } = await verifyPlugins({ repos: [repo] }); // cache hit after first verify
199
+ const { results } = await verifyPlugins({ repos: [repo], sources }); // cache hit after first verify
170
200
  const declaredName = results[repo]?.name;
171
201
  if (typeof declaredName !== "string") return raw;
172
- const info = await npmPackageInfo(declaredName);
202
+ const info = await npmPackageInfo(declaredName, { registry });
173
203
  if (info === null || info.repositoryUrl === undefined) return raw;
174
204
  const pointsBack = new RegExp(`github\\.com[/:]${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/|\\.git|$)`, "i").test(info.repositoryUrl);
175
205
  return pointsBack ? declaredName : raw;
@@ -197,7 +227,7 @@ function npmNameOf(raw) {
197
227
  return match === null ? null : match[1];
198
228
  }
199
229
 
200
- export async function assertSafeToInstall({ spec }) {
230
+ export async function assertSafeToInstall({ spec, registry, sources }) {
201
231
  const raw = String(spec ?? "");
202
232
  let hostDeps;
203
233
  if (/^(?:file:|link:)/i.test(raw)) {
@@ -211,7 +241,7 @@ export async function assertSafeToInstall({ spec }) {
211
241
  }
212
242
  } else if (/^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.test(raw)) {
213
243
  const repo = /^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.exec(raw)[1];
214
- const { results } = await verifyPlugins({ repos: [repo] });
244
+ const { results } = await verifyPlugins({ repos: [repo], sources });
215
245
  hostDeps = results[repo]?.hostDeps;
216
246
  } else if (/^https?:\/\//i.test(raw)) {
217
247
  return; // 远程 tarball 下载前无法廉价检查;罕见路径,放行
@@ -222,7 +252,7 @@ export async function assertSafeToInstall({ spec }) {
222
252
  // 无法识别形状的 spec 一律拒绝,而不是静默跳过检查。
223
253
  throw new Error(`cannot analyze install spec ${JSON.stringify(raw)} for host-shadow dependencies — refusing to install`);
224
254
  }
225
- const info = await npmPackageInfo(name);
255
+ const info = await npmPackageInfo(name, { registry });
226
256
  hostDeps = info?.hostDeps;
227
257
  }
228
258
  if (hostDeps !== undefined && hostDeps !== null && hostDeps.length > 0) {
@@ -257,25 +287,62 @@ export function compareVersions(a, b) {
257
287
  // for the process lifetime; a fetch failure caches "unknown" rather than
258
288
  // retrying forever.
259
289
 
260
- const RAW_BASE = "https://raw.githubusercontent.com";
261
- const VERIFY_CONCURRENCY = 8;
290
+ /** Cap on concurrent outbound requests — shared by verification and update checks. */
291
+ export const NETWORK_CONCURRENCY = 8;
292
+ // repo -> {kind, name, version, hostDeps, ts}
262
293
  const verifyCache = new Map();
294
+ // Verdicts expire. Caching a success for the process lifetime freezes the badge
295
+ // on whatever the repo looked like the first time it was seen: when dsh-TUI
296
+ // moved its @deepseek-ai/* deps to peerDependencies, the red "宿主依赖风险"
297
+ // badge stayed up until dsh restarted, still accusing a repo that had been
298
+ // fixed. Ten minutes keeps a browsing session on cache while letting a fix
299
+ // surface on its own.
300
+ const VERIFY_TTL = 600000;
301
+ // Network failures retry sooner — "unknown" carries no information worth keeping.
302
+ const VERIFY_TTL_UNKNOWN = 60000;
303
+
304
+ /**
305
+ * Run `task` over `items` with at most `limit` workers in flight. The one
306
+ * fan-out primitive for this module's network work, so nothing accidentally
307
+ * bursts every item at once.
308
+ */
309
+ export async function mapLimit(items, limit, task) {
310
+ const list = Array.isArray(items) ? items : [];
311
+ let cursor = 0;
312
+ const worker = async () => {
313
+ while (cursor < list.length) {
314
+ const index = cursor++;
315
+ await task(list[index], index);
316
+ }
317
+ };
318
+ await Promise.all(Array.from({ length: Math.min(limit, list.length) }, worker));
319
+ }
263
320
 
264
321
  // package.json is fetched from CDNs, not the REST API, so verification never
265
322
  // burns API quota. jsDelivr first (reachable where raw.githubusercontent.com
266
323
  // is blocked), raw as fallback; a 404 only means "no manifest" once EVERY
267
324
  // reachable source 404s (jsDelivr lags new pushes, so one 404 is not final).
268
- const RAW_SOURCES = [
269
- (repo) => `https://cdn.jsdelivr.net/gh/${repo}@HEAD/package.json`,
270
- (repo) => `${RAW_BASE}/${repo}/HEAD/package.json`,
325
+ // URL templates, `{repo}` substituted with owner/name — overridable through
326
+ // the `rawSources` config for self-hosted reverse proxies.
327
+ export const DEFAULT_RAW_SOURCES = [
328
+ "https://cdn.jsdelivr.net/gh/{repo}@HEAD/package.json",
329
+ "https://raw.githubusercontent.com/{repo}/HEAD/package.json",
271
330
  ];
272
331
 
273
- async function fetchRawPackageJson(repo, signal) {
332
+ /** The configured source templates, or the built-in pair. */
333
+ function rawSourcesOf(sources) {
334
+ const list = (Array.isArray(sources) ? sources : [])
335
+ .map((entry) => String(entry ?? "").trim())
336
+ .filter((entry) => entry.length > 0 && entry.includes("{repo}"));
337
+ return list.length > 0 ? list : DEFAULT_RAW_SOURCES;
338
+ }
339
+
340
+ async function fetchRawPackageJson(repo, signal, sources) {
274
341
  let saw404 = false;
275
342
  let lastError;
276
- for (const buildUrl of RAW_SOURCES) {
343
+ for (const template of rawSourcesOf(sources)) {
277
344
  try {
278
- const response = await fetch(buildUrl(repo), {
345
+ const response = await fetch(template.replace("{repo}", repo), {
279
346
  headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
280
347
  signal,
281
348
  });
@@ -297,10 +364,21 @@ async function fetchRawPackageJson(repo, signal) {
297
364
  * peerDependencies) installs real copies into the profile — the loader then
298
365
  * holds two module instances, symbol identities split, and every tool call
299
366
  * crashes with an undiagnosable "reading 'prepare'". See installer docs.
367
+ *
368
+ * ONLY the @deepseek-ai scope belongs here. This used to also match the bare
369
+ * upstream `cosmokit` and `schemastery` on the theory that dsh forked the
370
+ * cordis trio and therefore shipped those too. It does not: the host tree has
371
+ * no bare copy at any depth, and none of its 195 @deepseek-ai packages depends
372
+ * on one — the fork is complete and cut its ties to the upstream names. A
373
+ * plugin depending on bare `schemastery` shadows nothing, so flagging it was a
374
+ * pure false positive, and an expensive one: it hard-blocked 7 real plugins in
375
+ * the topic's top 300, `omdsh-dev/DSH-better-sidebar` (★1832) among them —
376
+ * every one of which declares its @deepseek-ai/* deps as peers correctly.
377
+ * Fixtures at the bottom of this file pin the distinction.
300
378
  */
301
- const HOST_PACKAGES = /^(@deepseek-ai\/|cosmokit$|schemastery$)/;
379
+ const HOST_PACKAGES = /^@deepseek-ai\//;
302
380
 
303
- /** The @deepseek-ai/* (and cordis runtime) entries inside `dependencies`. */
381
+ /** The @deepseek-ai/* entries inside `dependencies`. */
304
382
  function hostShadowDependencies(pkg) {
305
383
  if (pkg === undefined || typeof pkg !== "object") return undefined;
306
384
  const deps = Object.keys(pkg.dependencies ?? {});
@@ -310,42 +388,40 @@ function hostShadowDependencies(pkg) {
310
388
 
311
389
  /**
312
390
  * Verify repositories as real dsh plugins by their package.json declaration.
313
- * @param {{repos: string[], signal?: AbortSignal}} options - "owner/name" list.
391
+ * @param {{repos: string[], signal?: AbortSignal, sources?: string[]}} options - "owner/name" list.
314
392
  * @returns the {results} map: fullName -> {kind: "bundle"|"client"|"plain"|"no-manifest"|"unknown", name?, version?, hostDeps?}.
315
393
  */
316
- export async function verifyPlugins({ repos, signal }) {
394
+ export async function verifyPlugins({ repos, signal, sources }) {
395
+ // A GitHub owner never starts with "@", so rejecting that shape here keeps
396
+ // scoped npm names ("@scope/name") out of repository verification no matter
397
+ // which caller passes them in.
317
398
  const wanted = [...new Set((Array.isArray(repos) ? repos : []).map(String)
318
- .filter((repo) => /^[^/\s]+\/[^/\s]+$/.test(repo) && !repo.includes("..")))];
319
- // "unknown"(网络失败)60s 后过期重试;成功结果进程内永久缓存。
399
+ .filter((repo) => /^[^@/\s][^/\s]*\/[^/\s]+$/.test(repo) && !repo.includes("..")))];
400
+ // 每条判定都带时间戳:成功 10 分钟过期,网络失败 60 秒过期。
320
401
  const pending = wanted.filter((repo) => {
321
402
  const cached = verifyCache.get(repo);
322
403
  if (cached === undefined) return true;
323
- return cached.kind === "unknown" && Date.now() - (cached.ts ?? 0) > 60000;
404
+ const ttl = cached.kind === "unknown" ? VERIFY_TTL_UNKNOWN : VERIFY_TTL;
405
+ return Date.now() - (cached.ts ?? 0) > ttl;
324
406
  });
325
- let cursor = 0;
326
- const worker = async () => {
327
- while (cursor < pending.length) {
328
- const repo = pending[cursor++];
329
- try {
330
- const pkg = await fetchRawPackageJson(repo, signal);
331
- const kind = pkg === undefined ? "no-manifest"
332
- : typeof pkg.dsh?.bundle?.patch === "string" ? "bundle"
333
- : pkg.dsh?.client !== undefined ? "client"
334
- : "plain";
335
- verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg) });
336
- } catch (error) {
337
- if (error?.name === "AbortError") throw error;
338
- verifyCache.set(repo, { kind: "unknown", ts: Date.now() });
339
- }
407
+ await mapLimit(pending, NETWORK_CONCURRENCY, async (repo) => {
408
+ try {
409
+ const pkg = await fetchRawPackageJson(repo, signal, sources);
410
+ const kind = pkg === undefined ? "no-manifest"
411
+ : typeof pkg.dsh?.bundle?.patch === "string" ? "bundle"
412
+ : pkg.dsh?.client !== undefined ? "client"
413
+ : "plain";
414
+ verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg), ts: Date.now() });
415
+ } catch (error) {
416
+ if (error?.name === "AbortError") throw error;
417
+ verifyCache.set(repo, { kind: "unknown", ts: Date.now() });
340
418
  }
341
- };
342
- await Promise.all(Array.from({ length: Math.min(VERIFY_CONCURRENCY, pending.length) }, worker));
419
+ });
420
+ // 一律重建对象:内部的 ts 不该出现在发给浏览器的响应里。
343
421
  const results = {};
344
422
  for (const repo of wanted) {
345
423
  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;
424
+ results[repo] = { kind: cached?.kind ?? "unknown", name: cached?.name, version: cached?.version, hostDeps: cached?.hostDeps };
349
425
  }
350
426
  return { results };
351
427
  }
@@ -390,8 +466,69 @@ export async function repoInfo({ repo, apiBase, token, signal }) {
390
466
  };
391
467
  }
392
468
 
393
- // Self-test entry: node src/github.js
469
+ // ── offline fixtures ────────────────────────────────────────────────────────
470
+ //
471
+ // The host-shadow check has no live regression case any more: dsh-TUI, the
472
+ // plugin it was built against, moved its @deepseek-ai/* deps to
473
+ // peerDependencies after being reported, and nothing else on the network pins
474
+ // the bare-vs-scoped distinction. These manifests do. Shapes are taken from
475
+ // real repositories in the dsh-plugin topic, trimmed to the relevant fields.
476
+ const HOST_SHADOW_FIXTURES = [
477
+ {
478
+ label: "omdsh-dev/DSH-better-sidebar — 上游裸包,宿主不提供,放行",
479
+ pkg: {
480
+ dependencies: { schemastery: "^3.18.0", ws: "^8.18.0", clsx: "^2.1.1", "node-pty": "^1.1.0" },
481
+ peerDependencies: { "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", "@deepseek-ai/cordis": "^4.0.1" },
482
+ },
483
+ expect: undefined,
484
+ },
485
+ {
486
+ label: "裸 cordis / cosmokit — fork 已与上游断开,放行",
487
+ pkg: { dependencies: { cordis: "^4.0.0-rc.7", cosmokit: "^1.6.3" } },
488
+ expect: undefined,
489
+ },
490
+ {
491
+ label: "作用域包写进 dependencies — 真·宿主重复,拦截",
492
+ pkg: { dependencies: { "@deepseek-ai/schemastery": "^1.0.0" } },
493
+ expect: ["@deepseek-ai/schemastery"],
494
+ },
495
+ {
496
+ label: "合法 peer + 非法 dep 混合 — 仍须拦截",
497
+ pkg: {
498
+ dependencies: { "@deepseek-ai/dsh-settings": "*", clsx: "^2" },
499
+ peerDependencies: { "@deepseek-ai/dsh-tools": "*" },
500
+ },
501
+ expect: ["@deepseek-ai/dsh-settings"],
502
+ },
503
+ {
504
+ label: "只在 peerDependencies 声明 — 正确写法,放行",
505
+ pkg: { peerDependencies: { "@deepseek-ai/dsh-tools": "*", "@deepseek-ai/cordis": "^4.0.1" } },
506
+ expect: undefined,
507
+ },
508
+ { label: "无 dependencies 字段", pkg: {}, expect: undefined },
509
+ { label: "非对象清单", pkg: undefined, expect: undefined },
510
+ ];
511
+
512
+ /** Run the offline fixtures; returns the failure count. */
513
+ function runHostShadowFixtures() {
514
+ let failed = 0;
515
+ for (const { label, pkg, expect } of HOST_SHADOW_FIXTURES) {
516
+ const actual = hostShadowDependencies(pkg);
517
+ const ok = JSON.stringify(actual ?? null) === JSON.stringify(expect ?? null);
518
+ if (!ok) failed++;
519
+ console.log(` ${ok ? "PASS" : "FAIL"} ${label}${ok ? "" : ` 期望 ${JSON.stringify(expect)},实得 ${JSON.stringify(actual)}`}`);
520
+ }
521
+ return failed;
522
+ }
523
+
524
+ // Self-test entry: node src/github.js --self-test
525
+ // Offline fixtures run first and gate the network smoke test below.
394
526
  if (process.argv[1]?.endsWith("github.js") && process.argv.includes("--self-test")) {
527
+ console.log("宿主依赖检测 fixtures:");
528
+ const failed = runHostShadowFixtures();
529
+ console.log(`${HOST_SHADOW_FIXTURES.length - failed}/${HOST_SHADOW_FIXTURES.length} passed\n`);
530
+ if (failed > 0) process.exit(1);
531
+ if (process.argv.includes("--offline")) process.exit(0);
395
532
  const apiBase = "https://api.github.com";
396
533
  const result = await searchPlugins({ query: "", perPage: 3, apiBase });
397
534
  console.log(`total=${result.total} page=${result.page} perPage=${result.perPage}`);
package/src/index.js CHANGED
@@ -19,8 +19,8 @@ import { existsSync, readFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { spawn } from "node:child_process";
21
21
  import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
22
- import { repoInfo, searchPlugins, verifyPlugins, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall } from "./github.js";
23
- import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker, assertSafeSpec } from "./installer.js";
22
+ import { repoInfo, searchPlugins, verifyPlugins, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
23
+ import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker, assertSafeSpec, resolveRegistry } from "./installer.js";
24
24
 
25
25
  export const name = "@1e0zj/dsh-plugin-mall";
26
26
  export const inject = ["tools", "jobs", "systemPrompt"];
@@ -28,10 +28,23 @@ export const inject = ["tools", "jobs", "systemPrompt"];
28
28
  export const Config = z.object({
29
29
  defaultProfile: z.string().default("web"),
30
30
  apiBase: z.string().default("https://api.github.com"),
31
+ npmRegistry: z.string().default(""),
32
+ rawSources: z.array(z.string()).default([]),
31
33
  perPageMax: z.number().default(30),
32
34
  allowRestart: z.boolean().default(true),
33
35
  });
34
36
 
37
+ /**
38
+ * The registry to query for a profile: an explicit `npmRegistry` config wins,
39
+ * otherwise follow whatever pnpm installs from (profile .npmrc → pnpm config →
40
+ * npmjs). Never npmjs-by-assumption: a mirror user would silently lose
41
+ * anti-squatting, update checks, and the host-shadow guard all at once.
42
+ */
43
+ async function registryFor(profile, npmRegistry) {
44
+ const explicit = String(npmRegistry ?? "").trim();
45
+ return explicit.length > 0 ? explicit.replace(/\/+$/, "") : await resolveRegistry(profile);
46
+ }
47
+
35
48
  /** Clip long strings for compact model-facing output. */
36
49
  function clip(text, max) {
37
50
  const trimmed = String(text ?? "").replace(/\s+/g, " ").trim();
@@ -150,7 +163,7 @@ function rpcFail(error) {
150
163
  * @returns the {ok, value|error} envelope.
151
164
  */
152
165
  async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
153
- const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, allowRestart = true } = config;
166
+ const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, allowRestart = true, npmRegistry = "", rawSources = [] } = config;
154
167
  switch (endpoint) {
155
168
  case "search": {
156
169
  const perPage = Math.min(Math.max(Math.trunc(payload?.perPage ?? 10) || 10, 1), Math.trunc(perPageMax) || 30);
@@ -166,7 +179,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
166
179
  return rpcOk(result);
167
180
  }
168
181
  case "verify": {
169
- const result = await verifyPlugins({ repos: payload?.repos });
182
+ const result = await verifyPlugins({ repos: payload?.repos, sources: rawSources });
170
183
  return rpcOk(result);
171
184
  }
172
185
  case "updates": {
@@ -178,14 +191,16 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
178
191
  } catch (error) {
179
192
  return rpcFail(new Error(`invalid profile: ${error.message}`));
180
193
  }
194
+ const registry = await registryFor(profile, npmRegistry);
181
195
  const results = {};
182
- await Promise.all(deps.map(async (dep) => {
196
+ // 同 verifyPlugins 的 worker 池,而不是对所有依赖一次性扇出。
197
+ await mapLimit(deps, NETWORK_CONCURRENCY, async (dep) => {
183
198
  if (dep.kind === "missing") { results[dep.name] = { latest: null }; return; }
184
- const info = await npmPackageInfo(dep.name);
199
+ const info = await npmPackageInfo(dep.name, { registry });
185
200
  results[dep.name] = info === null
186
201
  ? { latest: null }
187
202
  : { latest: info.latest, hasUpdate: compareVersions(info.latest, dep.version) > 0 };
188
- }));
203
+ });
189
204
  return rpcOk(results);
190
205
  }
191
206
  case "info": {
@@ -211,12 +226,14 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
211
226
  return rpcFail(error);
212
227
  }
213
228
  // npm tarball 优先(小而快、带 integrity);registry 条目不同源的包名
214
- // 视为抢注,回退 github: 全仓库 spec。
215
- spec = await preferNpmSpec({ spec });
229
+ // 视为抢注,回退 github: 全仓库 spec。查的 registry 必须是 pnpm 实际
230
+ // 安装用的那个,否则镜像用户这里永远比对不上、次次退化成全仓库克隆。
231
+ const registry = await registryFor(profile, npmRegistry);
232
+ spec = await preferNpmSpec({ spec, registry, sources: rawSources });
216
233
  // 宿主依赖硬拦:dependencies 里拖着 @deepseek-ai/* 的包装进 profile
217
234
  // 就是双模块实例 + 全工具调度崩溃(宿主无任何护栏,市场是最后防线)。
218
235
  try {
219
- await assertSafeToInstall({ spec });
236
+ await assertSafeToInstall({ spec, registry, sources: rawSources });
220
237
  } catch (error) {
221
238
  return rpcFail(error);
222
239
  }
@@ -322,7 +339,7 @@ function registerRpcChannel(ctx, config, token) {
322
339
  }
323
340
 
324
341
  export function apply(ctx, config = {}) {
325
- const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30 } = config;
342
+ const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, npmRegistry = "", rawSources = [] } = config;
326
343
  const token = process.env.GITHUB_TOKEN ?? process.env.DSH_MARKET_GITHUB_TOKEN;
327
344
 
328
345
  ctx.systemPrompt.section({
@@ -433,8 +450,9 @@ export function apply(ctx, config = {}) {
433
450
  const profile = String(args.profile ?? defaultProfile).trim();
434
451
  const normalized = normalizeSpec(args.spec);
435
452
  assertSafeSpec(normalized);
436
- const spec = await preferNpmSpec({ spec: normalized });
437
- await assertSafeToInstall({ spec });
453
+ const registry = await registryFor(profile, npmRegistry);
454
+ const spec = await preferNpmSpec({ spec: normalized, registry, sources: rawSources });
455
+ await assertSafeToInstall({ spec, registry, sources: rawSources });
438
456
  let profileDir;
439
457
  try {
440
458
  profileDir = resolveProfileDir(profile);