dsh-lighthouse 0.0.0-stage → 0.3.4
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 -0
- package/README.md +167 -2
- package/bin/dsh-lighthouse.mjs +133 -0
- package/client.js +388 -0
- package/cordis.patch.yml +4 -0
- package/lib/collect.js +484 -0
- package/lib/fleet.js +139 -0
- package/lib/index.js +385 -0
- package/lib/render.js +415 -0
- package/lib/status-route.js +126 -0
- package/package.json +93 -6
package/lib/collect.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-lighthouse · collect —— 去三个地方把事实取回来:本地工程、GitHub、插件市场。
|
|
3
|
+
*
|
|
4
|
+
* 纪律:
|
|
5
|
+
* - **只读**:本模块一个写请求都不发(全是 GET);
|
|
6
|
+
* - **可注入 fetch**:测试用假通道,不碰网络;
|
|
7
|
+
* - **失败不炸**:单个数据源挂了就记 `error`,其余照常出结果 —— 看板缺一格好过整块黑屏;
|
|
8
|
+
* - **registry 只拉一次**:那份 JSON 有 5MB 上下,**绝不按作品重复打接口**(第一版犯过)。
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-lighthouse/collect
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { parseLoose } from './fleet.js';
|
|
16
|
+
|
|
17
|
+
/** 市场接口默认地址(本机 DSH Web)。 */
|
|
18
|
+
export const DEFAULT_MARKET_URL = 'http://127.0.0.1:19387';
|
|
19
|
+
|
|
20
|
+
/** 收集文件时跳过的目录。 */
|
|
21
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', '.github', 'coverage', '.cache']);
|
|
22
|
+
|
|
23
|
+
/** 递归数文件(用于"本地有多少东西")。 */
|
|
24
|
+
async function countFiles(dir) {
|
|
25
|
+
let total = 0;
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
29
|
+
} catch {
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
34
|
+
const full = join(dir, entry.name);
|
|
35
|
+
let st;
|
|
36
|
+
try {
|
|
37
|
+
st = await stat(full);
|
|
38
|
+
} catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (st.isDirectory()) total += await countFiles(full);
|
|
42
|
+
else if (st.isFile()) total += 1;
|
|
43
|
+
}
|
|
44
|
+
return total;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 本地:读工程的 package.json 与测试文件是否存在。
|
|
49
|
+
* @param {object} work 名册条目
|
|
50
|
+
* @returns {Promise<object>} 本地事实
|
|
51
|
+
*/
|
|
52
|
+
export async function collectLocal(work) {
|
|
53
|
+
if (work.localDir === null) return { available: false, reason: '名册里没有 localDir' };
|
|
54
|
+
const dir = work.localDir;
|
|
55
|
+
const out = { available: true, dir };
|
|
56
|
+
try {
|
|
57
|
+
const text = await readFile(join(dir, 'package.json'), 'utf8');
|
|
58
|
+
const manifest = parseLoose(text);
|
|
59
|
+
out.name = manifest.name ?? null;
|
|
60
|
+
out.version = manifest.version ?? null;
|
|
61
|
+
out.compatible = Object.keys(manifest?.dsh?.compatibility?.dshReleases ?? {});
|
|
62
|
+
// 中文显示名:优先 dsh.displayName(插件自己声明),也顺手带出 homepage 当直达链接的候选
|
|
63
|
+
out.displayName = typeof manifest?.dsh?.displayName === 'string' ? manifest.dsh.displayName : null;
|
|
64
|
+
out.homepage = typeof manifest.homepage === 'string' ? manifest.homepage : null;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
out.available = false;
|
|
67
|
+
out.reason = `读不到 package.json:${err instanceof Error ? err.message : String(err)}`;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
out.files = await countFiles(dir);
|
|
71
|
+
try {
|
|
72
|
+
const st = await stat(join(dir, 'test', 'smoke.mjs'));
|
|
73
|
+
out.hasSmoke = st.isFile();
|
|
74
|
+
} catch {
|
|
75
|
+
out.hasSmoke = false;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 发一次 GET(不写任何东西)。
|
|
82
|
+
* @param {string} url 地址
|
|
83
|
+
* @param {object} headers 头
|
|
84
|
+
* @param {Function} fetchImpl fetch
|
|
85
|
+
* @returns {Promise<{status:number, json:any}>} 响应
|
|
86
|
+
*/
|
|
87
|
+
async function getJson(url, headers, fetchImpl) {
|
|
88
|
+
const res = await fetchImpl(url, { method: 'GET', headers });
|
|
89
|
+
let json = null;
|
|
90
|
+
try {
|
|
91
|
+
const text = await res.text();
|
|
92
|
+
json = text === '' ? null : JSON.parse(text);
|
|
93
|
+
} catch {
|
|
94
|
+
json = null;
|
|
95
|
+
}
|
|
96
|
+
return { status: res.status, json };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* GitHub:仓库现状 + 我给它开的 PR。
|
|
101
|
+
* @param {object} work 名册条目
|
|
102
|
+
* @param {object} opts 选项 `{ owner, token, fetchImpl, prIndex }`
|
|
103
|
+
* @returns {Promise<object>} GitHub 事实
|
|
104
|
+
*/
|
|
105
|
+
export async function collectGitHub(work, { owner, token, fetchImpl = fetch, prIndex = null }) {
|
|
106
|
+
const headers = {
|
|
107
|
+
Accept: 'application/vnd.github+json',
|
|
108
|
+
'User-Agent': 'dsh-lighthouse',
|
|
109
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
110
|
+
};
|
|
111
|
+
const out = { available: false, repo: `${owner}/${work.repo}` };
|
|
112
|
+
try {
|
|
113
|
+
const repoRes = await getJson(`https://api.github.com/repos/${owner}/${work.repo}`, headers, fetchImpl);
|
|
114
|
+
if (repoRes.status === 404) {
|
|
115
|
+
out.available = true;
|
|
116
|
+
out.exists = false;
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
if (repoRes.status !== 200 || repoRes.json === null) {
|
|
120
|
+
// 403 多半是"没带 token 走了匿名":匿名额度 60/小时,用完就全 403(实测踩过)
|
|
121
|
+
out.reason = repoRes.status === 403
|
|
122
|
+
? '读仓库失败(HTTP 403):像是没带 token 走了匿名(匿名额度 60/小时)'
|
|
123
|
+
: `读仓库失败(HTTP ${repoRes.status})`;
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
out.available = true;
|
|
127
|
+
out.exists = true;
|
|
128
|
+
out.stars = repoRes.json.stargazers_count ?? 0;
|
|
129
|
+
out.forks = repoRes.json.forks_count ?? 0;
|
|
130
|
+
// 观察区要的两项:「关注」= watchers(GitHub 的 subscribers_count);
|
|
131
|
+
// 「留言」= 开着的 issue/PR 数(open_issues_count 两者都算,够用作"有没有人说话")
|
|
132
|
+
out.watchers = repoRes.json.subscribers_count ?? 0;
|
|
133
|
+
out.openIssues = repoRes.json.open_issues_count ?? 0;
|
|
134
|
+
out.pushedAt = repoRes.json.pushed_at ?? null;
|
|
135
|
+
out.defaultBranch = repoRes.json.default_branch ?? null;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
out.reason = `网络失败:${err instanceof Error ? err.message : String(err)}`;
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (Array.isArray(prIndex)) {
|
|
142
|
+
const needle = `${owner}/${work.repo}`;
|
|
143
|
+
const mine = prIndex.filter((item) => String(item.title ?? '').includes(needle));
|
|
144
|
+
out.pr = mine.length > 0
|
|
145
|
+
? {
|
|
146
|
+
number: mine[0].number,
|
|
147
|
+
state: mine[0].state,
|
|
148
|
+
title: mine[0].title,
|
|
149
|
+
createdAt: mine[0].created_at,
|
|
150
|
+
closedAt: mine[0].closed_at ?? null,
|
|
151
|
+
mergedAt: mine[0].pull_request?.merged_at ?? null,
|
|
152
|
+
}
|
|
153
|
+
: null;
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 我开过的全部 PR(一次拉完,给每个作品认领)。失败返回 `[]`。
|
|
160
|
+
* @param {object} opts 选项
|
|
161
|
+
* @returns {Promise<Array>} PR 列表
|
|
162
|
+
*/
|
|
163
|
+
export async function collectMyPulls({ owner, token, fetchImpl = fetch }) {
|
|
164
|
+
const headers = {
|
|
165
|
+
Accept: 'application/vnd.github+json',
|
|
166
|
+
'User-Agent': 'dsh-lighthouse',
|
|
167
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
const res = await getJson(
|
|
171
|
+
`https://api.github.com/search/issues?q=${encodeURIComponent(`author:${owner} type:pr`)}&per_page=50`,
|
|
172
|
+
headers,
|
|
173
|
+
fetchImpl,
|
|
174
|
+
);
|
|
175
|
+
return res.status === 200 && Array.isArray(res.json?.items) ? res.json.items : [];
|
|
176
|
+
} catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 把市场登记表拉一次(要同源头,否则被拒 —— 实测)。
|
|
183
|
+
* @param {object} opts 选项
|
|
184
|
+
* @returns {Promise<object>} `{ available, updated, count, list, reason }`
|
|
185
|
+
*/
|
|
186
|
+
/** 登记表缓存的默认时长(登记表一天才更新一两次,5 分钟缓存完全够)。 */
|
|
187
|
+
export const REGISTRY_TTL_MS = 300_000;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 登记表缓存:**按 fetchImpl 实例分桶**。
|
|
191
|
+
* 真 fetch 共用一个桶(拿到真实收益),测试自建的假 fetch 各用各的(互不污染)。
|
|
192
|
+
*/
|
|
193
|
+
const registryCache = new WeakMap();
|
|
194
|
+
|
|
195
|
+
async function fetchRegistryUncached({ marketUrl, fetchImpl }) {
|
|
196
|
+
try {
|
|
197
|
+
const res = await getJson(
|
|
198
|
+
`${marketUrl}/dsh-market/registry`,
|
|
199
|
+
{ Accept: 'application/json', Origin: marketUrl, Referer: `${marketUrl}/` },
|
|
200
|
+
fetchImpl,
|
|
201
|
+
);
|
|
202
|
+
if (res.status !== 200 || res.json === null) return { available: false, reason: `市场接口 HTTP ${res.status}`, list: [] };
|
|
203
|
+
const registry = res.json.registry ?? {};
|
|
204
|
+
const list = Array.isArray(registry.plugins) ? registry.plugins : [];
|
|
205
|
+
return { available: true, updated: registry.updated ?? null, count: registry.count ?? list.length, list };
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return { available: false, reason: `市场接口不可达:${err instanceof Error ? err.message : String(err)}`, list: [] };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 拉市场登记表(带进程内缓存)。
|
|
213
|
+
*
|
|
214
|
+
* 为什么缓存:这份表 **4.4MB**,本地 HTTP 拉一次要 **2.8 秒**(2026-10-17 实测),
|
|
215
|
+
* 而它一天才更新一两次 —— 面板每刷一次都重拉纯属浪费。
|
|
216
|
+
* **只缓存成功结果**:读不到时如实返回,绝不留假数据。
|
|
217
|
+
*/
|
|
218
|
+
export async function fetchRegistry({
|
|
219
|
+
marketUrl = DEFAULT_MARKET_URL,
|
|
220
|
+
fetchImpl = fetch,
|
|
221
|
+
cacheTtlMs = REGISTRY_TTL_MS,
|
|
222
|
+
now = Date.now,
|
|
223
|
+
} = {}) {
|
|
224
|
+
let bucket = registryCache.get(fetchImpl);
|
|
225
|
+
if (bucket === undefined) {
|
|
226
|
+
bucket = new Map();
|
|
227
|
+
registryCache.set(fetchImpl, bucket);
|
|
228
|
+
}
|
|
229
|
+
const hit = bucket.get(marketUrl);
|
|
230
|
+
if (hit !== undefined && hit.value.available === true && now() - hit.at < cacheTtlMs) return hit.value;
|
|
231
|
+
const value = await fetchRegistryUncached({ marketUrl, fetchImpl });
|
|
232
|
+
bucket.set(marketUrl, { at: now(), value });
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 从已拉回来的登记表里查一个包(纯函数,不打接口)。
|
|
238
|
+
* @param {object} registry fetchRegistry 的结果
|
|
239
|
+
* @param {string} name 包名
|
|
240
|
+
* @returns {object} 市场事实
|
|
241
|
+
*/
|
|
242
|
+
export function marketFactOf(registry, name) {
|
|
243
|
+
if (registry?.available !== true) return { available: false, listed: false, reason: registry?.reason ?? '未取到登记表' };
|
|
244
|
+
const list = registry.list;
|
|
245
|
+
const hit = list.find((item) => item?.name === name);
|
|
246
|
+
if (!hit) return { available: true, listed: false, registryUpdated: registry.updated ?? null, registryCount: registry.count ?? list.length };
|
|
247
|
+
const categories = Array.isArray(hit.category) ? hit.category : [];
|
|
248
|
+
// 市场的 downloads 只统计发到 npm 的包;没发 npm 的条目字段是缺的(不是 0)。
|
|
249
|
+
// 把"没有数据"当成 0 会把它排到真有包但没人用的后面,也会显示成"下载 0"。
|
|
250
|
+
const toCount = (v) => (v === null || v === undefined || v === '' || Number.isNaN(Number(v)) ? null : Number(v));
|
|
251
|
+
const downloads = toCount(hit.downloads);
|
|
252
|
+
const sameCat = list
|
|
253
|
+
.filter((item) => Array.isArray(item?.category) && categories.some((c) => item.category.includes(c)))
|
|
254
|
+
.map((item) => toCount(item.downloads))
|
|
255
|
+
.filter((v) => v !== null)
|
|
256
|
+
.sort((a, b) => b - a);
|
|
257
|
+
return {
|
|
258
|
+
available: true,
|
|
259
|
+
listed: true,
|
|
260
|
+
registryUpdated: registry.updated ?? null,
|
|
261
|
+
registryCount: registry.count ?? list.length,
|
|
262
|
+
downloads,
|
|
263
|
+
stars: Number(hit.stars ?? 0),
|
|
264
|
+
added: hit.added ?? null,
|
|
265
|
+
category: categories,
|
|
266
|
+
version: hit.version ?? null,
|
|
267
|
+
install: hit.install ?? null,
|
|
268
|
+
marketOwner: hit.owner ?? null,
|
|
269
|
+
/** 市场条目页(可以点进去看的那个)—— 没收录时没有。 */
|
|
270
|
+
page: typeof hit.page === 'string' && hit.page !== '' ? hit.page : null,
|
|
271
|
+
descriptionZh: typeof hit.description?.zh === 'string' ? hit.description.zh : null,
|
|
272
|
+
rankInCategory: downloads === null ? null : sameCat.indexOf(downloads) + 1,
|
|
273
|
+
categorySize: sameCat.length,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 便捷版:拉一次登记表再查一个包。
|
|
279
|
+
* @param {string} name 包名
|
|
280
|
+
* @param {object} opts 选项
|
|
281
|
+
* @returns {Promise<object>} 市场事实
|
|
282
|
+
*/
|
|
283
|
+
export async function collectMarket(name, opts = {}) {
|
|
284
|
+
return marketFactOf(await fetchRegistry(opts), name);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 上游节奏:最近一批已合并 PR 等了多久(中位数)—— 用来判断"我们等得算不算久"。
|
|
289
|
+
* @param {object} opts 选项 `{ upstream, token, fetchImpl, sample }`
|
|
290
|
+
* @returns {Promise<object>} 上游事实
|
|
291
|
+
*/
|
|
292
|
+
/** POST 版 getJson(GraphQL 用)—— 形状与 getJson 一致,测试里的假 fetch 直接可用。 */
|
|
293
|
+
async function postJson(url, headers, body, fetchImpl) {
|
|
294
|
+
const res = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
295
|
+
let json = null;
|
|
296
|
+
try {
|
|
297
|
+
const text = await res.text();
|
|
298
|
+
json = text === '' ? null : JSON.parse(text);
|
|
299
|
+
} catch {
|
|
300
|
+
json = null;
|
|
301
|
+
}
|
|
302
|
+
return { status: res.status, json };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 用 GraphQL 取上游的合并节奏:**只要两个时间字段**。
|
|
307
|
+
*
|
|
308
|
+
* 为什么值得单独开一条路(2026-10-17 实测):REST 的
|
|
309
|
+
* `pulls?state=closed&sort=updated&per_page=60` 要 **49 秒** —— 时间全花在传那一大坨
|
|
310
|
+
* PR 对象上(每个 PR 自带几十个字段);GraphQL 只回 `createdAt/mergedAt`,
|
|
311
|
+
* 100 条只要 **1.3 秒**(快约 38 倍)。这是整条数据口最大的瓶颈。
|
|
312
|
+
*
|
|
313
|
+
* @returns {Promise<{waits:number[], openCount:number|null}|null>} 不可用返回 null(调用方回退 REST)
|
|
314
|
+
*/
|
|
315
|
+
async function upstreamViaGraphQL({ upstream, token, fetchImpl, sample }) {
|
|
316
|
+
try {
|
|
317
|
+
const query = 'query($owner:String!,$name:String!,$n:Int!){repository(owner:$owner,name:$name){'
|
|
318
|
+
+ 'merged:pullRequests(last:$n,states:MERGED){nodes{createdAt mergedAt}}'
|
|
319
|
+
+ 'open:pullRequests(states:OPEN){totalCount}}}';
|
|
320
|
+
const res = await postJson('https://api.github.com/graphql', {
|
|
321
|
+
Accept: 'application/json',
|
|
322
|
+
'Content-Type': 'application/json',
|
|
323
|
+
'User-Agent': 'dsh-lighthouse',
|
|
324
|
+
Authorization: `Bearer ${token}`,
|
|
325
|
+
}, {
|
|
326
|
+
query,
|
|
327
|
+
variables: { owner: upstream.owner, name: upstream.repo, n: sample },
|
|
328
|
+
}, fetchImpl);
|
|
329
|
+
if (res.status !== 200 || res.json === null) return null;
|
|
330
|
+
const repo = res.json?.data?.repository;
|
|
331
|
+
const nodes = repo?.merged?.nodes;
|
|
332
|
+
if (!Array.isArray(nodes)) return null;
|
|
333
|
+
const waits = nodes
|
|
334
|
+
.map((node) => (Date.parse(node.mergedAt) - Date.parse(node.createdAt)) / 3600000)
|
|
335
|
+
.filter((h) => Number.isFinite(h) && h >= 0)
|
|
336
|
+
.sort((a, b) => a - b);
|
|
337
|
+
if (waits.length === 0) return null;
|
|
338
|
+
const openCount = Number.isFinite(repo?.open?.totalCount) ? repo.open.totalCount : null;
|
|
339
|
+
return { waits, openCount };
|
|
340
|
+
} catch {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function collectUpstream({ upstream, token, fetchImpl = fetch, sample = 60 }) {
|
|
346
|
+
const out = { available: false, upstream: `${upstream.owner}/${upstream.repo}` };
|
|
347
|
+
const headers = {
|
|
348
|
+
Accept: 'application/vnd.github+json',
|
|
349
|
+
'User-Agent': 'dsh-lighthouse',
|
|
350
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
351
|
+
};
|
|
352
|
+
try {
|
|
353
|
+
// 先试 GraphQL(快约 38 倍);不可用(没 token / 形状不对 / 网络问题)再回退 REST
|
|
354
|
+
let waits = null;
|
|
355
|
+
if (typeof token === 'string' && token !== '') {
|
|
356
|
+
const viaGraph = await upstreamViaGraphQL({ upstream, token, fetchImpl, sample });
|
|
357
|
+
if (viaGraph !== null) {
|
|
358
|
+
waits = viaGraph.waits;
|
|
359
|
+
out.openCount = viaGraph.openCount;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (waits === null) {
|
|
363
|
+
const res = await getJson(
|
|
364
|
+
`https://api.github.com/repos/${upstream.owner}/${upstream.repo}/pulls?state=closed&sort=updated&direction=desc&per_page=${sample}`,
|
|
365
|
+
headers,
|
|
366
|
+
fetchImpl,
|
|
367
|
+
);
|
|
368
|
+
if (res.status !== 200 || !Array.isArray(res.json)) {
|
|
369
|
+
out.reason = `读上游 PR 失败(HTTP ${res.status})`;
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
waits = res.json
|
|
373
|
+
.filter((pr) => typeof pr.merged_at === 'string' && pr.merged_at !== '')
|
|
374
|
+
.map((pr) => (Date.parse(pr.merged_at) - Date.parse(pr.created_at)) / 3600000)
|
|
375
|
+
.filter((h) => Number.isFinite(h) && h >= 0)
|
|
376
|
+
.sort((a, b) => a - b);
|
|
377
|
+
out.openCount = res.json.length;
|
|
378
|
+
}
|
|
379
|
+
if (waits.length === 0) {
|
|
380
|
+
out.reason = '样本里没有已合并的 PR';
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
out.available = true;
|
|
384
|
+
out.samples = waits.length;
|
|
385
|
+
out.medianHours = waits[Math.floor(waits.length / 2)];
|
|
386
|
+
out.minHours = waits[0];
|
|
387
|
+
out.maxHours = waits[waits.length - 1];
|
|
388
|
+
return out;
|
|
389
|
+
} catch (err) {
|
|
390
|
+
out.reason = `网络失败:${err instanceof Error ? err.message : String(err)}`;
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* npm 上有没有这个包(只回答有/没有/查不动)。
|
|
397
|
+
* @param {string} name 包名
|
|
398
|
+
* @param {object} opts 选项
|
|
399
|
+
* @returns {Promise<object>} npm 事实
|
|
400
|
+
*/
|
|
401
|
+
export async function collectNpm(name, { fetchImpl = fetch } = {}) {
|
|
402
|
+
try {
|
|
403
|
+
const res = await getJson(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { Accept: 'application/json' }, fetchImpl);
|
|
404
|
+
if (res.status === 200) return { available: true, published: true, version: res.json?.['dist-tags']?.latest ?? null };
|
|
405
|
+
if (res.status === 404) return { available: true, published: false };
|
|
406
|
+
return { available: false, reason: `HTTP ${res.status}` };
|
|
407
|
+
} catch (err) {
|
|
408
|
+
return { available: false, reason: err instanceof Error ? err.message : String(err) };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** 小时 → 人话。 */
|
|
413
|
+
export function hoursText(hours) {
|
|
414
|
+
if (!Number.isFinite(hours)) return '?';
|
|
415
|
+
if (hours < 1) return `${Math.round(hours * 60)} 分钟`;
|
|
416
|
+
if (hours < 48) return `${hours.toFixed(1)} 小时`;
|
|
417
|
+
return `${(hours / 24).toFixed(1)} 天`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 把整份名册跑一遍。
|
|
422
|
+
* @param {object} fleet 名册
|
|
423
|
+
* @param {object} deps 依赖 `{ token, fetchImpl, marketUrl, includeNpm, now }`
|
|
424
|
+
* @returns {Promise<object>} 报告
|
|
425
|
+
*/
|
|
426
|
+
export async function collectAll(fleet, deps = {}) {
|
|
427
|
+
const { token = null, fetchImpl = fetch, marketUrl = DEFAULT_MARKET_URL, includeNpm = true, now = Date.now() } = deps;
|
|
428
|
+
|
|
429
|
+
// 三份"整表"各拉一次:登记表(5MB)、我开过的 PR、上游节奏
|
|
430
|
+
const [registry, prIndex, upstream] = await Promise.all([
|
|
431
|
+
fetchRegistry({ marketUrl, fetchImpl }),
|
|
432
|
+
collectMyPulls({ owner: fleet.owner, token, fetchImpl }),
|
|
433
|
+
collectUpstream({ upstream: fleet.upstream, token, fetchImpl }),
|
|
434
|
+
]);
|
|
435
|
+
|
|
436
|
+
// 作品之间**必须并发**:串行查 7 个作品时整条口子要 11.6 秒(实测),
|
|
437
|
+
// 面板上就表现为一段很长的"读取中";并发后总耗时≈最慢的那一个(3~4 秒)。
|
|
438
|
+
const works = await Promise.all(fleet.works.map(async (work) => {
|
|
439
|
+
const [local, github, npm] = await Promise.all([
|
|
440
|
+
collectLocal(work),
|
|
441
|
+
collectGitHub(work, { owner: fleet.owner, token, fetchImpl, prIndex }),
|
|
442
|
+
// npm 包名可以和目录/GitHub 仓库名不同(例:仓库 dsh-tool-gate,npm 包 dsh-ctx-gate)。
|
|
443
|
+
// 名册里给了 work.npm 就用它,否则退回 work.name。
|
|
444
|
+
includeNpm ? collectNpm(work.npm ?? work.name, { fetchImpl }) : Promise.resolve({ available: false, reason: '已跳过' }),
|
|
445
|
+
]);
|
|
446
|
+
const market = marketFactOf(registry, work.name);
|
|
447
|
+
// 直达链接:名册显式给的优先;否则 GitHub 仓库;npm 与市场页面能确定才给(不确定就不编)。
|
|
448
|
+
const links = {
|
|
449
|
+
github: work.url ?? `https://github.com/${fleet.owner}/${work.repo}`,
|
|
450
|
+
npm: `https://www.npmjs.com/package/${work.npm ?? work.name}`,
|
|
451
|
+
market: market.listed === true ? market.page : null,
|
|
452
|
+
};
|
|
453
|
+
return { work, local, github, market, npm, links };
|
|
454
|
+
}));
|
|
455
|
+
|
|
456
|
+
const waiting = works
|
|
457
|
+
.map((w) => w.github.pr)
|
|
458
|
+
// 用 `!= null` 同时挡 null 与 undefined:prIndex 为空时 collectGitHub 根本不会挂 pr 字段
|
|
459
|
+
.filter((pr) => pr != null && pr.state === 'open')
|
|
460
|
+
.map((pr) => (now - Date.parse(pr.createdAt)) / 3600000);
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
generatedAt: new Date(now).toISOString(),
|
|
464
|
+
owner: fleet.owner,
|
|
465
|
+
upstream,
|
|
466
|
+
market: {
|
|
467
|
+
url: marketUrl,
|
|
468
|
+
available: registry.available === true,
|
|
469
|
+
updated: registry.updated ?? null,
|
|
470
|
+
count: registry.count ?? null,
|
|
471
|
+
reason: registry.reason ?? null,
|
|
472
|
+
},
|
|
473
|
+
works,
|
|
474
|
+
summary: {
|
|
475
|
+
works: works.length,
|
|
476
|
+
published: works.filter((w) => w.github.exists === true).length,
|
|
477
|
+
listed: works.filter((w) => w.market?.listed === true).length,
|
|
478
|
+
totalDownloads: works.reduce((sum, w) => sum + (w.market?.listed === true && w.market.downloads !== null && w.market.downloads !== undefined ? Number(w.market.downloads) : 0), 0),
|
|
479
|
+
totalStars: works.reduce((sum, w) => sum + Number(w.github.stars ?? 0), 0),
|
|
480
|
+
openPrs: waiting.length,
|
|
481
|
+
longestWaitHours: waiting.length > 0 ? Math.max(...waiting) : null,
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
package/lib/fleet.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-lighthouse · fleet —— 舰队名册:灯塔照着这份名册看,不猜。
|
|
3
|
+
*
|
|
4
|
+
* 名册是**显式文件**(默认 `~/.dsh/lighthouse-fleet.json`),形状:
|
|
5
|
+
* ```json
|
|
6
|
+
* {
|
|
7
|
+
* "version": 1,
|
|
8
|
+
* "owner": "7gzgn",
|
|
9
|
+
* "upstream": { "owner": "awesome-dsh-plugin", "repo": "awesome-dsh-plugin", "branch": "main" },
|
|
10
|
+
* "works": [
|
|
11
|
+
* { "name": "dsh-slipway", "repo": "dsh-slipway", "category": "tools", "localDir": "D:\\dsh\\projects\\dsh-slipway" }
|
|
12
|
+
* ]
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
* 为什么不做"自动发现":**目标必须被明确指认** —— 从目录名或包名前缀去猜
|
|
16
|
+
* "哪些是你的作品",猜错了就会把别人的仓库报进你的看板。
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-lighthouse/fleet
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
/** 名册格式版本。 */
|
|
25
|
+
export const FLEET_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
/** 默认名册路径。 */
|
|
28
|
+
export function defaultFleetPath() {
|
|
29
|
+
const home = process.env.USERPROFILE ?? process.env.HOME ?? '';
|
|
30
|
+
return join(home, '.dsh', 'lighthouse-fleet.json');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 去掉 BOM 并解析 JSON。 */
|
|
34
|
+
export function parseLoose(text) {
|
|
35
|
+
return JSON.parse(String(text).replace(/^\uFEFF/, ''));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 校验名册。**缺 owner 或缺 works 就是错**,不做补全。
|
|
40
|
+
* @param {any} data 名册内容
|
|
41
|
+
* @returns {{ok:boolean, errors:string[], fleet?:object}} 结论
|
|
42
|
+
*/
|
|
43
|
+
export function validateFleet(data) {
|
|
44
|
+
const errors = [];
|
|
45
|
+
if (data === null || typeof data !== 'object') {
|
|
46
|
+
return { ok: false, errors: ['名册不是对象'] };
|
|
47
|
+
}
|
|
48
|
+
const owner = data.owner;
|
|
49
|
+
if (typeof owner !== 'string' || owner.trim() === '') {
|
|
50
|
+
errors.push('名册缺 owner(GitHub 账号)—— 不猜,必须显式写');
|
|
51
|
+
}
|
|
52
|
+
if (!Array.isArray(data.works) || data.works.length === 0) {
|
|
53
|
+
errors.push('名册的 works 必须是非空数组');
|
|
54
|
+
} else {
|
|
55
|
+
data.works.forEach((work, index) => {
|
|
56
|
+
if (work === null || typeof work !== 'object') {
|
|
57
|
+
errors.push(`works[${index}] 不是对象`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (typeof work.name !== 'string' || work.name.trim() === '') errors.push(`works[${index}] 缺 name`);
|
|
61
|
+
if (typeof work.repo !== 'string' || work.repo.trim() === '') errors.push(`works[${index}] 缺 repo`);
|
|
62
|
+
if (work.localDir !== undefined && typeof work.localDir !== 'string') errors.push(`works[${index}] 的 localDir 必须是字符串`);
|
|
63
|
+
if (work.nameZh !== undefined && typeof work.nameZh !== 'string') errors.push(`works[${index}] 的 nameZh 必须是字符串`);
|
|
64
|
+
if (work.url !== undefined && typeof work.url !== 'string') errors.push(`works[${index}] 的 url 必须是字符串`);
|
|
65
|
+
if (work.mirrors !== undefined && !Array.isArray(work.mirrors)) errors.push(`works[${index}] 的 mirrors 必须是数组`);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
errors: [],
|
|
72
|
+
fleet: {
|
|
73
|
+
version: typeof data.version === 'number' ? data.version : FLEET_VERSION,
|
|
74
|
+
owner: owner.trim(),
|
|
75
|
+
upstream: {
|
|
76
|
+
owner: data.upstream?.owner ?? 'awesome-dsh-plugin',
|
|
77
|
+
repo: data.upstream?.repo ?? 'awesome-dsh-plugin',
|
|
78
|
+
branch: data.upstream?.branch ?? 'main',
|
|
79
|
+
},
|
|
80
|
+
works: data.works.map((work) => ({
|
|
81
|
+
name: work.name.trim(),
|
|
82
|
+
repo: work.repo.trim(),
|
|
83
|
+
category: typeof work.category === 'string' ? work.category : null,
|
|
84
|
+
localDir: typeof work.localDir === 'string' && work.localDir !== '' ? work.localDir : null,
|
|
85
|
+
/** 中文显示名(不填就退回 package.json 的 dsh.displayName,再退回英文名)。 */
|
|
86
|
+
nameZh: typeof work.nameZh === 'string' && work.nameZh.trim() !== '' ? work.nameZh.trim() : null,
|
|
87
|
+
/** 直达链接(不填就按 owner/repo 生成 GitHub 地址)。 */
|
|
88
|
+
url: typeof work.url === 'string' && work.url.trim() !== '' ? work.url.trim() : null,
|
|
89
|
+
/** npm 包名(仓库名与包名不一定一样,例:仓库 dsh-tool-gate → 包 dsh-ctx-gate)。不填就用 name 查。 */
|
|
90
|
+
npm: typeof work.npm === 'string' && work.npm.trim() !== '' ? work.npm.trim() : null,
|
|
91
|
+
/** 额外的发布源(例如 Gitee / GitLab / npm),形如 { platform, url }。 */
|
|
92
|
+
mirrors: Array.isArray(work.mirrors)
|
|
93
|
+
? work.mirrors
|
|
94
|
+
.filter((m) => m !== null && typeof m === 'object' && typeof m.url === 'string' && m.url !== '')
|
|
95
|
+
.map((m) => ({ platform: typeof m.platform === 'string' ? m.platform : 'other', url: m.url }))
|
|
96
|
+
: [],
|
|
97
|
+
})),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 读名册。
|
|
104
|
+
* @param {string} file 路径
|
|
105
|
+
* @param {object} [deps] 依赖(测试注入 readFileImpl)
|
|
106
|
+
* @returns {Promise<{ok:boolean, errors:string[], fleet?:object, path:string}>} 结论
|
|
107
|
+
*/
|
|
108
|
+
export async function readFleet(file, deps = {}) {
|
|
109
|
+
const read = deps.readFileImpl ?? readFile;
|
|
110
|
+
let text;
|
|
111
|
+
try {
|
|
112
|
+
text = await read(file, 'utf8');
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return { ok: false, errors: [`名册读不到(${file}):${err instanceof Error ? err.message : String(err)}`], path: file };
|
|
115
|
+
}
|
|
116
|
+
let data;
|
|
117
|
+
try {
|
|
118
|
+
data = parseLoose(text);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return { ok: false, errors: [`名册不是合法 JSON:${err instanceof Error ? err.message : String(err)}`], path: file };
|
|
121
|
+
}
|
|
122
|
+
const checked = validateFleet(data);
|
|
123
|
+
return { ...checked, path: file };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 写名册(建目录、格式化、带尾换行)。
|
|
128
|
+
* @param {string} file 路径
|
|
129
|
+
* @param {object} fleet 名册
|
|
130
|
+
* @param {object} [deps] 依赖
|
|
131
|
+
* @returns {Promise<string>} 实际写入的路径
|
|
132
|
+
*/
|
|
133
|
+
export async function writeFleet(file, fleet, deps = {}) {
|
|
134
|
+
const write = deps.writeFileImpl ?? writeFile;
|
|
135
|
+
const mk = deps.mkdirImpl ?? mkdir;
|
|
136
|
+
await mk(dirname(file), { recursive: true });
|
|
137
|
+
await write(file, `${JSON.stringify({ version: FLEET_VERSION, ...fleet }, null, 2)}\n`, 'utf8');
|
|
138
|
+
return file;
|
|
139
|
+
}
|