@1e0zj/dsh-plugin-mall 0.1.16 → 0.1.18
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/LICENSE +21 -21
- package/README.md +215 -194
- package/cordis.patch.yml +25 -25
- package/package.json +50 -50
- package/src/client.js +694 -610
- package/src/github.js +625 -541
- package/src/index.js +581 -568
- package/src/installer.js +912 -851
package/src/github.js
CHANGED
|
@@ -1,541 +1,625 @@
|
|
|
1
|
-
// GitHub API helpers for the dsh plugin marketplace.
|
|
2
|
-
// Pure functions with no harness imports, so this module is unit-testable
|
|
3
|
-
// standalone (node src/github.js --self-test).
|
|
4
|
-
import { readFileSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
|
|
7
|
-
const SEARCH_TOPIC = "topic:dsh-plugin";
|
|
8
|
-
/** GitHub search never serves past the first 1000 results. */
|
|
9
|
-
const SEARCH_WINDOW = 1000;
|
|
10
|
-
|
|
11
|
-
export function buildHeaders(token) {
|
|
12
|
-
const headers = {
|
|
13
|
-
"User-Agent": "dsh-plugin-mall",
|
|
14
|
-
Accept: "application/vnd.github+json",
|
|
15
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
16
|
-
};
|
|
17
|
-
if (token) headers.Authorization = `Bearer ${token}`;
|
|
18
|
-
return headers;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const DEFAULT_API_BASE = "https://api.github.com";
|
|
22
|
-
|
|
23
|
-
function apiUrl(apiBase, path) {
|
|
24
|
-
const raw = typeof apiBase === "string" && apiBase.length > 0 ? apiBase : DEFAULT_API_BASE;
|
|
25
|
-
const base = raw.endsWith("/") ? raw : `${raw}/`;
|
|
26
|
-
return `${base}${path.replace(/^\//, "")}`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* (
|
|
215
|
-
*/
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (raw.startsWith("@"))
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
*
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
{
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
1
|
+
// GitHub API helpers for the dsh plugin marketplace.
|
|
2
|
+
// Pure functions with no harness imports, so this module is unit-testable
|
|
3
|
+
// standalone (node src/github.js --self-test).
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const SEARCH_TOPIC = "topic:dsh-plugin";
|
|
8
|
+
/** GitHub search never serves past the first 1000 results. */
|
|
9
|
+
const SEARCH_WINDOW = 1000;
|
|
10
|
+
|
|
11
|
+
export function buildHeaders(token) {
|
|
12
|
+
const headers = {
|
|
13
|
+
"User-Agent": "dsh-plugin-mall",
|
|
14
|
+
Accept: "application/vnd.github+json",
|
|
15
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
16
|
+
};
|
|
17
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
18
|
+
return headers;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_API_BASE = "https://api.github.com";
|
|
22
|
+
|
|
23
|
+
function apiUrl(apiBase, path) {
|
|
24
|
+
const raw = typeof apiBase === "string" && apiBase.length > 0 ? apiBase : DEFAULT_API_BASE;
|
|
25
|
+
const base = raw.endsWith("/") ? raw : `${raw}/`;
|
|
26
|
+
return `${base}${path.replace(/^\//, "")}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* GET with bounded retries. The GitHub search API 504s under load — a plain
|
|
31
|
+
* transient that a retry clears — and its cold responses can take 8s+ while
|
|
32
|
+
* warm ones take 300ms. So: up to 3 attempts, 500ms/1500ms backoff, retrying
|
|
33
|
+
* only 5xx statuses, network errors, and our own per-attempt timeout. Never
|
|
34
|
+
* retried: 4xx (deterministic — the 422 "first 1000 results" contract in
|
|
35
|
+
* searchPlugins depends on failing fast) and caller cancellation, which
|
|
36
|
+
* propagates immediately.
|
|
37
|
+
*/
|
|
38
|
+
const REQUEST_TIMEOUT = 12000;
|
|
39
|
+
const RETRY_DELAYS = [500, 1500];
|
|
40
|
+
|
|
41
|
+
async function requestJson(path, { apiBase, token, signal }) {
|
|
42
|
+
const url = apiUrl(apiBase, path);
|
|
43
|
+
let lastError;
|
|
44
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
|
|
45
|
+
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt - 1]));
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT);
|
|
49
|
+
response = await fetch(url, {
|
|
50
|
+
headers: buildHeaders(token),
|
|
51
|
+
signal: signal === undefined ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]),
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error?.name === "AbortError" && signal?.aborted) throw error; // caller cancelled
|
|
55
|
+
// 网络错误或单次超时——都值得重试,错误文本留给最后一轮。
|
|
56
|
+
lastError = new Error(error?.name === "AbortError"
|
|
57
|
+
? `GitHub API request timed out after ${REQUEST_TIMEOUT / 1000}s (attempt ${attempt + 1})`
|
|
58
|
+
: `GitHub API request failed: ${error?.message ?? String(error)}`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (response.status >= 500 && attempt < RETRY_DELAYS.length) {
|
|
62
|
+
lastError = new Error(`GitHub API ${response.status}: ${response.statusText}`);
|
|
63
|
+
continue; // 5xx 瞬时故障,退避后重试
|
|
64
|
+
}
|
|
65
|
+
const remaining = response.headers.get("x-ratelimit-remaining");
|
|
66
|
+
const resetAt = response.headers.get("x-ratelimit-reset");
|
|
67
|
+
const body = await response.json().catch(() => undefined);
|
|
68
|
+
if (response.status === 403 && remaining === "0" && resetAt !== null) {
|
|
69
|
+
const reset = new Date(Number(resetAt) * 1000).toISOString();
|
|
70
|
+
throw new Error(`GitHub API rate limit exceeded; resets at ${reset} (UTC). Set GITHUB_TOKEN or DSH_MARKET_GITHUB_TOKEN for a higher limit.`);
|
|
71
|
+
}
|
|
72
|
+
if (response.status === 404) {
|
|
73
|
+
throw new Error(`GitHub API 404: ${body?.message ?? "not found"}`);
|
|
74
|
+
}
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`GitHub API ${response.status}: ${body?.message ?? response.statusText}`);
|
|
77
|
+
}
|
|
78
|
+
return body;
|
|
79
|
+
}
|
|
80
|
+
throw lastError ?? new Error("GitHub API request failed");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Pick the stable, compact fields the tools render. */
|
|
84
|
+
function pickRepo(item) {
|
|
85
|
+
return {
|
|
86
|
+
fullName: item.full_name ?? "",
|
|
87
|
+
htmlUrl: item.html_url ?? "",
|
|
88
|
+
description: item.description ?? "",
|
|
89
|
+
stars: item.stargazers_count ?? 0,
|
|
90
|
+
forks: item.forks_count ?? 0,
|
|
91
|
+
isFork: item.fork === true,
|
|
92
|
+
language: item.language,
|
|
93
|
+
license: item.license?.spdx_id,
|
|
94
|
+
topics: item.topics ?? [],
|
|
95
|
+
updatedAt: item.updated_at ?? "",
|
|
96
|
+
archived: item.archived ?? false,
|
|
97
|
+
defaultBranch: item.default_branch ?? "main",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Search repositories tagged `topic:dsh-plugin`, optionally narrowed by
|
|
103
|
+
* keywords (name/description/readme match), star-ranked by default.
|
|
104
|
+
* `minStars` (default 1) is pushed into the query as `stars:>=N` so the
|
|
105
|
+
* topic's noise (empty/demo repos riding the tag) is filtered server-side
|
|
106
|
+
* and `total` stays accurate; pass 0 to disable.
|
|
107
|
+
*/
|
|
108
|
+
export async function searchPlugins({ query, sort = "stars", perPage = 10, page = 1, minStars, apiBase, token, signal }) {
|
|
109
|
+
const trimmed = typeof query === "string" ? query.trim() : "";
|
|
110
|
+
const parts = [SEARCH_TOPIC];
|
|
111
|
+
if (trimmed.length > 0) parts.push(trimmed);
|
|
112
|
+
const safeMinStars = Math.max(Math.trunc(Number(minStars ?? 1)) || 0, 0);
|
|
113
|
+
if (safeMinStars > 0) parts.push(`stars:>=${safeMinStars}`);
|
|
114
|
+
const q = parts.join(" ");
|
|
115
|
+
const safePerPage = Math.min(Math.max(Math.trunc(perPage) || 10, 1), 100);
|
|
116
|
+
const safePage = Math.max(Math.trunc(page) || 1, 1);
|
|
117
|
+
const path = `/search/repositories?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&order=desc&per_page=${safePerPage}&page=${safePage}`;
|
|
118
|
+
let body;
|
|
119
|
+
try {
|
|
120
|
+
body = await requestJson(path, { apiBase, token, signal });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// Past the first 1000 results GitHub 422s with "Only the first 1000
|
|
123
|
+
// search results are available" — surface that as a clean empty
|
|
124
|
+
// truncated page instead of a hard error.
|
|
125
|
+
if (/first 1000 search results/i.test(String(error?.message ?? ""))) {
|
|
126
|
+
return { total: SEARCH_WINDOW, page: safePage, perPage: safePerPage, items: [], truncated: true };
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
total: body.total_count ?? 0,
|
|
132
|
+
page: safePage,
|
|
133
|
+
perPage: safePerPage,
|
|
134
|
+
items: (body.items ?? []).map(pickRepo),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── npm registry (prefer-npm installs + update checks) ──────────────────────
|
|
139
|
+
//
|
|
140
|
+
// npm tarballs beat GitHub whole-repo tarballs: smaller (files field only),
|
|
141
|
+
// faster, integrity-checked. `preferNpmSpec` rewrites a github: install spec
|
|
142
|
+
// to its npm package name — but only when the registry entry's repository URL
|
|
143
|
+
// points back at that GitHub repo, which doubles as an anti-squatting check
|
|
144
|
+
// (an unrelated package squatting the name never matches, install falls back
|
|
145
|
+
// to the explicit github: spec).
|
|
146
|
+
//
|
|
147
|
+
// The registry to query is the CALLER's business (see resolveRegistry in
|
|
148
|
+
// installer.js): it has to be the one pnpm installs from. Querying npmjs while
|
|
149
|
+
// pnpm installs from a mirror fails three ways at once and all of them are
|
|
150
|
+
// silent — anti-squatting never matches (every install degrades to a whole-repo
|
|
151
|
+
// GitHub clone plus a prepare build), `latest` comes back null (the update
|
|
152
|
+
// button disappears), and assertSafeToInstall sees no manifest, so the
|
|
153
|
+
// host-shadow guard stops guarding.
|
|
154
|
+
|
|
155
|
+
export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
156
|
+
// `${registry}|${name}` -> {info, at}. Keyed by registry so a config change
|
|
157
|
+
// cannot serve answers from a different registry.
|
|
158
|
+
const npmCache = new Map();
|
|
159
|
+
// Success and failure expire alike. A never-expiring success cache means
|
|
160
|
+
// `hasUpdate` is frozen for the process lifetime: the author publishes, and no
|
|
161
|
+
// amount of "刷新已装" shows it until dsh restarts — self-defeating for a
|
|
162
|
+
// marketplace whose selling point is update management.
|
|
163
|
+
const NPM_CACHE_TTL = 300000;
|
|
164
|
+
|
|
165
|
+
/** Strip trailing slashes so `${base}/${name}` never doubles up. */
|
|
166
|
+
function normalizeRegistry(registry) {
|
|
167
|
+
const value = String(registry ?? "").trim();
|
|
168
|
+
return (value.length > 0 ? value : DEFAULT_NPM_REGISTRY).replace(/\/+$/, "");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Look up a package's `latest` manifest on an npm registry.
|
|
173
|
+
* @param name - the npm package name.
|
|
174
|
+
* @param options - `registry` defaults to npmjs; pass what pnpm installs from.
|
|
175
|
+
* @returns `{latest, repositoryUrl, hostDeps}`, or null when unknown/unreachable.
|
|
176
|
+
*/
|
|
177
|
+
export async function npmPackageInfo(name, { registry } = {}) {
|
|
178
|
+
const clean = String(name ?? "").trim();
|
|
179
|
+
if (clean.length === 0 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(clean)) return null;
|
|
180
|
+
const base = normalizeRegistry(registry);
|
|
181
|
+
const key = `${base}|${clean}`;
|
|
182
|
+
const cached = npmCache.get(key);
|
|
183
|
+
if (cached !== undefined && Date.now() - cached.at < NPM_CACHE_TTL) return cached.info;
|
|
184
|
+
let info = null;
|
|
185
|
+
try {
|
|
186
|
+
// 单版本端点返回 latest 的完整 manifest(dependencies + repository 都在)。
|
|
187
|
+
// abbreviated packument 不含 repository,防抢注比对会永远落空。
|
|
188
|
+
// 镜像(npmmirror 等)的同名端点响应格式一致,两个字段都保留。
|
|
189
|
+
const response = await fetch(`${base}/${clean.replace("/", "%2F")}/latest`, {
|
|
190
|
+
headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
|
|
191
|
+
});
|
|
192
|
+
if (response.ok) {
|
|
193
|
+
const body = await response.json();
|
|
194
|
+
if (typeof body?.version === "string") {
|
|
195
|
+
const rawRepository = body.repository;
|
|
196
|
+
const repositoryUrl = typeof rawRepository === "string" ? rawRepository : rawRepository?.url;
|
|
197
|
+
info = {
|
|
198
|
+
latest: body.version,
|
|
199
|
+
repositoryUrl: typeof repositoryUrl === "string" ? repositoryUrl : undefined,
|
|
200
|
+
hostDeps: hostShadowDependencies(body),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
info = null; // registry unreachable — caller falls back
|
|
206
|
+
}
|
|
207
|
+
npmCache.set(key, { info, at: Date.now() });
|
|
208
|
+
return info;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Rewrite "github:owner/repo" (or "owner/repo") to the npm package name when
|
|
213
|
+
* that package exists on npm AND its repository URL points back at the repo
|
|
214
|
+
* (anti-squatting). Anything else passes through untouched.
|
|
215
|
+
*/
|
|
216
|
+
export async function preferNpmSpec({ spec, registry, sources }) {
|
|
217
|
+
const raw = String(spec ?? "");
|
|
218
|
+
// A scoped npm name ("@scope/name", "@scope/name@1.2.3") is shaped exactly
|
|
219
|
+
// like owner/repo and matches the regex below, sending every such install
|
|
220
|
+
// off to verify a repository that cannot exist — two wasted CDN requests and
|
|
221
|
+
// a bogus cache entry. Same guard normalizeSpec already uses.
|
|
222
|
+
if (raw.startsWith("@")) return raw;
|
|
223
|
+
const githubMatch = /^(?:github:)?([^/\s]+\/[^/\s]+?)(?:\.git)?$/i.exec(raw);
|
|
224
|
+
if (githubMatch === null) return raw;
|
|
225
|
+
const repo = githubMatch[1];
|
|
226
|
+
const { results } = await verifyPlugins({ repos: [repo], sources }); // cache hit after first verify
|
|
227
|
+
const declaredName = results[repo]?.name;
|
|
228
|
+
if (typeof declaredName !== "string") return raw;
|
|
229
|
+
const info = await npmPackageInfo(declaredName, { registry });
|
|
230
|
+
if (info === null || info.repositoryUrl === undefined) return raw;
|
|
231
|
+
const pointsBack = new RegExp(`github\\.com[/:]${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/|\\.git|$)`, "i").test(info.repositoryUrl);
|
|
232
|
+
return pointsBack ? declaredName : raw;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Refuse to install a package whose `dependencies` shadow host framework
|
|
237
|
+
* packages. Host copies inside a profile split module identities and crash
|
|
238
|
+
* all tool scheduling — the dsh contract is peerDependencies, but the host
|
|
239
|
+
* enforces nothing, so the marketplace is the last line of defense.
|
|
240
|
+
* Sources: registry abbreviated metadata for npm names, the verify cache
|
|
241
|
+
* (raw package.json) for github: specs.
|
|
242
|
+
*/
|
|
243
|
+
/**
|
|
244
|
+
* Extract the bare npm package name: "name", "name@version", "@s/n@version"
|
|
245
|
+
* → "name"/"@s/n". Returns null for anything that is not an npm name shape.
|
|
246
|
+
*/
|
|
247
|
+
export function npmNameOf(raw) {
|
|
248
|
+
if (typeof raw !== "string" || raw.length === 0) return null;
|
|
249
|
+
if (raw.startsWith("@")) {
|
|
250
|
+
const match = /^(@[^/@\s]+\/[^/@\s]+?)(?:@[^/\s]+)?$/.exec(raw);
|
|
251
|
+
return match === null ? null : match[1];
|
|
252
|
+
}
|
|
253
|
+
const match = /^([^@\s]+?)(?:@[^/\s]+)?$/.exec(raw);
|
|
254
|
+
return match === null ? null : match[1];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function assertSafeToInstall({ spec, registry, sources }) {
|
|
258
|
+
const raw = String(spec ?? "");
|
|
259
|
+
let hostDeps;
|
|
260
|
+
if (/^(?:file:|link:)/i.test(raw)) {
|
|
261
|
+
// 本地路径直通:直接读它的 package.json 查 dependencies。
|
|
262
|
+
const dir = raw.replace(/^(?:file:|link:)/i, "").replace(/[\\/]+$/, "");
|
|
263
|
+
try {
|
|
264
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
265
|
+
hostDeps = hostShadowDependencies(pkg);
|
|
266
|
+
} catch {
|
|
267
|
+
return; // 读不到清单——pnpm 会给出真实错误,这里放行
|
|
268
|
+
}
|
|
269
|
+
} else if (/^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.test(raw)) {
|
|
270
|
+
const repo = /^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.exec(raw)[1];
|
|
271
|
+
const { results } = await verifyPlugins({ repos: [repo], sources });
|
|
272
|
+
hostDeps = results[repo]?.hostDeps;
|
|
273
|
+
} else if (/^https?:\/\//i.test(raw)) {
|
|
274
|
+
return; // 远程 tarball 下载前无法廉价检查;罕见路径,放行
|
|
275
|
+
} else {
|
|
276
|
+
// npm 名(含带版本形式):剥掉 @version 再查。
|
|
277
|
+
const name = npmNameOf(raw);
|
|
278
|
+
if (name === null || name.length === 0) {
|
|
279
|
+
// 无法识别形状的 spec 一律拒绝,而不是静默跳过检查。
|
|
280
|
+
throw new Error(`cannot analyze install spec ${JSON.stringify(raw)} for host-shadow dependencies — refusing to install`);
|
|
281
|
+
}
|
|
282
|
+
const info = await npmPackageInfo(name, { registry });
|
|
283
|
+
hostDeps = info?.hostDeps;
|
|
284
|
+
}
|
|
285
|
+
if (hostDeps !== undefined && hostDeps !== null && hostDeps.length > 0) {
|
|
286
|
+
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.`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── install-script disclosure ───────────────────────────────────────────────
|
|
291
|
+
//
|
|
292
|
+
// pnpm blocks dependency lifecycle scripts by default; the marketplace used to
|
|
293
|
+
// silently allow them and retry. Allowing them is a real decision — those
|
|
294
|
+
// commands run on the user's machine with the user's privileges, before any
|
|
295
|
+
// plugin code loads — so the caller has to be able to show WHAT it is asking
|
|
296
|
+
// approval for, not just a package name. Everything here is one registry
|
|
297
|
+
// request per blocked package (there are typically 0-3).
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Look up what a blocked package would actually execute.
|
|
301
|
+
* @param entries - `{name, version}` pairs (version optional → falls back to latest).
|
|
302
|
+
* @param options - `registry` to query; `installedName` marks which entry, if
|
|
303
|
+
* any, is the package the user actually asked for — everything else is a
|
|
304
|
+
* transitive dependency they never chose, which is the part worth flagging.
|
|
305
|
+
* @returns one record per entry; fields absent when the registry is unreachable.
|
|
306
|
+
*/
|
|
307
|
+
export async function describeBuildScripts(entries, { registry, installedName } = {}) {
|
|
308
|
+
const base = normalizeRegistry(registry);
|
|
309
|
+
const list = (Array.isArray(entries) ? entries : []).map((entry) => (
|
|
310
|
+
typeof entry === "string" ? { name: entry } : { name: entry?.name, version: entry?.version }
|
|
311
|
+
)).filter((entry) => typeof entry.name === "string" && entry.name.length > 0);
|
|
312
|
+
const out = [];
|
|
313
|
+
await mapLimit(list, NETWORK_CONCURRENCY, async ({ name, version }) => {
|
|
314
|
+
const record = { name, version, direct: installedName !== undefined && name === installedName };
|
|
315
|
+
try {
|
|
316
|
+
const point = version === undefined ? "latest" : encodeURIComponent(version);
|
|
317
|
+
const response = await fetch(`${base}/${name.replace("/", "%2F")}/${point}`, {
|
|
318
|
+
headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
|
|
319
|
+
});
|
|
320
|
+
if (response.ok) {
|
|
321
|
+
const body = await response.json();
|
|
322
|
+
record.version = body.version ?? version;
|
|
323
|
+
const scripts = body.scripts ?? {};
|
|
324
|
+
record.scripts = {};
|
|
325
|
+
for (const key of ["preinstall", "install", "postinstall"]) {
|
|
326
|
+
if (typeof scripts[key] === "string") record.scripts[key] = scripts[key];
|
|
327
|
+
}
|
|
328
|
+
record.provenance = body.dist?.attestations !== undefined;
|
|
329
|
+
record.unpackedSize = body.dist?.unpackedSize;
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
/* 查不到就只报名字。绝不因为查询失败而放行,也不因此阻断——
|
|
333
|
+
这里只负责补充事实,是否放行由用户决定。 */
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
// 下载量只在 npmjs 的统计 API 上有,镜像用户可能打不通;纯可选信息。
|
|
337
|
+
const stats = await fetch(`https://api.npmjs.org/downloads/point/last-week/${name.replace("/", "%2F")}`);
|
|
338
|
+
if (stats.ok) record.weeklyDownloads = (await stats.json())?.downloads;
|
|
339
|
+
} catch {
|
|
340
|
+
/* 可选 */
|
|
341
|
+
}
|
|
342
|
+
out.push(record);
|
|
343
|
+
});
|
|
344
|
+
return out.sort((a, b) => Number(b.direct) - Number(a.direct) || a.name.localeCompare(b.name));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Loose semver-ish comparison: "0.2.10" vs "0.2.9" → 1. Non-numeric parts
|
|
348
|
+
* read as 0; numerically equal versions where one carries a pre-release
|
|
349
|
+
* segment ("-rc.1") sort below the plain release ("2.0.0-beta" < "2.0.0"). */
|
|
350
|
+
export function compareVersions(a, b) {
|
|
351
|
+
const pa = String(a ?? "").split(".");
|
|
352
|
+
const pb = String(b ?? "").split(".");
|
|
353
|
+
for (let index = 0; index < Math.max(pa.length, pb.length); index++) {
|
|
354
|
+
const na = Number(pa[index]) || 0;
|
|
355
|
+
const nb = Number(pb[index]) || 0;
|
|
356
|
+
if (na !== nb) return na < nb ? -1 : 1;
|
|
357
|
+
}
|
|
358
|
+
const preA = String(a ?? "").includes("-");
|
|
359
|
+
const preB = String(b ?? "").includes("-");
|
|
360
|
+
if (preA !== preB) return preA ? -1 : 1;
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
// ── plugin verification (raw CDN, no API quota) ─────────────────────────────
|
|
364
|
+
//
|
|
365
|
+
// The topic carries thousands of repos that are not dsh plugins at all. The
|
|
366
|
+
// authoritative signal is a package.json declaring `dsh.bundle.patch` (host
|
|
367
|
+
// bundle) or `dsh.client` (browser UI plugin) — the same contract
|
|
368
|
+
// classifyPackage applies locally. package.json is fetched from
|
|
369
|
+
// raw.githubusercontent.com (a CDN that does not consume REST API quota), so a
|
|
370
|
+
// page of 20 verifies in one burst even without a token. Results are cached
|
|
371
|
+
// for the process lifetime; a fetch failure caches "unknown" rather than
|
|
372
|
+
// retrying forever.
|
|
373
|
+
|
|
374
|
+
/** Cap on concurrent outbound requests — shared by verification and update checks. */
|
|
375
|
+
export const NETWORK_CONCURRENCY = 8;
|
|
376
|
+
// repo -> {kind, name, version, hostDeps, ts}
|
|
377
|
+
const verifyCache = new Map();
|
|
378
|
+
// Verdicts expire. Caching a success for the process lifetime freezes the badge
|
|
379
|
+
// on whatever the repo looked like the first time it was seen: when dsh-TUI
|
|
380
|
+
// moved its @deepseek-ai/* deps to peerDependencies, the red "宿主依赖风险"
|
|
381
|
+
// badge stayed up until dsh restarted, still accusing a repo that had been
|
|
382
|
+
// fixed. Ten minutes keeps a browsing session on cache while letting a fix
|
|
383
|
+
// surface on its own.
|
|
384
|
+
const VERIFY_TTL = 600000;
|
|
385
|
+
// Network failures retry sooner — "unknown" carries no information worth keeping.
|
|
386
|
+
const VERIFY_TTL_UNKNOWN = 60000;
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Run `task` over `items` with at most `limit` workers in flight. The one
|
|
390
|
+
* fan-out primitive for this module's network work, so nothing accidentally
|
|
391
|
+
* bursts every item at once.
|
|
392
|
+
*/
|
|
393
|
+
export async function mapLimit(items, limit, task) {
|
|
394
|
+
const list = Array.isArray(items) ? items : [];
|
|
395
|
+
let cursor = 0;
|
|
396
|
+
const worker = async () => {
|
|
397
|
+
while (cursor < list.length) {
|
|
398
|
+
const index = cursor++;
|
|
399
|
+
await task(list[index], index);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
await Promise.all(Array.from({ length: Math.min(limit, list.length) }, worker));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// package.json is fetched from CDNs, not the REST API, so verification never
|
|
406
|
+
// burns API quota. jsDelivr first (reachable where raw.githubusercontent.com
|
|
407
|
+
// is blocked), raw as fallback; a 404 only means "no manifest" once EVERY
|
|
408
|
+
// reachable source 404s (jsDelivr lags new pushes, so one 404 is not final).
|
|
409
|
+
// URL templates, `{repo}` substituted with owner/name — overridable through
|
|
410
|
+
// the `rawSources` config for self-hosted reverse proxies.
|
|
411
|
+
export const DEFAULT_RAW_SOURCES = [
|
|
412
|
+
"https://cdn.jsdelivr.net/gh/{repo}@HEAD/package.json",
|
|
413
|
+
"https://raw.githubusercontent.com/{repo}/HEAD/package.json",
|
|
414
|
+
];
|
|
415
|
+
|
|
416
|
+
/** The configured source templates, or the built-in pair. */
|
|
417
|
+
function rawSourcesOf(sources) {
|
|
418
|
+
const list = (Array.isArray(sources) ? sources : [])
|
|
419
|
+
.map((entry) => String(entry ?? "").trim())
|
|
420
|
+
.filter((entry) => entry.length > 0 && entry.includes("{repo}"));
|
|
421
|
+
return list.length > 0 ? list : DEFAULT_RAW_SOURCES;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function fetchRawPackageJson(repo, signal, sources) {
|
|
425
|
+
let saw404 = false;
|
|
426
|
+
let lastError;
|
|
427
|
+
for (const template of rawSourcesOf(sources)) {
|
|
428
|
+
try {
|
|
429
|
+
const response = await fetch(template.replace("{repo}", repo), {
|
|
430
|
+
headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
|
|
431
|
+
signal,
|
|
432
|
+
});
|
|
433
|
+
if (response.status === 404) { saw404 = true; continue; }
|
|
434
|
+
if (!response.ok) throw new Error(`source returned ${response.status}`);
|
|
435
|
+
return await response.json();
|
|
436
|
+
} catch (error) {
|
|
437
|
+
if (error?.name === "AbortError") throw error;
|
|
438
|
+
lastError = error;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (saw404) return undefined;
|
|
442
|
+
throw lastError ?? new Error("no raw source reachable");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Framework packages the host provides via the profiles/node_modules fallback.
|
|
447
|
+
* A plugin declaring any of these as `dependencies` (instead of
|
|
448
|
+
* peerDependencies) installs real copies into the profile — the loader then
|
|
449
|
+
* holds two module instances, symbol identities split, and every tool call
|
|
450
|
+
* crashes with an undiagnosable "reading 'prepare'". See installer docs.
|
|
451
|
+
*
|
|
452
|
+
* ONLY the @deepseek-ai scope belongs here. This used to also match the bare
|
|
453
|
+
* upstream `cosmokit` and `schemastery` on the theory that dsh forked the
|
|
454
|
+
* cordis trio and therefore shipped those too. It does not: the host tree has
|
|
455
|
+
* no bare copy at any depth, and none of its 195 @deepseek-ai packages depends
|
|
456
|
+
* on one — the fork is complete and cut its ties to the upstream names. A
|
|
457
|
+
* plugin depending on bare `schemastery` shadows nothing, so flagging it was a
|
|
458
|
+
* pure false positive, and an expensive one: it hard-blocked 7 real plugins in
|
|
459
|
+
* the topic's top 300, `omdsh-dev/DSH-better-sidebar` (★1832) among them —
|
|
460
|
+
* every one of which declares its @deepseek-ai/* deps as peers correctly.
|
|
461
|
+
* Fixtures at the bottom of this file pin the distinction.
|
|
462
|
+
*/
|
|
463
|
+
const HOST_PACKAGES = /^@deepseek-ai\//;
|
|
464
|
+
|
|
465
|
+
/** The @deepseek-ai/* entries inside `dependencies`. */
|
|
466
|
+
function hostShadowDependencies(pkg) {
|
|
467
|
+
if (pkg === undefined || typeof pkg !== "object") return undefined;
|
|
468
|
+
const deps = Object.keys(pkg.dependencies ?? {});
|
|
469
|
+
const host = deps.filter((name) => HOST_PACKAGES.test(name));
|
|
470
|
+
return host.length > 0 ? host : undefined;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Verify repositories as real dsh plugins by their package.json declaration.
|
|
475
|
+
* @param {{repos: string[], signal?: AbortSignal, sources?: string[]}} options - "owner/name" list.
|
|
476
|
+
* @returns the {results} map: fullName -> {kind: "bundle"|"client"|"plain"|"no-manifest"|"unknown", name?, version?, hostDeps?}.
|
|
477
|
+
*/
|
|
478
|
+
export async function verifyPlugins({ repos, signal, sources }) {
|
|
479
|
+
// A GitHub owner never starts with "@", so rejecting that shape here keeps
|
|
480
|
+
// scoped npm names ("@scope/name") out of repository verification no matter
|
|
481
|
+
// which caller passes them in.
|
|
482
|
+
const wanted = [...new Set((Array.isArray(repos) ? repos : []).map(String)
|
|
483
|
+
.filter((repo) => /^[^@/\s][^/\s]*\/[^/\s]+$/.test(repo) && !repo.includes("..")))];
|
|
484
|
+
// 每条判定都带时间戳:成功 10 分钟过期,网络失败 60 秒过期。
|
|
485
|
+
const pending = wanted.filter((repo) => {
|
|
486
|
+
const cached = verifyCache.get(repo);
|
|
487
|
+
if (cached === undefined) return true;
|
|
488
|
+
const ttl = cached.kind === "unknown" ? VERIFY_TTL_UNKNOWN : VERIFY_TTL;
|
|
489
|
+
return Date.now() - (cached.ts ?? 0) > ttl;
|
|
490
|
+
});
|
|
491
|
+
await mapLimit(pending, NETWORK_CONCURRENCY, async (repo) => {
|
|
492
|
+
try {
|
|
493
|
+
const pkg = await fetchRawPackageJson(repo, signal, sources);
|
|
494
|
+
const kind = pkg === undefined ? "no-manifest"
|
|
495
|
+
: typeof pkg.dsh?.bundle?.patch === "string" ? "bundle"
|
|
496
|
+
: pkg.dsh?.client !== undefined ? "client"
|
|
497
|
+
: "plain";
|
|
498
|
+
verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg), ts: Date.now() });
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (error?.name === "AbortError") throw error;
|
|
501
|
+
verifyCache.set(repo, { kind: "unknown", ts: Date.now() });
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
// 一律重建对象:内部的 ts 不该出现在发给浏览器的响应里。
|
|
505
|
+
const results = {};
|
|
506
|
+
for (const repo of wanted) {
|
|
507
|
+
const cached = verifyCache.get(repo);
|
|
508
|
+
results[repo] = { kind: cached?.kind ?? "unknown", name: cached?.name, version: cached?.version, hostDeps: cached?.hostDeps };
|
|
509
|
+
}
|
|
510
|
+
return { results };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Fetch one repository's metadata plus its package.json (base64-decoded),
|
|
515
|
+
* which is what tells us whether it declares a dsh bundle patch.
|
|
516
|
+
*/
|
|
517
|
+
export async function repoInfo({ repo, apiBase, token, signal }) {
|
|
518
|
+
const trimmed = String(repo ?? "").trim();
|
|
519
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed) || trimmed.includes("..")) {
|
|
520
|
+
throw new Error(`market_info: repo must be "owner/name", got ${JSON.stringify(trimmed)}`);
|
|
521
|
+
}
|
|
522
|
+
let meta;
|
|
523
|
+
try {
|
|
524
|
+
meta = await requestJson(`/repos/${trimmed}`, { apiBase, token, signal });
|
|
525
|
+
} catch (error) {
|
|
526
|
+
throw new Error(`market_info: repository ${trimmed} not found on GitHub (${error.message})`);
|
|
527
|
+
}
|
|
528
|
+
let packageJson;
|
|
529
|
+
try {
|
|
530
|
+
const contents = await requestJson(`/repos/${trimmed}/contents/package.json`, { apiBase, token, signal });
|
|
531
|
+
if (typeof contents.content === "string") {
|
|
532
|
+
packageJson = JSON.parse(Buffer.from(contents.content, "base64").toString("utf8"));
|
|
533
|
+
}
|
|
534
|
+
} catch {
|
|
535
|
+
packageJson = undefined; // no package.json at the repo root
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
meta: pickRepo(meta),
|
|
539
|
+
packageJson: packageJson === undefined ? undefined : {
|
|
540
|
+
name: packageJson.name,
|
|
541
|
+
version: packageJson.version,
|
|
542
|
+
description: packageJson.description,
|
|
543
|
+
type: packageJson.type,
|
|
544
|
+
dshBundlePatch: typeof packageJson.dsh?.bundle?.patch === "string" ? packageJson.dsh.bundle.patch : undefined,
|
|
545
|
+
dshClientPlatform: packageJson.dsh?.client?.platform,
|
|
546
|
+
dshClientInjectCount: Array.isArray(packageJson.dsh?.client?.inject) ? packageJson.dsh.client.inject.length : undefined,
|
|
547
|
+
dependencyCount: Object.keys(packageJson.dependencies ?? {}).length,
|
|
548
|
+
peerDependencyCount: Object.keys(packageJson.peerDependencies ?? {}).length,
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ── offline fixtures ────────────────────────────────────────────────────────
|
|
554
|
+
//
|
|
555
|
+
// The host-shadow check has no live regression case any more: dsh-TUI, the
|
|
556
|
+
// plugin it was built against, moved its @deepseek-ai/* deps to
|
|
557
|
+
// peerDependencies after being reported, and nothing else on the network pins
|
|
558
|
+
// the bare-vs-scoped distinction. These manifests do. Shapes are taken from
|
|
559
|
+
// real repositories in the dsh-plugin topic, trimmed to the relevant fields.
|
|
560
|
+
const HOST_SHADOW_FIXTURES = [
|
|
561
|
+
{
|
|
562
|
+
label: "omdsh-dev/DSH-better-sidebar — 上游裸包,宿主不提供,放行",
|
|
563
|
+
pkg: {
|
|
564
|
+
dependencies: { schemastery: "^3.18.0", ws: "^8.18.0", clsx: "^2.1.1", "node-pty": "^1.1.0" },
|
|
565
|
+
peerDependencies: { "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", "@deepseek-ai/cordis": "^4.0.1" },
|
|
566
|
+
},
|
|
567
|
+
expect: undefined,
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
label: "裸 cordis / cosmokit — fork 已与上游断开,放行",
|
|
571
|
+
pkg: { dependencies: { cordis: "^4.0.0-rc.7", cosmokit: "^1.6.3" } },
|
|
572
|
+
expect: undefined,
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
label: "作用域包写进 dependencies — 真·宿主重复,拦截",
|
|
576
|
+
pkg: { dependencies: { "@deepseek-ai/schemastery": "^1.0.0" } },
|
|
577
|
+
expect: ["@deepseek-ai/schemastery"],
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
label: "合法 peer + 非法 dep 混合 — 仍须拦截",
|
|
581
|
+
pkg: {
|
|
582
|
+
dependencies: { "@deepseek-ai/dsh-settings": "*", clsx: "^2" },
|
|
583
|
+
peerDependencies: { "@deepseek-ai/dsh-tools": "*" },
|
|
584
|
+
},
|
|
585
|
+
expect: ["@deepseek-ai/dsh-settings"],
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
label: "只在 peerDependencies 声明 — 正确写法,放行",
|
|
589
|
+
pkg: { peerDependencies: { "@deepseek-ai/dsh-tools": "*", "@deepseek-ai/cordis": "^4.0.1" } },
|
|
590
|
+
expect: undefined,
|
|
591
|
+
},
|
|
592
|
+
{ label: "无 dependencies 字段", pkg: {}, expect: undefined },
|
|
593
|
+
{ label: "非对象清单", pkg: undefined, expect: undefined },
|
|
594
|
+
];
|
|
595
|
+
|
|
596
|
+
/** Run the offline fixtures; returns the failure count. */
|
|
597
|
+
function runHostShadowFixtures() {
|
|
598
|
+
let failed = 0;
|
|
599
|
+
for (const { label, pkg, expect } of HOST_SHADOW_FIXTURES) {
|
|
600
|
+
const actual = hostShadowDependencies(pkg);
|
|
601
|
+
const ok = JSON.stringify(actual ?? null) === JSON.stringify(expect ?? null);
|
|
602
|
+
if (!ok) failed++;
|
|
603
|
+
console.log(` ${ok ? "PASS" : "FAIL"} ${label}${ok ? "" : ` 期望 ${JSON.stringify(expect)},实得 ${JSON.stringify(actual)}`}`);
|
|
604
|
+
}
|
|
605
|
+
return failed;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Self-test entry: node src/github.js --self-test
|
|
609
|
+
// Offline fixtures run first and gate the network smoke test below.
|
|
610
|
+
if (process.argv[1]?.endsWith("github.js") && process.argv.includes("--self-test")) {
|
|
611
|
+
console.log("宿主依赖检测 fixtures:");
|
|
612
|
+
const failed = runHostShadowFixtures();
|
|
613
|
+
console.log(`${HOST_SHADOW_FIXTURES.length - failed}/${HOST_SHADOW_FIXTURES.length} passed\n`);
|
|
614
|
+
if (failed > 0) process.exit(1);
|
|
615
|
+
if (process.argv.includes("--offline")) process.exit(0);
|
|
616
|
+
const apiBase = "https://api.github.com";
|
|
617
|
+
const result = await searchPlugins({ query: "", perPage: 3, apiBase });
|
|
618
|
+
console.log(`total=${result.total} page=${result.page} perPage=${result.perPage}`);
|
|
619
|
+
for (const item of result.items) console.log(`${item.fullName} ★${item.stars} ${item.language ?? ""}`);
|
|
620
|
+
if (result.items.length > 0) {
|
|
621
|
+
const info = await repoInfo({ repo: result.items[0].fullName, apiBase });
|
|
622
|
+
console.log(`repo=${info.meta.fullName} defaultBranch=${info.meta.defaultBranch} archived=${info.meta.archived}`);
|
|
623
|
+
console.log(`packageJson=${info.packageJson ? `${info.packageJson.name}@${info.packageJson.version} bundle=${info.packageJson.dshBundlePatch ?? "none"}` : "absent"}`);
|
|
624
|
+
}
|
|
625
|
+
}
|