@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/README.md +186 -123
- package/cordis.patch.yml +12 -0
- package/package.json +1 -1
- package/src/github.js +189 -52
- package/src/index.js +31 -13
- package/src/installer.js +374 -39
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
|
-
|
|
121
|
-
|
|
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
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
|
132
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
261
|
-
const
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
343
|
+
for (const template of rawSourcesOf(sources)) {
|
|
277
344
|
try {
|
|
278
|
-
const response = await fetch(
|
|
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 =
|
|
379
|
+
const HOST_PACKAGES = /^@deepseek-ai\//;
|
|
302
380
|
|
|
303
|
-
/** The @deepseek-ai/*
|
|
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]
|
|
319
|
-
//
|
|
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
|
-
|
|
404
|
+
const ttl = cached.kind === "unknown" ? VERIFY_TTL_UNKNOWN : VERIFY_TTL;
|
|
405
|
+
return Date.now() - (cached.ts ?? 0) > ttl;
|
|
324
406
|
});
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
437
|
-
await
|
|
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);
|