dsh-skill-hub 0.3.7 → 0.3.9
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 +40 -50
- package/README.zh.md +38 -48
- package/lib/client.js +2209 -1820
- package/lib/client.js.map +1 -1
- package/lib/index.js +653 -419
- package/lib/types/client/SkillHubSettingsCard.d.ts +1 -0
- package/lib/types/client/api.d.ts +12 -6
- package/lib/types/client/helpers.d.ts +0 -1
- package/lib/types/client/locales/common.d.ts +52 -0
- package/lib/types/client/locales/detail.d.ts +16 -0
- package/lib/types/client/locales/market.d.ts +73 -0
- package/lib/types/client/locales/settings.d.ts +41 -0
- package/lib/types/client/locales/skills.d.ts +65 -0
- package/lib/types/client/locales/sources.d.ts +35 -0
- package/lib/types/client/locales.d.ts +124 -118
- package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
- package/lib/types/client/panel/MarketView.d.ts +1 -0
- package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
- package/lib/types/client/panel/hooks/shared.d.ts +72 -0
- package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
- package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
- package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
- package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
- package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
- package/lib/types/client/panel/market-format.d.ts +6 -0
- package/lib/types/client/panel/useSkillHub.d.ts +45 -73
- package/lib/types/client/settings-card.d.ts +20 -0
- package/lib/types/client/settings-form.d.ts +6 -0
- package/lib/types/protocol/api.d.ts +38 -0
- package/lib/types/protocol/catalog.d.ts +155 -0
- package/lib/types/protocol/config.d.ts +113 -0
- package/lib/types/protocol/groups.d.ts +95 -0
- package/lib/types/protocol/market.d.ts +94 -0
- package/lib/types/protocol/repo.d.ts +107 -0
- package/lib/types/protocol/sources.d.ts +139 -0
- package/lib/types/protocol/stats.d.ts +46 -0
- package/lib/types/protocol.d.ts +13 -743
- package/lib/types/repo/github-client.d.ts +42 -0
- package/lib/types/repo.d.ts +6 -17
- package/lib/types/routes/catalog.d.ts +7 -0
- package/lib/types/routes/config.d.ts +7 -0
- package/lib/types/routes/groups.d.ts +7 -0
- package/lib/types/routes/helpers.d.ts +145 -0
- package/lib/types/routes/market.d.ts +8 -0
- package/lib/types/routes/repo-import.d.ts +7 -0
- package/lib/types/routes/route-state.d.ts +55 -0
- package/lib/types/routes/sources.d.ts +8 -0
- package/lib/types/routes.d.ts +5 -34
- package/lib/types/stats.d.ts +3 -2
- package/lib/types/store/errors.d.ts +10 -0
- package/lib/types/store/migrate.d.ts +44 -0
- package/lib/types/store/paths.d.ts +32 -0
- package/lib/types/store/store.d.ts +133 -0
- package/lib/types/store.d.ts +7 -146
- package/package.json +30 -28
- package/src/client/SkillHubSettingsCard.tsx +17 -3
- package/src/client/api.ts +101 -195
- package/src/client/helpers.ts +10 -1
- package/src/client/locales/common.ts +102 -0
- package/src/client/locales/detail.ts +30 -0
- package/src/client/locales/market.ts +144 -0
- package/src/client/locales/settings.ts +80 -0
- package/src/client/locales/skills.ts +128 -0
- package/src/client/locales/sources.ts +68 -0
- package/src/client/locales.ts +26 -512
- package/src/client/panel/MarketSourceRow.tsx +108 -0
- package/src/client/panel/MarketView.tsx +12 -253
- package/src/client/panel/RepoScanCard.tsx +171 -0
- package/src/client/panel/hooks/shared.ts +81 -0
- package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
- package/src/client/panel/hooks/useGroupFlow.ts +240 -0
- package/src/client/panel/hooks/useMarketFlow.ts +421 -0
- package/src/client/panel/hooks/useMetaFlow.ts +58 -0
- package/src/client/panel/hooks/useSourceFlow.ts +193 -0
- package/src/client/panel/market-format.ts +22 -0
- package/src/client/panel/useSkillHub.ts +141 -1032
- package/src/client/settings-card.tsx +51 -0
- package/src/client/settings-form.ts +18 -0
- package/src/index.ts +14 -4
- package/src/protocol/api.ts +38 -0
- package/src/protocol/catalog.ts +168 -0
- package/src/protocol/config.ts +145 -0
- package/src/protocol/groups.ts +106 -0
- package/src/protocol/market.ts +100 -0
- package/src/protocol/repo.ts +104 -0
- package/src/protocol/sources.ts +146 -0
- package/src/protocol/stats.ts +42 -0
- package/src/protocol.ts +83 -809
- package/src/repo/github-client.ts +131 -0
- package/src/repo.test.ts +24 -1
- package/src/repo.ts +50 -170
- package/src/routes/catalog.ts +309 -0
- package/src/routes/config.ts +119 -0
- package/src/routes/groups.ts +133 -0
- package/src/routes/helpers.ts +489 -0
- package/src/routes/market.ts +255 -0
- package/src/routes/repo-import.ts +267 -0
- package/src/routes/route-state.ts +89 -0
- package/src/routes/sources.ts +318 -0
- package/src/routes.test.ts +20 -0
- package/src/routes.ts +21 -1699
- package/src/stats.test.ts +58 -3
- package/src/stats.ts +45 -16
- package/src/store/errors.ts +14 -0
- package/src/store/migrate.ts +272 -0
- package/src/store/paths.ts +43 -0
- package/src/store/store.ts +532 -0
- package/src/store.test.ts +18 -0
- package/src/store.ts +7 -737
package/lib/index.js
CHANGED
|
@@ -6,13 +6,7 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { dump, load } from "js-yaml";
|
|
7
7
|
import { isSkillName } from "@deepseek-ai/dsh-skill";
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
|
-
//#region src/protocol.ts
|
|
10
|
-
/**
|
|
11
|
-
* Shared API contract for dsh-skill-hub: the route paths and payload shapes
|
|
12
|
-
* both the host half and the browser half import. The browser half must never
|
|
13
|
-
* depend on host SDK packages, so registry types are re-spelled here as
|
|
14
|
-
* plain JSON-safe interfaces.
|
|
15
|
-
*/
|
|
9
|
+
//#region src/protocol/api.ts
|
|
16
10
|
/** Browser-facing base paths of the skill-hub API family. */
|
|
17
11
|
const SKILL_HUB_API = {
|
|
18
12
|
catalog: "/api/skill-hub/catalog",
|
|
@@ -48,8 +42,11 @@ const SKILL_HUB_API = {
|
|
|
48
42
|
sourceRestore: "/api/skill-hub/sources/restore",
|
|
49
43
|
sourceTrashClear: "/api/skill-hub/sources/trash/clear",
|
|
50
44
|
diagnosticFix: "/api/skill-hub/diagnostic/fix",
|
|
51
|
-
marketSourceVersions: "/api/skill-hub/market/source/versions"
|
|
45
|
+
marketSourceVersions: "/api/skill-hub/market/source/versions",
|
|
46
|
+
marketStats: "/api/skill-hub/market/stats"
|
|
52
47
|
};
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/protocol/config.ts
|
|
53
50
|
/**
|
|
54
51
|
* Hub config defaults — the single source every layer reads: the cordis
|
|
55
52
|
* schema (index.ts), the host's saved-override merge, and the routes'
|
|
@@ -74,6 +71,7 @@ const HUB_CONFIG_DEFAULTS = {
|
|
|
74
71
|
function resolveHubConfig(saved, base = {}) {
|
|
75
72
|
const dotModelColor = saved.dotModelColor !== void 0 ? saved.dotModelColor : base.dotModelColor;
|
|
76
73
|
const dotUserColor = saved.dotUserColor !== void 0 ? saved.dotUserColor : base.dotUserColor;
|
|
74
|
+
const githubToken = saved.githubToken !== void 0 ? saved.githubToken : base.githubToken;
|
|
77
75
|
const windowDays = clampNumber(saved.statsWindowDays ?? base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays;
|
|
78
76
|
const scanMinutes = clampNumber(saved.statsScanMinutes ?? base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes;
|
|
79
77
|
return {
|
|
@@ -85,7 +83,8 @@ function resolveHubConfig(saved, base = {}) {
|
|
|
85
83
|
statsWindowDays: windowDays,
|
|
86
84
|
statsScanMinutes: scanMinutes,
|
|
87
85
|
...dotModelColor !== void 0 ? { dotModelColor } : {},
|
|
88
|
-
...dotUserColor !== void 0 ? { dotUserColor } : {}
|
|
86
|
+
...dotUserColor !== void 0 ? { dotUserColor } : {},
|
|
87
|
+
...githubToken !== void 0 ? { githubToken } : {}
|
|
89
88
|
};
|
|
90
89
|
}
|
|
91
90
|
/** Clamp a numeric override into a valid value; undefined/invalid stays undefined. */
|
|
@@ -95,25 +94,16 @@ function clampNumber(value, min) {
|
|
|
95
94
|
}
|
|
96
95
|
/** HEX color validation shared by host routes and the settings card. */
|
|
97
96
|
const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
|
|
97
|
+
/** GitHub token validation shared by host routes and the settings card. */
|
|
98
|
+
const GITHUB_TOKEN_RE = /^[A-Za-z0-9_\-]{8,255}$/;
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/protocol/sources.ts
|
|
98
101
|
/** 项目级技能来源(它们有 workspace 归属,不属于「个人」组)。 */
|
|
99
102
|
function isProjectSource(source) {
|
|
100
103
|
return source === "project-dsh" || source === "project-agents";
|
|
101
104
|
}
|
|
102
105
|
//#endregion
|
|
103
|
-
//#region src/store.ts
|
|
104
|
-
/**
|
|
105
|
-
* Hub sidecar store: remembers which skills the hub toggled off and the
|
|
106
|
-
* user's organization/tracking records. Disabling renames the skill's
|
|
107
|
-
* SKILL.md (or flat .md) out of the filesystem provider's discovery shapes,
|
|
108
|
-
* so the provider catalog alone cannot see disabled skills; this store keeps
|
|
109
|
-
* name/path/root so the GUI can list them and re-enable. It also persists
|
|
110
|
-
* user tag groups, upstream source records (repo + commit snapshot for
|
|
111
|
-
* update checks), the market source list, and the trash (skills removed
|
|
112
|
-
* after upstream deletion).
|
|
113
|
-
*
|
|
114
|
-
* State file: $DSH_HOME/dsh-skill-hub.json — a small JSON document written
|
|
115
|
-
* atomically (tmp file + rename).
|
|
116
|
-
*/
|
|
106
|
+
//#region src/store/paths.ts
|
|
117
107
|
/** 默认场景名(系统预置的兜底场景,新技能自动归入)。 */
|
|
118
108
|
const DEFAULT_SCENE_NAME = "通用";
|
|
119
109
|
/** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
|
|
@@ -124,6 +114,8 @@ function dshHome() {
|
|
|
124
114
|
function statePath(home = dshHome()) {
|
|
125
115
|
return join(home, "dsh-skill-hub.json");
|
|
126
116
|
}
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/store/errors.ts
|
|
127
119
|
/**
|
|
128
120
|
* Business-rule failure the routes layer can map onto a 4xx status instead
|
|
129
121
|
* of a blanket 500: user input is invalid (validation → 400), the target
|
|
@@ -138,6 +130,8 @@ var StoreError = class extends Error {
|
|
|
138
130
|
this.kind = kind;
|
|
139
131
|
}
|
|
140
132
|
};
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/store/migrate.ts
|
|
141
135
|
/**
|
|
142
136
|
* Normalize an arbitrary parsed sidecar document to the current schema.
|
|
143
137
|
* v1 to v2: scenes are dropped (the one-click role merged into group
|
|
@@ -184,10 +178,152 @@ function migrateStore(parsed) {
|
|
|
184
178
|
...marketSources !== void 0 ? { marketSources } : {},
|
|
185
179
|
...trash !== void 0 ? { trash } : {},
|
|
186
180
|
...record.skillStats !== void 0 ? { skillStats: record.skillStats } : {},
|
|
181
|
+
...record.marketStats !== void 0 ? { marketStats: record.marketStats } : {},
|
|
187
182
|
...Array.isArray(record.collectionOrder) ? { collectionOrder: record.collectionOrder } : {},
|
|
188
183
|
...Array.isArray(record.sourceGroupOrder) ? { sourceGroupOrder: record.sourceGroupOrder } : {}
|
|
189
184
|
};
|
|
190
185
|
}
|
|
186
|
+
/** 小优化:统一的非空字符串数组清洗(去空、去重可选由调用方处理)。 */
|
|
187
|
+
function cleanStringList(value) {
|
|
188
|
+
if (!Array.isArray(value)) return [];
|
|
189
|
+
return value.filter((n) => typeof n === "string" && n !== "");
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 把迁移后的原始文档校验为内存状态:坏条目丢弃、坏桶降级为空,
|
|
193
|
+
* 绝不因一条脏数据丢掉整个 sidecar(与原 ensureLoaded 内联逻辑一致)。
|
|
194
|
+
*/
|
|
195
|
+
function hydrateMigratedState(migrated) {
|
|
196
|
+
const entries = /* @__PURE__ */ new Map();
|
|
197
|
+
if (Array.isArray(migrated.disabled)) {
|
|
198
|
+
for (const entry of migrated.disabled) if (typeof entry?.name === "string" && typeof entry?.path === "string") entries.set(entry.name, entry);
|
|
199
|
+
}
|
|
200
|
+
const config = {};
|
|
201
|
+
const saved = migrated.config;
|
|
202
|
+
if (typeof saved === "object" && saved !== null) {
|
|
203
|
+
if (typeof saved.enabled === "boolean") config.enabled = saved.enabled;
|
|
204
|
+
if (typeof saved.announceToAgent === "boolean") config.announceToAgent = saved.announceToAgent;
|
|
205
|
+
if (typeof saved.dotModelColor === "string" && saved.dotModelColor !== "") config.dotModelColor = saved.dotModelColor;
|
|
206
|
+
if (typeof saved.dotUserColor === "string" && saved.dotUserColor !== "") config.dotUserColor = saved.dotUserColor;
|
|
207
|
+
if (typeof saved.showUseCount === "boolean") config.showUseCount = saved.showUseCount;
|
|
208
|
+
if (typeof saved.showUseTime === "boolean") config.showUseTime = saved.showUseTime;
|
|
209
|
+
if (typeof saved.showGroupSummary === "boolean") config.showGroupSummary = saved.showGroupSummary;
|
|
210
|
+
}
|
|
211
|
+
const tagsById = /* @__PURE__ */ new Map();
|
|
212
|
+
if (Array.isArray(migrated.tags)) for (const entry of migrated.tags) {
|
|
213
|
+
const tag = entry;
|
|
214
|
+
if (tag !== null && typeof tag === "object" && typeof tag.id === "string" && typeof tag.name === "string" && Array.isArray(tag.skillNames)) tagsById.set(tag.id, {
|
|
215
|
+
id: tag.id,
|
|
216
|
+
name: tag.name,
|
|
217
|
+
skillNames: tag.skillNames.filter((n) => typeof n === "string"),
|
|
218
|
+
...tag.default === true ? { default: true } : {}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const sourcesByRepo = /* @__PURE__ */ new Map();
|
|
222
|
+
if (Array.isArray(migrated.sources)) for (const entry of migrated.sources) {
|
|
223
|
+
const source = entry;
|
|
224
|
+
if (source !== null && typeof source === "object" && typeof source.repo === "string" && source.repo !== "" && Array.isArray(source.skills)) {
|
|
225
|
+
const manifest = source.manifest;
|
|
226
|
+
const rawRoot = typeof source.root === "string" && source.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(source.root) ? source.root : "skills";
|
|
227
|
+
sourcesByRepo.set(source.repo, {
|
|
228
|
+
repo: source.repo,
|
|
229
|
+
...typeof source.ref === "string" && source.ref !== "" ? { ref: source.ref } : {},
|
|
230
|
+
root: rawRoot,
|
|
231
|
+
commitSha: typeof source.commitSha === "string" ? source.commitSha : "",
|
|
232
|
+
skills: source.skills.filter((n) => typeof n === "string"),
|
|
233
|
+
...manifest !== null && typeof manifest === "object" && !Array.isArray(manifest) ? { manifest: Object.fromEntries(Object.entries(manifest).filter(([, size]) => typeof size === "number")) } : {}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const marketSources = [];
|
|
238
|
+
if (Array.isArray(migrated.marketSources)) for (const entry of migrated.marketSources) {
|
|
239
|
+
const item = entry;
|
|
240
|
+
if (item !== null && typeof item === "object" && typeof item.repo === "string" && item.repo !== "" && !marketSources.some((s) => s.repo === item.repo)) marketSources.push({
|
|
241
|
+
repo: item.repo,
|
|
242
|
+
...typeof item.ref === "string" && item.ref !== "" ? { ref: item.ref } : {},
|
|
243
|
+
...typeof item.commitSha === "string" && item.commitSha !== "" ? { commitSha: item.commitSha } : {}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const trashByName = /* @__PURE__ */ new Map();
|
|
247
|
+
if (Array.isArray(migrated.trash)) for (const entry of migrated.trash) {
|
|
248
|
+
const item = entry;
|
|
249
|
+
if (item !== null && typeof item === "object" && typeof item.name === "string" && typeof item.path === "string") {
|
|
250
|
+
const origin = item.origin;
|
|
251
|
+
const tagIds = item.tagIds;
|
|
252
|
+
trashByName.set(item.name, {
|
|
253
|
+
name: item.name,
|
|
254
|
+
path: item.path,
|
|
255
|
+
movedAt: typeof item.movedAt === "number" ? item.movedAt : 0,
|
|
256
|
+
...typeof item.sourcePath === "string" && item.sourcePath !== "" ? { sourcePath: item.sourcePath } : {},
|
|
257
|
+
...origin !== null && typeof origin === "object" && typeof origin.repo === "string" && origin.repo !== "" && typeof origin.root === "string" && origin.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(origin.root) ? { origin: {
|
|
258
|
+
repo: origin.repo,
|
|
259
|
+
root: origin.root,
|
|
260
|
+
...typeof origin.ref === "string" && origin.ref !== "" ? { ref: origin.ref } : {},
|
|
261
|
+
commitSha: typeof origin.commitSha === "string" ? origin.commitSha : ""
|
|
262
|
+
} } : {},
|
|
263
|
+
...Array.isArray(tagIds) ? { tagIds: tagIds.filter((id) => typeof id === "string") } : {}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
let skillStats = void 0;
|
|
268
|
+
const savedStats = migrated.skillStats;
|
|
269
|
+
if (savedStats !== null && typeof savedStats === "object" && typeof savedStats.frozenBefore === "number" && typeof savedStats.lastFullReconcile === "number" && typeof savedStats.windowDays === "number" && typeof savedStats.frozenSessions === "object" && savedStats.frozenSessions !== null) {
|
|
270
|
+
const sessions = {};
|
|
271
|
+
for (const [id, entry] of Object.entries(savedStats.frozenSessions)) {
|
|
272
|
+
if (entry === null || typeof entry !== "object" || typeof entry.createdAt !== "number" || typeof entry.counts !== "object" || entry.counts === null) continue;
|
|
273
|
+
const counts = {};
|
|
274
|
+
for (const [name, stat] of Object.entries(entry.counts)) if (stat !== null && typeof stat === "object" && typeof stat.count === "number" && typeof stat.lastUsed === "number") counts[name] = {
|
|
275
|
+
count: stat.count,
|
|
276
|
+
lastUsed: stat.lastUsed
|
|
277
|
+
};
|
|
278
|
+
sessions[id] = {
|
|
279
|
+
createdAt: entry.createdAt,
|
|
280
|
+
counts
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const rawTotals = savedStats.lastTotals;
|
|
284
|
+
const lastTotals = Array.isArray(rawTotals) ? rawTotals.filter((entry) => entry !== null && typeof entry === "object" && typeof entry.name === "string" && typeof entry.count === "number").map((entry) => ({
|
|
285
|
+
name: entry.name,
|
|
286
|
+
count: entry.count,
|
|
287
|
+
...typeof entry.lastUsed === "number" ? { lastUsed: entry.lastUsed } : {}
|
|
288
|
+
})) : void 0;
|
|
289
|
+
skillStats = {
|
|
290
|
+
windowDays: savedStats.windowDays,
|
|
291
|
+
frozenBefore: savedStats.frozenBefore,
|
|
292
|
+
frozenSessions: sessions,
|
|
293
|
+
lastFullReconcile: savedStats.lastFullReconcile,
|
|
294
|
+
...lastTotals !== void 0 ? { lastTotals } : {}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
let marketStats = void 0;
|
|
298
|
+
const savedMarketStats = migrated.marketStats;
|
|
299
|
+
if (savedMarketStats !== null && typeof savedMarketStats === "object" && typeof savedMarketStats.fetchedAt === "number" && typeof savedMarketStats.stats === "object" && savedMarketStats.stats !== null) {
|
|
300
|
+
const stats = {};
|
|
301
|
+
for (const [repo, entry] of Object.entries(savedMarketStats.stats)) if (entry !== null && typeof entry === "object" && typeof entry.stars === "number" && typeof entry.downloads === "number") stats[repo] = {
|
|
302
|
+
stars: entry.stars,
|
|
303
|
+
downloads: entry.downloads
|
|
304
|
+
};
|
|
305
|
+
marketStats = {
|
|
306
|
+
fetchedAt: savedMarketStats.fetchedAt,
|
|
307
|
+
stats
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const collectionOrder = cleanStringList(migrated.collectionOrder);
|
|
311
|
+
const sourceGroupOrder = cleanStringList(migrated.sourceGroupOrder);
|
|
312
|
+
return {
|
|
313
|
+
entries,
|
|
314
|
+
config,
|
|
315
|
+
tagsById,
|
|
316
|
+
sourcesByRepo,
|
|
317
|
+
marketSources,
|
|
318
|
+
trashByName,
|
|
319
|
+
skillStats,
|
|
320
|
+
marketStats,
|
|
321
|
+
collectionOrder,
|
|
322
|
+
sourceGroupOrder
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
//#endregion
|
|
326
|
+
//#region src/store/store.ts
|
|
191
327
|
/** Sidecar state owner. */
|
|
192
328
|
var SkillHubStore = class {
|
|
193
329
|
file;
|
|
@@ -198,6 +334,7 @@ var SkillHubStore = class {
|
|
|
198
334
|
marketSources = [];
|
|
199
335
|
trashByName = /* @__PURE__ */ new Map();
|
|
200
336
|
skillStats = void 0;
|
|
337
|
+
marketStats = void 0;
|
|
201
338
|
collectionOrder = [];
|
|
202
339
|
sourceGroupOrder = [];
|
|
203
340
|
loaded = false;
|
|
@@ -215,97 +352,17 @@ var SkillHubStore = class {
|
|
|
215
352
|
const migrated = migrateStore(JSON.parse(raw));
|
|
216
353
|
if (migrated === null) console.warn("[dsh-skill-hub] sidecar state uses a newer schema than this plugin supports; starting empty");
|
|
217
354
|
else {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (typeof saved.showGroupSummary === "boolean") this.config.showGroupSummary = saved.showGroupSummary;
|
|
230
|
-
}
|
|
231
|
-
if (Array.isArray(migrated.tags)) for (const entry of migrated.tags) {
|
|
232
|
-
const tag = entry;
|
|
233
|
-
if (tag !== null && typeof tag === "object" && typeof tag.id === "string" && typeof tag.name === "string" && Array.isArray(tag.skillNames)) this.tagsById.set(tag.id, {
|
|
234
|
-
id: tag.id,
|
|
235
|
-
name: tag.name,
|
|
236
|
-
skillNames: tag.skillNames.filter((n) => typeof n === "string"),
|
|
237
|
-
...tag.default === true ? { default: true } : {}
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
if (Array.isArray(migrated.sources)) for (const entry of migrated.sources) {
|
|
241
|
-
const source = entry;
|
|
242
|
-
if (source !== null && typeof source === "object" && typeof source.repo === "string" && source.repo !== "" && Array.isArray(source.skills)) {
|
|
243
|
-
const manifest = source.manifest;
|
|
244
|
-
const rawRoot = typeof source.root === "string" && source.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(source.root) ? source.root : "skills";
|
|
245
|
-
this.sourcesByRepo.set(source.repo, {
|
|
246
|
-
repo: source.repo,
|
|
247
|
-
...typeof source.ref === "string" && source.ref !== "" ? { ref: source.ref } : {},
|
|
248
|
-
root: rawRoot,
|
|
249
|
-
commitSha: typeof source.commitSha === "string" ? source.commitSha : "",
|
|
250
|
-
skills: source.skills.filter((n) => typeof n === "string"),
|
|
251
|
-
...manifest !== null && typeof manifest === "object" && !Array.isArray(manifest) ? { manifest: Object.fromEntries(Object.entries(manifest).filter(([, size]) => typeof size === "number")) } : {}
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
if (Array.isArray(migrated.marketSources)) for (const entry of migrated.marketSources) {
|
|
256
|
-
const item = entry;
|
|
257
|
-
if (item !== null && typeof item === "object" && typeof item.repo === "string" && item.repo !== "" && !this.marketSources.some((s) => s.repo === item.repo)) this.marketSources.push({
|
|
258
|
-
repo: item.repo,
|
|
259
|
-
...typeof item.ref === "string" && item.ref !== "" ? { ref: item.ref } : {},
|
|
260
|
-
...typeof item.commitSha === "string" && item.commitSha !== "" ? { commitSha: item.commitSha } : {}
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
if (Array.isArray(migrated.trash)) for (const entry of migrated.trash) {
|
|
264
|
-
const item = entry;
|
|
265
|
-
if (item !== null && typeof item === "object" && typeof item.name === "string" && typeof item.path === "string") {
|
|
266
|
-
const origin = item.origin;
|
|
267
|
-
const tagIds = item.tagIds;
|
|
268
|
-
this.trashByName.set(item.name, {
|
|
269
|
-
name: item.name,
|
|
270
|
-
path: item.path,
|
|
271
|
-
movedAt: typeof item.movedAt === "number" ? item.movedAt : 0,
|
|
272
|
-
...typeof item.sourcePath === "string" && item.sourcePath !== "" ? { sourcePath: item.sourcePath } : {},
|
|
273
|
-
...origin !== null && typeof origin === "object" && typeof origin.repo === "string" && origin.repo !== "" && typeof origin.root === "string" && origin.root !== "" && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(origin.root) ? { origin: {
|
|
274
|
-
repo: origin.repo,
|
|
275
|
-
root: origin.root,
|
|
276
|
-
...typeof origin.ref === "string" && origin.ref !== "" ? { ref: origin.ref } : {},
|
|
277
|
-
commitSha: typeof origin.commitSha === "string" ? origin.commitSha : ""
|
|
278
|
-
} } : {},
|
|
279
|
-
...Array.isArray(tagIds) ? { tagIds: tagIds.filter((id) => typeof id === "string") } : {}
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
const savedStats = migrated.skillStats;
|
|
284
|
-
if (savedStats !== null && typeof savedStats === "object" && typeof savedStats.frozenBefore === "number" && typeof savedStats.lastFullReconcile === "number" && typeof savedStats.windowDays === "number" && typeof savedStats.frozenSessions === "object" && savedStats.frozenSessions !== null) {
|
|
285
|
-
const sessions = {};
|
|
286
|
-
for (const [id, entry] of Object.entries(savedStats.frozenSessions)) {
|
|
287
|
-
if (entry === null || typeof entry !== "object" || typeof entry.createdAt !== "number" || typeof entry.counts !== "object" || entry.counts === null) continue;
|
|
288
|
-
const counts = {};
|
|
289
|
-
for (const [name, stat] of Object.entries(entry.counts)) if (stat !== null && typeof stat === "object" && typeof stat.count === "number" && typeof stat.lastUsed === "number") counts[name] = {
|
|
290
|
-
count: stat.count,
|
|
291
|
-
lastUsed: stat.lastUsed
|
|
292
|
-
};
|
|
293
|
-
sessions[id] = {
|
|
294
|
-
createdAt: entry.createdAt,
|
|
295
|
-
counts
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
this.skillStats = {
|
|
299
|
-
windowDays: savedStats.windowDays,
|
|
300
|
-
frozenBefore: savedStats.frozenBefore,
|
|
301
|
-
frozenSessions: sessions,
|
|
302
|
-
lastFullReconcile: savedStats.lastFullReconcile
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
const rawColOrder = migrated.collectionOrder;
|
|
306
|
-
if (Array.isArray(rawColOrder)) this.collectionOrder = rawColOrder.filter((n) => typeof n === "string" && n !== "");
|
|
307
|
-
const rawSrcOrder = migrated.sourceGroupOrder;
|
|
308
|
-
if (Array.isArray(rawSrcOrder)) this.sourceGroupOrder = rawSrcOrder.filter((n) => typeof n === "string" && n !== "");
|
|
355
|
+
const state = hydrateMigratedState(migrated);
|
|
356
|
+
this.entries = state.entries;
|
|
357
|
+
this.config = state.config;
|
|
358
|
+
this.tagsById = state.tagsById;
|
|
359
|
+
this.sourcesByRepo = state.sourcesByRepo;
|
|
360
|
+
this.marketSources = state.marketSources;
|
|
361
|
+
this.trashByName = state.trashByName;
|
|
362
|
+
this.skillStats = state.skillStats;
|
|
363
|
+
this.marketStats = state.marketStats;
|
|
364
|
+
this.collectionOrder = state.collectionOrder;
|
|
365
|
+
this.sourceGroupOrder = state.sourceGroupOrder;
|
|
309
366
|
}
|
|
310
367
|
} catch (error) {
|
|
311
368
|
if (error.code !== "ENOENT") console.warn("[dsh-skill-hub] sidecar state unreadable, starting empty:", error instanceof Error ? error.message : error);
|
|
@@ -699,17 +756,36 @@ var SkillHubStore = class {
|
|
|
699
756
|
await this.ensureLoaded();
|
|
700
757
|
return this.skillStats !== void 0 ? {
|
|
701
758
|
...this.skillStats,
|
|
702
|
-
frozenSessions: { ...this.skillStats.frozenSessions }
|
|
759
|
+
frozenSessions: { ...this.skillStats.frozenSessions },
|
|
760
|
+
...this.skillStats.lastTotals !== void 0 ? { lastTotals: [...this.skillStats.lastTotals] } : {}
|
|
703
761
|
} : void 0;
|
|
704
762
|
}
|
|
705
|
-
/** Persist a usage-statistics checkpoint (
|
|
763
|
+
/** Persist a usage-statistics checkpoint (after every completed scan; cadence follows the scan TTL). */
|
|
706
764
|
async saveSkillStatsState(state) {
|
|
707
765
|
await this.ensureLoaded();
|
|
708
766
|
this.skillStats = {
|
|
709
767
|
windowDays: state.windowDays,
|
|
710
768
|
frozenBefore: state.frozenBefore,
|
|
711
769
|
frozenSessions: { ...state.frozenSessions },
|
|
712
|
-
lastFullReconcile: state.lastFullReconcile
|
|
770
|
+
lastFullReconcile: state.lastFullReconcile,
|
|
771
|
+
...state.lastTotals !== void 0 ? { lastTotals: [...state.lastTotals] } : {}
|
|
772
|
+
};
|
|
773
|
+
await this.persist();
|
|
774
|
+
}
|
|
775
|
+
/** The persisted market-stats snapshot (undefined until first saved). */
|
|
776
|
+
async getMarketStatsState() {
|
|
777
|
+
await this.ensureLoaded();
|
|
778
|
+
return this.marketStats !== void 0 ? {
|
|
779
|
+
fetchedAt: this.marketStats.fetchedAt,
|
|
780
|
+
stats: { ...this.marketStats.stats }
|
|
781
|
+
} : void 0;
|
|
782
|
+
}
|
|
783
|
+
/** Persist a market-stats snapshot (written when a refresh fetched anything new). */
|
|
784
|
+
async saveMarketStatsState(state) {
|
|
785
|
+
await this.ensureLoaded();
|
|
786
|
+
this.marketStats = {
|
|
787
|
+
fetchedAt: state.fetchedAt,
|
|
788
|
+
stats: { ...state.stats }
|
|
713
789
|
};
|
|
714
790
|
await this.persist();
|
|
715
791
|
}
|
|
@@ -724,6 +800,7 @@ var SkillHubStore = class {
|
|
|
724
800
|
...this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {},
|
|
725
801
|
...this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {},
|
|
726
802
|
...this.skillStats !== void 0 ? { skillStats: this.skillStats } : {},
|
|
803
|
+
...this.marketStats !== void 0 ? { marketStats: this.marketStats } : {},
|
|
727
804
|
...this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {},
|
|
728
805
|
...this.sourceGroupOrder.length > 0 ? { sourceGroupOrder: [...this.sourceGroupOrder] } : {}
|
|
729
806
|
};
|
|
@@ -1349,17 +1426,12 @@ var SkillHubProvider = class {
|
|
|
1349
1426
|
}
|
|
1350
1427
|
};
|
|
1351
1428
|
//#endregion
|
|
1352
|
-
//#region src/repo.ts
|
|
1429
|
+
//#region src/repo/github-client.ts
|
|
1353
1430
|
/**
|
|
1354
|
-
* GitHub
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
* test. Roots are auto-derived: any top-level directory that contains a
|
|
1358
|
-
* `**\/SKILL.md` (e.g. `skills/**`, `design-templates/**`, `templates/**`,
|
|
1359
|
-
* `workflows/**`) is treated as a skill root. No hard-coded allowlist.
|
|
1431
|
+
* GitHub API 统一请求层:token 管理、鉴权头、ETag 缓存、错误映射、
|
|
1432
|
+
* 带缓存的 JSON GET。从 repo.ts 抽出,原 6 处重复的 try/catch +
|
|
1433
|
+
* headers + json 解析收敛到 fetchJson / fetchJsonCached 两个入口。
|
|
1360
1434
|
*/
|
|
1361
|
-
/** Top-level directory pattern for a skill root: visible, non-dot, safe chars. First char must be alphanum. */
|
|
1362
|
-
const ROOT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
1363
1435
|
/** Fetch failure carrying a useful HTTP status for the route layer. */
|
|
1364
1436
|
var RepoFetchError = class extends Error {
|
|
1365
1437
|
status;
|
|
@@ -1372,13 +1444,25 @@ var RepoFetchError = class extends Error {
|
|
|
1372
1444
|
/**
|
|
1373
1445
|
* GitHub token for authenticated API calls. Read from GITHUB_TOKEN /
|
|
1374
1446
|
* GH_TOKEN at module load; setGithubToken() overrides it at runtime (the
|
|
1375
|
-
*
|
|
1447
|
+
* host calls it from the settings sync whenever the card value changes;
|
|
1448
|
+
* an absent value falls back to the env var).
|
|
1376
1449
|
*/
|
|
1377
1450
|
let githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
|
|
1451
|
+
/** Override the GitHub token at runtime ('' falls back to the env var). */
|
|
1452
|
+
function setGithubToken(token) {
|
|
1453
|
+
githubToken = token !== void 0 && token !== "" ? token : process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
|
|
1454
|
+
}
|
|
1378
1455
|
/** Authorization header for api.github.com / raw.githubusercontent.com calls. */
|
|
1379
1456
|
function githubAuthHeaders() {
|
|
1380
1457
|
return githubToken === "" ? {} : { authorization: "Bearer " + githubToken };
|
|
1381
1458
|
}
|
|
1459
|
+
/** JSON API 默认请求头(含鉴权)。 */
|
|
1460
|
+
function apiHeaders() {
|
|
1461
|
+
return {
|
|
1462
|
+
accept: "application/vnd.github+json",
|
|
1463
|
+
...githubAuthHeaders()
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1382
1466
|
/** ETag cache for GitHub API JSON (mirrors codex lib.rs marker+fingerprint). In-memory only, saves 304 for daily checks. */
|
|
1383
1467
|
const etagCache = /* @__PURE__ */ new Map();
|
|
1384
1468
|
const ETAG_MAX = 200;
|
|
@@ -1407,41 +1491,31 @@ function fetchError(context, response, fallbackStatus = 502) {
|
|
|
1407
1491
|
}
|
|
1408
1492
|
return new RepoFetchError(context + " (HTTP " + response.status + ")", response.status === 404 ? 404 : fallbackStatus);
|
|
1409
1493
|
}
|
|
1410
|
-
/**
|
|
1411
|
-
function
|
|
1412
|
-
return
|
|
1494
|
+
/** 是否限流/中断类错误(调用方可直接透出,不再包装)。 */
|
|
1495
|
+
function isAbortError(error) {
|
|
1496
|
+
return error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted") || error.message.includes("Abort"));
|
|
1413
1497
|
}
|
|
1414
1498
|
/**
|
|
1415
|
-
*
|
|
1416
|
-
*
|
|
1499
|
+
* 不带缓存的统一 GET+JSON:网络异常、非 2xx、坏 JSON 全部映射为
|
|
1500
|
+
* RepoFetchError,调用方无需重复写 try/catch。
|
|
1417
1501
|
*/
|
|
1418
|
-
function
|
|
1419
|
-
let
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1502
|
+
async function fetchJson(url, fetchImpl, context, headers = apiHeaders()) {
|
|
1503
|
+
let response;
|
|
1504
|
+
try {
|
|
1505
|
+
response = await fetchImpl(url, { headers });
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
if (isAbortError(error)) throw error;
|
|
1508
|
+
throw new RepoFetchError(context + ": " + (error instanceof Error ? error.message : String(error)));
|
|
1509
|
+
}
|
|
1510
|
+
if (!response.ok) throw fetchError(context, response);
|
|
1511
|
+
try {
|
|
1512
|
+
return {
|
|
1513
|
+
json: await response.json(),
|
|
1514
|
+
response
|
|
1515
|
+
};
|
|
1516
|
+
} catch {
|
|
1517
|
+
throw new RepoFetchError("invalid github response for " + url);
|
|
1433
1518
|
}
|
|
1434
|
-
value = value.replace(/\.git$/, "").replace(/\/+$/, "");
|
|
1435
|
-
const parts = value.split("/").filter(Boolean);
|
|
1436
|
-
if (parts.length < 2) return null;
|
|
1437
|
-
const owner = parts[0];
|
|
1438
|
-
const repo = parts[1];
|
|
1439
|
-
if (owner === "" || repo === "" || owner.includes("..") || repo.includes("..")) return null;
|
|
1440
|
-
return {
|
|
1441
|
-
owner,
|
|
1442
|
-
repo,
|
|
1443
|
-
...ref !== void 0 && ref !== "" ? { ref } : {}
|
|
1444
|
-
};
|
|
1445
1519
|
}
|
|
1446
1520
|
/**
|
|
1447
1521
|
* Load repo metadata and the recursive git tree at the given ref. The meta
|
|
@@ -1451,10 +1525,7 @@ function normalizeRepoInput(input) {
|
|
|
1451
1525
|
*/
|
|
1452
1526
|
async function fetchJsonCached(url, fetchImpl, context) {
|
|
1453
1527
|
const cached = etagCacheGet(url);
|
|
1454
|
-
const headers = {
|
|
1455
|
-
accept: "application/vnd.github+json",
|
|
1456
|
-
...githubAuthHeaders()
|
|
1457
|
-
};
|
|
1528
|
+
const headers = { ...apiHeaders() };
|
|
1458
1529
|
if (cached !== void 0) headers["if-none-match"] = cached.etag;
|
|
1459
1530
|
let response;
|
|
1460
1531
|
try {
|
|
@@ -1480,6 +1551,54 @@ async function fetchJsonCached(url, fetchImpl, context) {
|
|
|
1480
1551
|
response
|
|
1481
1552
|
};
|
|
1482
1553
|
}
|
|
1554
|
+
//#endregion
|
|
1555
|
+
//#region src/repo.ts
|
|
1556
|
+
/**
|
|
1557
|
+
* GitHub repository skill discovery/import helpers.
|
|
1558
|
+
*
|
|
1559
|
+
* Kept dependency-free and mostly pure so the root/origin rules are easy to
|
|
1560
|
+
* test. Roots are auto-derived: any top-level directory that contains a
|
|
1561
|
+
* `**\/SKILL.md` (e.g. `skills/**`, `design-templates/**`, `templates/**`,
|
|
1562
|
+
* `workflows/**`) is treated as a skill root. No hard-coded allowlist.
|
|
1563
|
+
*/
|
|
1564
|
+
/** Top-level directory pattern for a skill root: visible, non-dot, safe chars. First char must be alphanum. */
|
|
1565
|
+
const ROOT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
1566
|
+
/** `owner/repo` slug for a parsed reference. */
|
|
1567
|
+
function repoSlug(ref) {
|
|
1568
|
+
return `${ref.owner}/${ref.repo}`;
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Normalize a GitHub URL or `owner/repo`/`owner/repo@ref` input.
|
|
1572
|
+
* Only github.com URLs are accepted in v1.
|
|
1573
|
+
*/
|
|
1574
|
+
function normalizeRepoInput(input) {
|
|
1575
|
+
let value = input.trim();
|
|
1576
|
+
if (value === "") return null;
|
|
1577
|
+
let ref;
|
|
1578
|
+
const urlMatch = /^https?:\/\/github\.com\/([^/]+)\/([^/#?@]+)(?:\/tree\/([^/?#]+))?/.exec(value);
|
|
1579
|
+
if (urlMatch !== null) {
|
|
1580
|
+
value = `${urlMatch[1]}/${urlMatch[2]}`;
|
|
1581
|
+
ref = urlMatch[3];
|
|
1582
|
+
} else {
|
|
1583
|
+
if (/^https?:\/\//.test(value)) return null;
|
|
1584
|
+
const at = value.indexOf("@");
|
|
1585
|
+
if (at !== -1) {
|
|
1586
|
+
ref = value.slice(at + 1).trim();
|
|
1587
|
+
value = value.slice(0, at).trim();
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
value = value.replace(/\.git$/, "").replace(/\/+$/, "");
|
|
1591
|
+
const parts = value.split("/").filter(Boolean);
|
|
1592
|
+
if (parts.length < 2) return null;
|
|
1593
|
+
const owner = parts[0];
|
|
1594
|
+
const repo = parts[1];
|
|
1595
|
+
if (owner === "" || repo === "" || owner.includes("..") || repo.includes("..")) return null;
|
|
1596
|
+
return {
|
|
1597
|
+
owner,
|
|
1598
|
+
repo,
|
|
1599
|
+
...ref !== void 0 && ref !== "" ? { ref } : {}
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1483
1602
|
async function loadRepoTree(repo, ref, fetchImpl = fetch) {
|
|
1484
1603
|
const { json: metaJson } = await fetchJsonCached(`https://api.github.com/repos/${repo}`, fetchImpl, "github repo not found or unavailable");
|
|
1485
1604
|
let meta = metaJson;
|
|
@@ -1545,7 +1664,7 @@ function discoverRepoEntries(tree, repo, existingNames = /* @__PURE__ */ new Set
|
|
|
1545
1664
|
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
1546
1665
|
}
|
|
1547
1666
|
/** Run an async worker over items with a bounded concurrency. */
|
|
1548
|
-
async function mapConcurrent(items, limit, worker) {
|
|
1667
|
+
async function mapConcurrent$1(items, limit, worker) {
|
|
1549
1668
|
const results = new Array(items.length);
|
|
1550
1669
|
let cursor = 0;
|
|
1551
1670
|
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
@@ -1558,10 +1677,6 @@ async function mapConcurrent(items, limit, worker) {
|
|
|
1558
1677
|
await Promise.all(runners);
|
|
1559
1678
|
return results;
|
|
1560
1679
|
}
|
|
1561
|
-
/** Whether an error is an abort (AbortError / signal). */
|
|
1562
|
-
function isAbortError(error) {
|
|
1563
|
-
return error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted") || error.message.includes("Abort"));
|
|
1564
|
-
}
|
|
1565
1680
|
/**
|
|
1566
1681
|
* Download one GitHub file as a buffer. Tries raw.githubusercontent.com
|
|
1567
1682
|
* first (no API quota); on any failure falls back to the api.github.com
|
|
@@ -1619,7 +1734,7 @@ async function downloadRepoSkill(repo, ref, entry, files, targetRoot, fetchImpl
|
|
|
1619
1734
|
const tempDir = await mkdtemp(join(targetRoot, "." + entry.name + ".import-"));
|
|
1620
1735
|
let renamed = false;
|
|
1621
1736
|
try {
|
|
1622
|
-
await mapConcurrent(files, 6, async (file) => {
|
|
1737
|
+
await mapConcurrent$1(files, 6, async (file) => {
|
|
1623
1738
|
if (signal?.aborted) throw new DOMException("aborted", "AbortError");
|
|
1624
1739
|
const relative = file.path.slice(entry.dir.length + 1);
|
|
1625
1740
|
if (relative === "" || relative.includes("..")) throw new RepoFetchError("unsafe repo path: " + file.path);
|
|
@@ -1700,65 +1815,48 @@ async function cleanupLeftoverImportDirs(targetRoot) {
|
|
|
1700
1815
|
* repo has no releases (GitHub's /releases/latest skips prereleases/drafts).
|
|
1701
1816
|
*/
|
|
1702
1817
|
async function getLatestReleaseTag(repo, fetchImpl = fetch) {
|
|
1703
|
-
let response;
|
|
1704
|
-
try {
|
|
1705
|
-
response = await fetchImpl(`https://api.github.com/repos/${repo}/releases/latest`, { headers: {
|
|
1706
|
-
accept: "application/vnd.github+json",
|
|
1707
|
-
...githubAuthHeaders()
|
|
1708
|
-
} });
|
|
1709
|
-
} catch (error) {
|
|
1710
|
-
throw new RepoFetchError("github release request failed: " + (error instanceof Error ? error.message : String(error)));
|
|
1711
|
-
}
|
|
1712
|
-
if (response.status === 404) return void 0;
|
|
1713
|
-
if (!response.ok) throw fetchError("github release lookup failed", response);
|
|
1714
1818
|
let payload;
|
|
1715
1819
|
try {
|
|
1716
|
-
payload = await
|
|
1717
|
-
} catch {
|
|
1718
|
-
|
|
1820
|
+
({json: payload} = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`, fetchImpl, "github release lookup failed"));
|
|
1821
|
+
} catch (error) {
|
|
1822
|
+
if (error instanceof RepoFetchError && error.status === 404) return void 0;
|
|
1823
|
+
throw error;
|
|
1719
1824
|
}
|
|
1720
1825
|
const tag = typeof payload === "object" && payload !== null ? payload.tag_name : void 0;
|
|
1721
1826
|
return typeof tag === "string" && tag !== "" ? tag : void 0;
|
|
1722
1827
|
}
|
|
1828
|
+
/** Stars + release-asset downloads of a repo (two GitHub API requests). */
|
|
1829
|
+
async function getRepoStats(repo, fetchImpl = fetch) {
|
|
1830
|
+
const headers = apiHeaders();
|
|
1831
|
+
const { json: meta } = await fetchJson(`https://api.github.com/repos/${repo}`, fetchImpl, "github repo not found or unavailable", headers);
|
|
1832
|
+
const stars = typeof meta === "object" && meta !== null && typeof meta.stargazers_count === "number" ? meta.stargazers_count : 0;
|
|
1833
|
+
let downloads = 0;
|
|
1834
|
+
try {
|
|
1835
|
+
const relResponse = await fetchImpl(`https://api.github.com/repos/${repo}/releases?per_page=20`, { headers });
|
|
1836
|
+
if (relResponse.ok) {
|
|
1837
|
+
const releases = await relResponse.json();
|
|
1838
|
+
if (Array.isArray(releases)) for (const item of releases) {
|
|
1839
|
+
if (typeof item !== "object" || item === null) continue;
|
|
1840
|
+
const assets = item.assets;
|
|
1841
|
+
if (!Array.isArray(assets)) continue;
|
|
1842
|
+
for (const asset of assets) if (typeof asset === "object" && asset !== null && typeof asset.download_count === "number") downloads += asset.download_count;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
} catch {}
|
|
1846
|
+
return {
|
|
1847
|
+
stars,
|
|
1848
|
+
downloads
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1723
1851
|
/** Release tags of a repo, newest first (skips drafts, keeps prereleases). */
|
|
1724
1852
|
async function listRepoReleases(repo, fetchImpl = fetch) {
|
|
1725
|
-
|
|
1726
|
-
try {
|
|
1727
|
-
response = await fetchImpl(`https://api.github.com/repos/${repo}/releases?per_page=20`, { headers: {
|
|
1728
|
-
accept: "application/vnd.github+json",
|
|
1729
|
-
...githubAuthHeaders()
|
|
1730
|
-
} });
|
|
1731
|
-
} catch (error) {
|
|
1732
|
-
throw new RepoFetchError("github releases request failed: " + (error instanceof Error ? error.message : String(error)));
|
|
1733
|
-
}
|
|
1734
|
-
if (!response.ok) throw fetchError("github releases lookup failed", response);
|
|
1735
|
-
let payload;
|
|
1736
|
-
try {
|
|
1737
|
-
payload = await response.json();
|
|
1738
|
-
} catch {
|
|
1739
|
-
throw new RepoFetchError("invalid github releases response");
|
|
1740
|
-
}
|
|
1853
|
+
const { json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/releases?per_page=20`, fetchImpl, "github releases lookup failed");
|
|
1741
1854
|
if (!Array.isArray(payload)) return [];
|
|
1742
1855
|
return payload.filter((item) => typeof item === "object" && item !== null && item.draft !== true).map((item) => item.tag_name).filter((tag) => typeof tag === "string" && tag !== "");
|
|
1743
1856
|
}
|
|
1744
1857
|
/** Branch names of a repo, default branch first (one GitHub API request). */
|
|
1745
1858
|
async function listRepoBranches(repo, fetchImpl = fetch) {
|
|
1746
|
-
|
|
1747
|
-
try {
|
|
1748
|
-
response = await fetchImpl(`https://api.github.com/repos/${repo}/branches?per_page=100`, { headers: {
|
|
1749
|
-
accept: "application/vnd.github+json",
|
|
1750
|
-
...githubAuthHeaders()
|
|
1751
|
-
} });
|
|
1752
|
-
} catch (error) {
|
|
1753
|
-
throw new RepoFetchError("github branches request failed: " + (error instanceof Error ? error.message : String(error)));
|
|
1754
|
-
}
|
|
1755
|
-
if (!response.ok) throw fetchError("github branches lookup failed", response);
|
|
1756
|
-
let payload;
|
|
1757
|
-
try {
|
|
1758
|
-
payload = await response.json();
|
|
1759
|
-
} catch {
|
|
1760
|
-
throw new RepoFetchError("invalid github branches response");
|
|
1761
|
-
}
|
|
1859
|
+
const { json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/branches?per_page=100`, fetchImpl, "github branches lookup failed");
|
|
1762
1860
|
if (!Array.isArray(payload)) return [];
|
|
1763
1861
|
return payload.map((item) => typeof item === "object" && item !== null ? item.name : void 0).filter((name) => typeof name === "string" && name !== "");
|
|
1764
1862
|
}
|
|
@@ -1768,23 +1866,7 @@ async function listRepoBranches(repo, fetchImpl = fetch) {
|
|
|
1768
1866
|
* upstream tree on change (one extra request).
|
|
1769
1867
|
*/
|
|
1770
1868
|
async function getLatestCommit(repo, ref, fetchImpl = fetch) {
|
|
1771
|
-
const
|
|
1772
|
-
let response;
|
|
1773
|
-
try {
|
|
1774
|
-
response = await fetchImpl(url, { headers: {
|
|
1775
|
-
accept: "application/vnd.github+json",
|
|
1776
|
-
...githubAuthHeaders()
|
|
1777
|
-
} });
|
|
1778
|
-
} catch (error) {
|
|
1779
|
-
throw new RepoFetchError("github request failed: " + (error instanceof Error ? error.message : String(error)));
|
|
1780
|
-
}
|
|
1781
|
-
if (!response.ok) throw fetchError("github commit lookup failed", response);
|
|
1782
|
-
let payload;
|
|
1783
|
-
try {
|
|
1784
|
-
payload = await response.json();
|
|
1785
|
-
} catch {
|
|
1786
|
-
throw new RepoFetchError("invalid github commit response");
|
|
1787
|
-
}
|
|
1869
|
+
const { json: payload } = await fetchJson(ref !== void 0 && ref !== "" ? `https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}` : `https://api.github.com/repos/${repo}/commits?per_page=1`, fetchImpl, "github commit lookup failed");
|
|
1788
1870
|
const record = Array.isArray(payload) ? payload[0] ?? {} : typeof payload === "object" && payload !== null ? payload : {};
|
|
1789
1871
|
const commitSha = typeof record.sha === "string" && record.sha !== "" ? record.sha : null;
|
|
1790
1872
|
const tree = typeof record.commit === "object" && record.commit !== null ? record.commit.tree : void 0;
|
|
@@ -1797,22 +1879,7 @@ async function getLatestCommit(repo, ref, fetchImpl = fetch) {
|
|
|
1797
1879
|
}
|
|
1798
1880
|
/** Load the recursive git tree at an explicit tree SHA (one API request). */
|
|
1799
1881
|
async function loadRepoTreeAt(repo, treeSha, fetchImpl = fetch) {
|
|
1800
|
-
|
|
1801
|
-
try {
|
|
1802
|
-
response = await fetchImpl(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, { headers: {
|
|
1803
|
-
accept: "application/vnd.github+json",
|
|
1804
|
-
...githubAuthHeaders()
|
|
1805
|
-
} });
|
|
1806
|
-
} catch (error) {
|
|
1807
|
-
throw new RepoFetchError("github tree request failed: " + (error instanceof Error ? error.message : String(error)));
|
|
1808
|
-
}
|
|
1809
|
-
if (!response.ok) throw fetchError("github tree lookup failed", response);
|
|
1810
|
-
let payload;
|
|
1811
|
-
try {
|
|
1812
|
-
payload = await response.json();
|
|
1813
|
-
} catch {
|
|
1814
|
-
throw new RepoFetchError("invalid github tree response");
|
|
1815
|
-
}
|
|
1882
|
+
const { json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, fetchImpl, "github tree lookup failed");
|
|
1816
1883
|
const record = typeof payload === "object" && payload !== null ? payload : {};
|
|
1817
1884
|
if (record.truncated === true) throw new RepoFetchError("repo tree is too large to scan");
|
|
1818
1885
|
return Array.isArray(record.tree) ? record.tree : [];
|
|
@@ -2017,25 +2084,6 @@ async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl = fetch) {
|
|
|
2017
2084
|
url: typeof record.html_url === "string" ? record.html_url : null
|
|
2018
2085
|
};
|
|
2019
2086
|
}
|
|
2020
|
-
//#endregion
|
|
2021
|
-
//#region src/routes.ts
|
|
2022
|
-
/**
|
|
2023
|
-
* The /api/skill-hub route family: full catalog (enabled skills from the
|
|
2024
|
-
* official registry + hub-disabled skills + discovery diagnostics), skill
|
|
2025
|
-
* detail, enable/disable toggle, new-skill scaffold, user groups (tags +
|
|
2026
|
-
* origin collections), and upstream source tracking (check/sync/follow
|
|
2027
|
-
* upstream deletion into a restorable trash). Every route carries a
|
|
2028
|
-
* loopback-only trust fence — these endpoints rename files under the user's
|
|
2029
|
-
* skill roots, so LAN-exposed dsh web deployments must not serve them.
|
|
2030
|
-
*
|
|
2031
|
-
* Every handler is declared through the local `route()` wrapper, which owns
|
|
2032
|
-
* the shared request fences (loopback trust, HTTP method, master switch,
|
|
2033
|
-
* JSON body) exactly once, so a new route cannot forget them.
|
|
2034
|
-
*/
|
|
2035
|
-
/** Cap on JSON request bodies (toggle/create payloads are tiny). */
|
|
2036
|
-
const MAX_JSON_BODY_BYTES = 64 * 1024;
|
|
2037
|
-
/** Minimum interval between network update checks per source (GitHub rate limits). */
|
|
2038
|
-
const MIN_CHECK_INTERVAL_MS = 5 * 6e4;
|
|
2039
2087
|
/** Loopback literal check plus browser same-origin markers. */
|
|
2040
2088
|
function isLoopbackRequest(request) {
|
|
2041
2089
|
const address = request.socket.remoteAddress;
|
|
@@ -2096,7 +2144,7 @@ async function readJsonBody(req) {
|
|
|
2096
2144
|
for await (const chunk of req) {
|
|
2097
2145
|
const buffer = chunk;
|
|
2098
2146
|
size += buffer.length;
|
|
2099
|
-
if (size >
|
|
2147
|
+
if (size > 65536) return void 0;
|
|
2100
2148
|
chunks.push(buffer);
|
|
2101
2149
|
}
|
|
2102
2150
|
try {
|
|
@@ -2389,50 +2437,14 @@ function toDetail(skill) {
|
|
|
2389
2437
|
content: skill.content
|
|
2390
2438
|
};
|
|
2391
2439
|
}
|
|
2392
|
-
/** Last successful check time per repo (in-memory throttle for GitHub rate limits). */
|
|
2393
|
-
const lastSourceCheck = /* @__PURE__ */ new Map();
|
|
2394
|
-
/** Last successful market update check per repo (same throttle). */
|
|
2395
|
-
const lastMarketCheck = /* @__PURE__ */ new Map();
|
|
2396
|
-
const importJobs = /* @__PURE__ */ new Map();
|
|
2397
|
-
const IMPORT_JOB_TTL_MS = 5 * 6e4;
|
|
2398
|
-
const IMPORT_JOB_MAX = 100;
|
|
2399
|
-
function gcImportJobs() {
|
|
2400
|
-
if (importJobs.size > 500) importJobs.clear();
|
|
2401
|
-
const now = Date.now();
|
|
2402
|
-
for (const [id, job] of importJobs) if (job.status !== "running" && now - job.createdAt > IMPORT_JOB_TTL_MS) importJobs.delete(id);
|
|
2403
|
-
if (importJobs.size > IMPORT_JOB_MAX) {
|
|
2404
|
-
const sorted = [...importJobs.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
2405
|
-
for (let i = 0; i < sorted.length - IMPORT_JOB_MAX; i++) importJobs.delete(sorted[i][0]);
|
|
2406
|
-
}
|
|
2407
|
-
}
|
|
2408
|
-
/** Replace one skill directory with a fresh download; restores the old dir on failure. */
|
|
2409
|
-
async function replaceSkillDir(targetDir, download) {
|
|
2410
|
-
const backup = join(dirname(targetDir), "." + basename(targetDir) + ".sync-bak-" + Date.now());
|
|
2411
|
-
await rename(targetDir, backup);
|
|
2412
|
-
try {
|
|
2413
|
-
await download();
|
|
2414
|
-
} catch (error) {
|
|
2415
|
-
await rename(backup, targetDir).catch(() => {});
|
|
2416
|
-
throw error;
|
|
2417
|
-
}
|
|
2418
|
-
await rm(backup, {
|
|
2419
|
-
recursive: true,
|
|
2420
|
-
force: true
|
|
2421
|
-
});
|
|
2422
|
-
}
|
|
2423
2440
|
/**
|
|
2424
|
-
*
|
|
2425
|
-
*
|
|
2426
|
-
*
|
|
2441
|
+
* Wrap one handler in the shared request fences. Order matches the
|
|
2442
|
+
* original per-handler prologue: loopback trust → HTTP method → master
|
|
2443
|
+
* switch → JSON body. The outer catch maps errors onto the JSON error
|
|
2444
|
+
* body (preserving RepoFetchError status codes).
|
|
2427
2445
|
*/
|
|
2428
|
-
function
|
|
2429
|
-
|
|
2430
|
-
* Wrap one handler in the shared request fences. Order matches the
|
|
2431
|
-
* original per-handler prologue: loopback trust → HTTP method → master
|
|
2432
|
-
* switch → JSON body. The outer catch maps errors onto the JSON error
|
|
2433
|
-
* body (preserving RepoFetchError status codes).
|
|
2434
|
-
*/
|
|
2435
|
-
const route = (spec) => ({
|
|
2446
|
+
function createRoute(deps, spec) {
|
|
2447
|
+
return {
|
|
2436
2448
|
kind: "exact",
|
|
2437
2449
|
path: spec.path,
|
|
2438
2450
|
handler: async (req, res) => {
|
|
@@ -2466,16 +2478,25 @@ function makeRoutes(deps) {
|
|
|
2466
2478
|
writeRouteError(res, error);
|
|
2467
2479
|
}
|
|
2468
2480
|
}
|
|
2469
|
-
}
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
//#endregion
|
|
2484
|
+
//#region src/routes/catalog.ts
|
|
2485
|
+
/**
|
|
2486
|
+
* 目录域路由:catalog / skill 详情 / skill 删除 / toggle / toggle-batch /
|
|
2487
|
+
* create / stats。从 routes.ts 原样搬出,handler 逻辑不变。
|
|
2488
|
+
*/
|
|
2489
|
+
/** 目录域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
2490
|
+
function catalogRoutes(deps) {
|
|
2470
2491
|
return [
|
|
2471
|
-
|
|
2492
|
+
{
|
|
2472
2493
|
path: SKILL_HUB_API.catalog,
|
|
2473
2494
|
methods: ["GET"],
|
|
2474
2495
|
handler: async ({ res, url }) => {
|
|
2475
2496
|
writeJson(res, 200, await buildCatalog(deps, queryParam(url, "cwd")));
|
|
2476
2497
|
}
|
|
2477
|
-
}
|
|
2478
|
-
|
|
2498
|
+
},
|
|
2499
|
+
{
|
|
2479
2500
|
path: SKILL_HUB_API.skill,
|
|
2480
2501
|
methods: ["GET"],
|
|
2481
2502
|
handler: async ({ res, url }) => {
|
|
@@ -2551,8 +2572,8 @@ function makeRoutes(deps) {
|
|
|
2551
2572
|
skill: detail
|
|
2552
2573
|
});
|
|
2553
2574
|
}
|
|
2554
|
-
}
|
|
2555
|
-
|
|
2575
|
+
},
|
|
2576
|
+
{
|
|
2556
2577
|
path: SKILL_HUB_API.skillDelete,
|
|
2557
2578
|
methods: ["POST"],
|
|
2558
2579
|
jsonBody: true,
|
|
@@ -2608,8 +2629,8 @@ function makeRoutes(deps) {
|
|
|
2608
2629
|
path
|
|
2609
2630
|
});
|
|
2610
2631
|
}
|
|
2611
|
-
}
|
|
2612
|
-
|
|
2632
|
+
},
|
|
2633
|
+
{
|
|
2613
2634
|
path: SKILL_HUB_API.toggle,
|
|
2614
2635
|
methods: ["POST"],
|
|
2615
2636
|
jsonBody: true,
|
|
@@ -2658,8 +2679,8 @@ function makeRoutes(deps) {
|
|
|
2658
2679
|
catalog: await buildCatalog(deps, lookup?.cwd)
|
|
2659
2680
|
});
|
|
2660
2681
|
}
|
|
2661
|
-
}
|
|
2662
|
-
|
|
2682
|
+
},
|
|
2683
|
+
{
|
|
2663
2684
|
path: SKILL_HUB_API.toggleBatch,
|
|
2664
2685
|
methods: ["POST"],
|
|
2665
2686
|
jsonBody: true,
|
|
@@ -2710,8 +2731,8 @@ function makeRoutes(deps) {
|
|
|
2710
2731
|
failures
|
|
2711
2732
|
});
|
|
2712
2733
|
}
|
|
2713
|
-
}
|
|
2714
|
-
|
|
2734
|
+
},
|
|
2735
|
+
{
|
|
2715
2736
|
path: SKILL_HUB_API.create,
|
|
2716
2737
|
methods: ["POST"],
|
|
2717
2738
|
jsonBody: true,
|
|
@@ -2749,8 +2770,8 @@ function makeRoutes(deps) {
|
|
|
2749
2770
|
root
|
|
2750
2771
|
});
|
|
2751
2772
|
}
|
|
2752
|
-
}
|
|
2753
|
-
|
|
2773
|
+
},
|
|
2774
|
+
{
|
|
2754
2775
|
path: SKILL_HUB_API.stats,
|
|
2755
2776
|
methods: ["GET"],
|
|
2756
2777
|
handler: async ({ res }) => {
|
|
@@ -2768,8 +2789,19 @@ function makeRoutes(deps) {
|
|
|
2768
2789
|
stats: await deps.stats()
|
|
2769
2790
|
});
|
|
2770
2791
|
}
|
|
2771
|
-
}
|
|
2772
|
-
|
|
2792
|
+
}
|
|
2793
|
+
];
|
|
2794
|
+
}
|
|
2795
|
+
//#endregion
|
|
2796
|
+
//#region src/routes/config.ts
|
|
2797
|
+
/**
|
|
2798
|
+
* 配置域路由:config(总开关开着也可用,设置卡自救入口)/ update 自身更新
|
|
2799
|
+
* 检查 / diagnosticFix。从 routes.ts 原样搬出,handler 逻辑不变。
|
|
2800
|
+
*/
|
|
2801
|
+
/** 配置域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
2802
|
+
function configRoutes(deps) {
|
|
2803
|
+
return [
|
|
2804
|
+
{
|
|
2773
2805
|
path: SKILL_HUB_API.config,
|
|
2774
2806
|
methods: ["GET", "POST"],
|
|
2775
2807
|
jsonBody: true,
|
|
@@ -2826,6 +2858,15 @@ function makeRoutes(deps) {
|
|
|
2826
2858
|
}
|
|
2827
2859
|
patch[key] = value;
|
|
2828
2860
|
}
|
|
2861
|
+
if (raw.githubToken !== void 0) {
|
|
2862
|
+
const value = raw.githubToken;
|
|
2863
|
+
if (value === null) patch.githubToken = void 0;
|
|
2864
|
+
else if (typeof value !== "string" || value.trim() === "") patch.githubToken = void 0;
|
|
2865
|
+
else if (!GITHUB_TOKEN_RE.test(value.trim())) {
|
|
2866
|
+
writeError(res, 400, "githubToken looks invalid (expected a personal access token)");
|
|
2867
|
+
return;
|
|
2868
|
+
} else patch.githubToken = value.trim();
|
|
2869
|
+
}
|
|
2829
2870
|
let config;
|
|
2830
2871
|
if (deps.updateConfig === void 0) {
|
|
2831
2872
|
const merged = { ...configOf(deps) };
|
|
@@ -2840,8 +2881,96 @@ function makeRoutes(deps) {
|
|
|
2840
2881
|
saved: savedOf(deps)
|
|
2841
2882
|
});
|
|
2842
2883
|
}
|
|
2843
|
-
}
|
|
2844
|
-
|
|
2884
|
+
},
|
|
2885
|
+
{
|
|
2886
|
+
path: SKILL_HUB_API.update,
|
|
2887
|
+
methods: ["GET"],
|
|
2888
|
+
handler: async ({ res }) => {
|
|
2889
|
+
writeJson(res, 200, await checkLatestRelease());
|
|
2890
|
+
}
|
|
2891
|
+
},
|
|
2892
|
+
{
|
|
2893
|
+
path: SKILL_HUB_API.diagnosticFix,
|
|
2894
|
+
methods: ["POST"],
|
|
2895
|
+
jsonBody: true,
|
|
2896
|
+
handler: async ({ res, body }) => {
|
|
2897
|
+
const request = body;
|
|
2898
|
+
const rawPath = typeof request.path === "string" ? request.path.trim() : "";
|
|
2899
|
+
if (rawPath === "") {
|
|
2900
|
+
writeError(res, 400, "path is required");
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
try {
|
|
2904
|
+
await fixDiagnosticFile(rawPath, homeOf(deps));
|
|
2905
|
+
deps.invalidate?.();
|
|
2906
|
+
writeJson(res, 200, {
|
|
2907
|
+
ok: true,
|
|
2908
|
+
path: rawPath
|
|
2909
|
+
});
|
|
2910
|
+
} catch (error) {
|
|
2911
|
+
writeRouteError(res, error);
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
];
|
|
2916
|
+
}
|
|
2917
|
+
/** Last successful check time per repo (in-memory throttle for GitHub rate limits). */
|
|
2918
|
+
const lastSourceCheck = /* @__PURE__ */ new Map();
|
|
2919
|
+
/** Last successful market update check per repo (same throttle). */
|
|
2920
|
+
const lastMarketCheck = /* @__PURE__ */ new Map();
|
|
2921
|
+
/** Market stats (stars/downloads) throttle: hourly, plus a fallback cache. */
|
|
2922
|
+
const lastMarketStats = /* @__PURE__ */ new Map();
|
|
2923
|
+
const marketStatsCache = /* @__PURE__ */ new Map();
|
|
2924
|
+
/** One-time seed of the in-memory stats cache from the sidecar (survives restarts). */
|
|
2925
|
+
let marketStatsSeeded = false;
|
|
2926
|
+
async function seedMarketStats(deps) {
|
|
2927
|
+
if (marketStatsSeeded) return;
|
|
2928
|
+
marketStatsSeeded = true;
|
|
2929
|
+
try {
|
|
2930
|
+
const saved = await deps.store.getMarketStatsState();
|
|
2931
|
+
if (saved === void 0) return;
|
|
2932
|
+
for (const [repo, stats] of Object.entries(saved.stats)) if (!marketStatsCache.has(repo)) {
|
|
2933
|
+
marketStatsCache.set(repo, stats);
|
|
2934
|
+
lastMarketStats.set(repo, saved.fetchedAt);
|
|
2935
|
+
}
|
|
2936
|
+
} catch {}
|
|
2937
|
+
}
|
|
2938
|
+
const importJobs = /* @__PURE__ */ new Map();
|
|
2939
|
+
function gcImportJobs() {
|
|
2940
|
+
if (importJobs.size > 500) importJobs.clear();
|
|
2941
|
+
const now = Date.now();
|
|
2942
|
+
for (const [id, job] of importJobs) if (job.status !== "running" && now - job.createdAt > 3e5) importJobs.delete(id);
|
|
2943
|
+
if (importJobs.size > 100) {
|
|
2944
|
+
const sorted = [...importJobs.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
2945
|
+
for (let i = 0; i < sorted.length - 100; i++) importJobs.delete(sorted[i][0]);
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
/** Replace one skill directory with a fresh download; restores the old dir on failure. */
|
|
2949
|
+
async function replaceSkillDir(targetDir, download) {
|
|
2950
|
+
const backup = join(dirname(targetDir), "." + basename(targetDir) + ".sync-bak-" + Date.now());
|
|
2951
|
+
await rename(targetDir, backup);
|
|
2952
|
+
try {
|
|
2953
|
+
await download();
|
|
2954
|
+
} catch (error) {
|
|
2955
|
+
await rename(backup, targetDir).catch(() => {});
|
|
2956
|
+
throw error;
|
|
2957
|
+
}
|
|
2958
|
+
await rm(backup, {
|
|
2959
|
+
recursive: true,
|
|
2960
|
+
force: true
|
|
2961
|
+
});
|
|
2962
|
+
}
|
|
2963
|
+
//#endregion
|
|
2964
|
+
//#region src/routes/market.ts
|
|
2965
|
+
/**
|
|
2966
|
+
* 市场域路由:market 列表 / source 增删 / ref 锁定 / versions 选择器 /
|
|
2967
|
+
* check 更新检查 / stats 星标统计 / sync 对齐版本。从 routes.ts 原样搬出,
|
|
2968
|
+
* handler 逻辑不变。
|
|
2969
|
+
*/
|
|
2970
|
+
/** 市场域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
2971
|
+
function marketRoutes(deps) {
|
|
2972
|
+
return [
|
|
2973
|
+
{
|
|
2845
2974
|
path: SKILL_HUB_API.market,
|
|
2846
2975
|
methods: ["GET"],
|
|
2847
2976
|
handler: async ({ res }) => {
|
|
@@ -2850,8 +2979,8 @@ function makeRoutes(deps) {
|
|
|
2850
2979
|
repos: await deps.store.listMarketSources()
|
|
2851
2980
|
});
|
|
2852
2981
|
}
|
|
2853
|
-
}
|
|
2854
|
-
|
|
2982
|
+
},
|
|
2983
|
+
{
|
|
2855
2984
|
path: SKILL_HUB_API.marketSource,
|
|
2856
2985
|
methods: ["POST"],
|
|
2857
2986
|
jsonBody: true,
|
|
@@ -2868,8 +2997,8 @@ function makeRoutes(deps) {
|
|
|
2868
2997
|
repos: await deps.store.addMarketSource(repoSlug(parsed), ref)
|
|
2869
2998
|
});
|
|
2870
2999
|
}
|
|
2871
|
-
}
|
|
2872
|
-
|
|
3000
|
+
},
|
|
3001
|
+
{
|
|
2873
3002
|
path: SKILL_HUB_API.marketSourceDelete,
|
|
2874
3003
|
methods: ["POST"],
|
|
2875
3004
|
jsonBody: true,
|
|
@@ -2885,8 +3014,8 @@ function makeRoutes(deps) {
|
|
|
2885
3014
|
repos: await deps.store.removeMarketSource(repoSlug(parsed))
|
|
2886
3015
|
});
|
|
2887
3016
|
}
|
|
2888
|
-
}
|
|
2889
|
-
|
|
3017
|
+
},
|
|
3018
|
+
{
|
|
2890
3019
|
path: SKILL_HUB_API.marketSourceRef,
|
|
2891
3020
|
methods: ["POST"],
|
|
2892
3021
|
jsonBody: true,
|
|
@@ -2911,8 +3040,8 @@ function makeRoutes(deps) {
|
|
|
2911
3040
|
repos: await deps.store.listMarketSources()
|
|
2912
3041
|
});
|
|
2913
3042
|
}
|
|
2914
|
-
}
|
|
2915
|
-
|
|
3043
|
+
},
|
|
3044
|
+
{
|
|
2916
3045
|
path: SKILL_HUB_API.marketSourceVersions,
|
|
2917
3046
|
methods: ["GET"],
|
|
2918
3047
|
handler: async ({ res, url }) => {
|
|
@@ -2936,8 +3065,8 @@ function makeRoutes(deps) {
|
|
|
2936
3065
|
branches
|
|
2937
3066
|
});
|
|
2938
3067
|
}
|
|
2939
|
-
}
|
|
2940
|
-
|
|
3068
|
+
},
|
|
3069
|
+
{
|
|
2941
3070
|
path: SKILL_HUB_API.marketCheck,
|
|
2942
3071
|
methods: ["GET"],
|
|
2943
3072
|
handler: async ({ res }) => {
|
|
@@ -2950,7 +3079,7 @@ function makeRoutes(deps) {
|
|
|
2950
3079
|
...source.ref !== void 0 ? { ref: source.ref } : {}
|
|
2951
3080
|
};
|
|
2952
3081
|
const now = Date.now();
|
|
2953
|
-
if (now - (lastMarketCheck.get(source.repo) ?? 0) <
|
|
3082
|
+
if (now - (lastMarketCheck.get(source.repo) ?? 0) < 3e5) {
|
|
2954
3083
|
results.push({
|
|
2955
3084
|
...base,
|
|
2956
3085
|
updateAvailable: false,
|
|
@@ -2994,8 +3123,65 @@ function makeRoutes(deps) {
|
|
|
2994
3123
|
results
|
|
2995
3124
|
});
|
|
2996
3125
|
}
|
|
2997
|
-
}
|
|
2998
|
-
|
|
3126
|
+
},
|
|
3127
|
+
{
|
|
3128
|
+
path: SKILL_HUB_API.marketStats,
|
|
3129
|
+
methods: ["GET"],
|
|
3130
|
+
handler: async ({ res, url }) => {
|
|
3131
|
+
const refresh = queryParam(url, "refresh") === "1";
|
|
3132
|
+
await seedMarketStats(deps);
|
|
3133
|
+
const sources = await deps.store.listMarketSources();
|
|
3134
|
+
if (lastMarketStats.size > 500) lastMarketStats.clear();
|
|
3135
|
+
const results = [];
|
|
3136
|
+
const now = Date.now();
|
|
3137
|
+
let fetchedAny = false;
|
|
3138
|
+
for (const source of sources) {
|
|
3139
|
+
const cached = marketStatsCache.get(source.repo);
|
|
3140
|
+
const age = now - (lastMarketStats.get(source.repo) ?? 0);
|
|
3141
|
+
if (cached !== void 0 && (!refresh || age < 36e5)) {
|
|
3142
|
+
results.push({
|
|
3143
|
+
repo: source.repo,
|
|
3144
|
+
stars: cached.stars,
|
|
3145
|
+
downloads: cached.downloads,
|
|
3146
|
+
...age >= 36e5 ? { stale: true } : refresh ? { throttled: true } : {}
|
|
3147
|
+
});
|
|
3148
|
+
continue;
|
|
3149
|
+
}
|
|
3150
|
+
try {
|
|
3151
|
+
const stats = await getRepoStats(source.repo);
|
|
3152
|
+
marketStatsCache.set(source.repo, stats);
|
|
3153
|
+
lastMarketStats.set(source.repo, now);
|
|
3154
|
+
fetchedAny = true;
|
|
3155
|
+
results.push({
|
|
3156
|
+
repo: source.repo,
|
|
3157
|
+
...stats
|
|
3158
|
+
});
|
|
3159
|
+
} catch (error) {
|
|
3160
|
+
if (cached !== void 0) results.push({
|
|
3161
|
+
repo: source.repo,
|
|
3162
|
+
stars: cached.stars,
|
|
3163
|
+
downloads: cached.downloads,
|
|
3164
|
+
stale: true
|
|
3165
|
+
});
|
|
3166
|
+
else results.push({
|
|
3167
|
+
repo: source.repo,
|
|
3168
|
+
stars: 0,
|
|
3169
|
+
downloads: 0,
|
|
3170
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3171
|
+
});
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
if (fetchedAny) deps.store.saveMarketStatsState({
|
|
3175
|
+
fetchedAt: now,
|
|
3176
|
+
stats: Object.fromEntries(marketStatsCache)
|
|
3177
|
+
}).catch(() => {});
|
|
3178
|
+
writeJson(res, 200, {
|
|
3179
|
+
ok: true,
|
|
3180
|
+
results
|
|
3181
|
+
});
|
|
3182
|
+
}
|
|
3183
|
+
},
|
|
3184
|
+
{
|
|
2999
3185
|
path: SKILL_HUB_API.marketSync,
|
|
3000
3186
|
methods: ["POST"],
|
|
3001
3187
|
jsonBody: true,
|
|
@@ -3034,8 +3220,19 @@ function makeRoutes(deps) {
|
|
|
3034
3220
|
skills: tracked !== void 0 ? [...tracked.skills] : []
|
|
3035
3221
|
});
|
|
3036
3222
|
}
|
|
3037
|
-
}
|
|
3038
|
-
|
|
3223
|
+
}
|
|
3224
|
+
];
|
|
3225
|
+
}
|
|
3226
|
+
//#endregion
|
|
3227
|
+
//#region src/routes/repo-import.ts
|
|
3228
|
+
/**
|
|
3229
|
+
* 仓库导入域路由:repo 发现 / repoImport 异步导入任务 / progress 轮询 /
|
|
3230
|
+
* cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
|
|
3231
|
+
*/
|
|
3232
|
+
/** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
3233
|
+
function repoImportRoutes(deps) {
|
|
3234
|
+
return [
|
|
3235
|
+
{
|
|
3039
3236
|
path: SKILL_HUB_API.repo,
|
|
3040
3237
|
methods: ["GET"],
|
|
3041
3238
|
handler: async ({ res, url }) => {
|
|
@@ -3081,8 +3278,8 @@ function makeRoutes(deps) {
|
|
|
3081
3278
|
...truncated ? { truncated: true } : {}
|
|
3082
3279
|
});
|
|
3083
3280
|
}
|
|
3084
|
-
}
|
|
3085
|
-
|
|
3281
|
+
},
|
|
3282
|
+
{
|
|
3086
3283
|
path: SKILL_HUB_API.repoImport,
|
|
3087
3284
|
methods: ["POST"],
|
|
3088
3285
|
jsonBody: true,
|
|
@@ -3220,8 +3417,8 @@ function makeRoutes(deps) {
|
|
|
3220
3417
|
totalBytes
|
|
3221
3418
|
});
|
|
3222
3419
|
}
|
|
3223
|
-
}
|
|
3224
|
-
|
|
3420
|
+
},
|
|
3421
|
+
{
|
|
3225
3422
|
path: SKILL_HUB_API.repoImportProgress,
|
|
3226
3423
|
methods: ["GET"],
|
|
3227
3424
|
handler: async ({ res, url }) => {
|
|
@@ -3254,8 +3451,8 @@ function makeRoutes(deps) {
|
|
|
3254
3451
|
...job.error !== void 0 ? { error: job.error } : {}
|
|
3255
3452
|
});
|
|
3256
3453
|
}
|
|
3257
|
-
}
|
|
3258
|
-
|
|
3454
|
+
},
|
|
3455
|
+
{
|
|
3259
3456
|
path: SKILL_HUB_API.repoImportCancel,
|
|
3260
3457
|
methods: ["POST"],
|
|
3261
3458
|
jsonBody: true,
|
|
@@ -3281,22 +3478,26 @@ function makeRoutes(deps) {
|
|
|
3281
3478
|
status: job.status
|
|
3282
3479
|
});
|
|
3283
3480
|
}
|
|
3284
|
-
}
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3481
|
+
}
|
|
3482
|
+
];
|
|
3483
|
+
}
|
|
3484
|
+
//#endregion
|
|
3485
|
+
//#region src/routes/groups.ts
|
|
3486
|
+
/**
|
|
3487
|
+
* 分组域路由:groups / tag 新建重命名 / tag 删除 / tag 成员 / tag 重排 /
|
|
3488
|
+
* collection 重排 / source-group 重排。从 routes.ts 原样搬出,handler 逻辑不变。
|
|
3489
|
+
*/
|
|
3490
|
+
/** 分组域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
3491
|
+
function groupRoutes(deps) {
|
|
3492
|
+
return [
|
|
3493
|
+
{
|
|
3293
3494
|
path: SKILL_HUB_API.groups,
|
|
3294
3495
|
methods: ["GET"],
|
|
3295
3496
|
handler: async ({ res }) => {
|
|
3296
3497
|
writeJson(res, 200, await buildGroups(deps));
|
|
3297
3498
|
}
|
|
3298
|
-
}
|
|
3299
|
-
|
|
3499
|
+
},
|
|
3500
|
+
{
|
|
3300
3501
|
path: SKILL_HUB_API.tag,
|
|
3301
3502
|
methods: ["POST"],
|
|
3302
3503
|
jsonBody: true,
|
|
@@ -3316,8 +3517,8 @@ function makeRoutes(deps) {
|
|
|
3316
3517
|
tags: await deps.store.listTags()
|
|
3317
3518
|
});
|
|
3318
3519
|
}
|
|
3319
|
-
}
|
|
3320
|
-
|
|
3520
|
+
},
|
|
3521
|
+
{
|
|
3321
3522
|
path: SKILL_HUB_API.tagDelete,
|
|
3322
3523
|
methods: ["POST"],
|
|
3323
3524
|
jsonBody: true,
|
|
@@ -3337,8 +3538,8 @@ function makeRoutes(deps) {
|
|
|
3337
3538
|
tags: await deps.store.listTags()
|
|
3338
3539
|
});
|
|
3339
3540
|
}
|
|
3340
|
-
}
|
|
3341
|
-
|
|
3541
|
+
},
|
|
3542
|
+
{
|
|
3342
3543
|
path: SKILL_HUB_API.tagMembers,
|
|
3343
3544
|
methods: ["POST"],
|
|
3344
3545
|
jsonBody: true,
|
|
@@ -3360,8 +3561,8 @@ function makeRoutes(deps) {
|
|
|
3360
3561
|
tags: await deps.store.listTags()
|
|
3361
3562
|
});
|
|
3362
3563
|
}
|
|
3363
|
-
}
|
|
3364
|
-
|
|
3564
|
+
},
|
|
3565
|
+
{
|
|
3365
3566
|
path: SKILL_HUB_API.tagReorder,
|
|
3366
3567
|
methods: ["POST"],
|
|
3367
3568
|
jsonBody: true,
|
|
@@ -3381,8 +3582,8 @@ function makeRoutes(deps) {
|
|
|
3381
3582
|
throw error;
|
|
3382
3583
|
}
|
|
3383
3584
|
}
|
|
3384
|
-
}
|
|
3385
|
-
|
|
3585
|
+
},
|
|
3586
|
+
{
|
|
3386
3587
|
path: SKILL_HUB_API.collectionReorder,
|
|
3387
3588
|
methods: ["POST"],
|
|
3388
3589
|
jsonBody: true,
|
|
@@ -3396,8 +3597,8 @@ function makeRoutes(deps) {
|
|
|
3396
3597
|
order
|
|
3397
3598
|
});
|
|
3398
3599
|
}
|
|
3399
|
-
}
|
|
3400
|
-
|
|
3600
|
+
},
|
|
3601
|
+
{
|
|
3401
3602
|
path: SKILL_HUB_API.sourceGroupReorder,
|
|
3402
3603
|
methods: ["POST"],
|
|
3403
3604
|
jsonBody: true,
|
|
@@ -3409,8 +3610,20 @@ function makeRoutes(deps) {
|
|
|
3409
3610
|
order: await deps.store.reorderSourceGroups(orderedKeys)
|
|
3410
3611
|
});
|
|
3411
3612
|
}
|
|
3412
|
-
}
|
|
3413
|
-
|
|
3613
|
+
}
|
|
3614
|
+
];
|
|
3615
|
+
}
|
|
3616
|
+
//#endregion
|
|
3617
|
+
//#region src/routes/sources.ts
|
|
3618
|
+
/**
|
|
3619
|
+
* 来源跟踪域路由:sources 列表 / check 上游更新 / sync 同步 / delete 跟进
|
|
3620
|
+
* 删除 / restore 恢复 / trash/clear 清空回收站。从 routes.ts 原样搬出,
|
|
3621
|
+
* handler 逻辑不变。
|
|
3622
|
+
*/
|
|
3623
|
+
/** 来源跟踪域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
3624
|
+
function sourceRoutes(deps) {
|
|
3625
|
+
return [
|
|
3626
|
+
{
|
|
3414
3627
|
path: SKILL_HUB_API.sources,
|
|
3415
3628
|
methods: ["GET"],
|
|
3416
3629
|
handler: async ({ res }) => {
|
|
@@ -3443,8 +3656,8 @@ function makeRoutes(deps) {
|
|
|
3443
3656
|
trash
|
|
3444
3657
|
});
|
|
3445
3658
|
}
|
|
3446
|
-
}
|
|
3447
|
-
|
|
3659
|
+
},
|
|
3660
|
+
{
|
|
3448
3661
|
path: SKILL_HUB_API.sourceCheck,
|
|
3449
3662
|
methods: ["POST"],
|
|
3450
3663
|
jsonBody: true,
|
|
@@ -3469,7 +3682,7 @@ function makeRoutes(deps) {
|
|
|
3469
3682
|
...item.ref !== void 0 ? { ref: item.ref } : {}
|
|
3470
3683
|
};
|
|
3471
3684
|
const now = Date.now();
|
|
3472
|
-
if (now - (lastSourceCheck.get(item.repo) ?? 0) <
|
|
3685
|
+
if (now - (lastSourceCheck.get(item.repo) ?? 0) < 3e5) {
|
|
3473
3686
|
results.push({
|
|
3474
3687
|
...base,
|
|
3475
3688
|
changed: false,
|
|
@@ -3526,8 +3739,8 @@ function makeRoutes(deps) {
|
|
|
3526
3739
|
results
|
|
3527
3740
|
});
|
|
3528
3741
|
}
|
|
3529
|
-
}
|
|
3530
|
-
|
|
3742
|
+
},
|
|
3743
|
+
{
|
|
3531
3744
|
path: SKILL_HUB_API.sourceSync,
|
|
3532
3745
|
methods: ["POST"],
|
|
3533
3746
|
jsonBody: true,
|
|
@@ -3598,8 +3811,8 @@ function makeRoutes(deps) {
|
|
|
3598
3811
|
failed
|
|
3599
3812
|
});
|
|
3600
3813
|
}
|
|
3601
|
-
}
|
|
3602
|
-
|
|
3814
|
+
},
|
|
3815
|
+
{
|
|
3603
3816
|
path: SKILL_HUB_API.sourceDelete,
|
|
3604
3817
|
methods: ["POST"],
|
|
3605
3818
|
jsonBody: true,
|
|
@@ -3683,8 +3896,8 @@ function makeRoutes(deps) {
|
|
|
3683
3896
|
failed
|
|
3684
3897
|
});
|
|
3685
3898
|
}
|
|
3686
|
-
}
|
|
3687
|
-
|
|
3899
|
+
},
|
|
3900
|
+
{
|
|
3688
3901
|
path: SKILL_HUB_API.sourceRestore,
|
|
3689
3902
|
methods: ["POST"],
|
|
3690
3903
|
jsonBody: true,
|
|
@@ -3716,8 +3929,8 @@ function makeRoutes(deps) {
|
|
|
3716
3929
|
path
|
|
3717
3930
|
});
|
|
3718
3931
|
}
|
|
3719
|
-
}
|
|
3720
|
-
|
|
3932
|
+
},
|
|
3933
|
+
{
|
|
3721
3934
|
path: SKILL_HUB_API.sourceTrashClear,
|
|
3722
3935
|
methods: ["POST"],
|
|
3723
3936
|
handler: async ({ res }) => {
|
|
@@ -3743,33 +3956,27 @@ function makeRoutes(deps) {
|
|
|
3743
3956
|
failed
|
|
3744
3957
|
});
|
|
3745
3958
|
}
|
|
3746
|
-
}
|
|
3747
|
-
route({
|
|
3748
|
-
path: SKILL_HUB_API.diagnosticFix,
|
|
3749
|
-
methods: ["POST"],
|
|
3750
|
-
jsonBody: true,
|
|
3751
|
-
handler: async ({ res, body }) => {
|
|
3752
|
-
const request = body;
|
|
3753
|
-
const rawPath = typeof request.path === "string" ? request.path.trim() : "";
|
|
3754
|
-
if (rawPath === "") {
|
|
3755
|
-
writeError(res, 400, "path is required");
|
|
3756
|
-
return;
|
|
3757
|
-
}
|
|
3758
|
-
try {
|
|
3759
|
-
await fixDiagnosticFile(rawPath, homeOf(deps));
|
|
3760
|
-
deps.invalidate?.();
|
|
3761
|
-
writeJson(res, 200, {
|
|
3762
|
-
ok: true,
|
|
3763
|
-
path: rawPath
|
|
3764
|
-
});
|
|
3765
|
-
} catch (error) {
|
|
3766
|
-
writeRouteError(res, error);
|
|
3767
|
-
}
|
|
3768
|
-
}
|
|
3769
|
-
})
|
|
3959
|
+
}
|
|
3770
3960
|
];
|
|
3771
3961
|
}
|
|
3772
3962
|
//#endregion
|
|
3963
|
+
//#region src/routes.ts
|
|
3964
|
+
/**
|
|
3965
|
+
* Build every /api/skill-hub route.
|
|
3966
|
+
* @param deps - skill registry view + sidecar store.
|
|
3967
|
+
* @returns the exact-path routes.
|
|
3968
|
+
*/
|
|
3969
|
+
function makeRoutes(deps) {
|
|
3970
|
+
return [
|
|
3971
|
+
...catalogRoutes(deps),
|
|
3972
|
+
...configRoutes(deps),
|
|
3973
|
+
...marketRoutes(deps),
|
|
3974
|
+
...repoImportRoutes(deps),
|
|
3975
|
+
...groupRoutes(deps),
|
|
3976
|
+
...sourceRoutes(deps)
|
|
3977
|
+
].map((spec) => createRoute(deps, spec));
|
|
3978
|
+
}
|
|
3979
|
+
//#endregion
|
|
3773
3980
|
//#region src/stats.ts
|
|
3774
3981
|
/** Fallback freeze horizon when no rolling window is configured (14 days). */
|
|
3775
3982
|
const STATS_FREEZE_AFTER_MS = 336 * 60 * 60 * 1e3;
|
|
@@ -3808,6 +4015,22 @@ function isFrozen(record, watermark) {
|
|
|
3808
4015
|
const created = record.header.createdAt;
|
|
3809
4016
|
return typeof created === "number" && created > 0 && created < watermark;
|
|
3810
4017
|
}
|
|
4018
|
+
/** Bounded-concurrency map: session-log reads are independent decompressions, so wall time scales ~1/limit. Merge stays sequential in index order. */
|
|
4019
|
+
async function mapConcurrent(items, limit, worker) {
|
|
4020
|
+
const results = new Array(items.length);
|
|
4021
|
+
let cursor = 0;
|
|
4022
|
+
const runners = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
|
|
4023
|
+
while (cursor < items.length) {
|
|
4024
|
+
const index = cursor;
|
|
4025
|
+
cursor += 1;
|
|
4026
|
+
results[index] = await worker(items[index], index);
|
|
4027
|
+
}
|
|
4028
|
+
});
|
|
4029
|
+
await Promise.all(runners);
|
|
4030
|
+
return results;
|
|
4031
|
+
}
|
|
4032
|
+
/** Max parallel session-log reads per scan (read-only; merge is order-independent). */
|
|
4033
|
+
const SCAN_READ_CONCURRENCY = 6;
|
|
3811
4034
|
function mergeInto(totals, counted) {
|
|
3812
4035
|
for (const [name, stat] of counted) {
|
|
3813
4036
|
const total = totals[name];
|
|
@@ -3853,20 +4076,23 @@ async function scan(query, checkpoint, nowMs, windowDays) {
|
|
|
3853
4076
|
if (nowMs - checkpoint.lastFullReconcile >= 864e5 || checkpoint.windowDays !== windowDays) {
|
|
3854
4077
|
const cache = {};
|
|
3855
4078
|
const totals = {};
|
|
3856
|
-
|
|
3857
|
-
let counted;
|
|
4079
|
+
const countedList = await mapConcurrent(sessions, SCAN_READ_CONCURRENCY, async (record) => {
|
|
3858
4080
|
try {
|
|
3859
|
-
|
|
4081
|
+
return countSkillInvocations((await query.readSession(record.header.id)).events);
|
|
3860
4082
|
} catch {
|
|
3861
|
-
|
|
4083
|
+
return;
|
|
3862
4084
|
}
|
|
4085
|
+
});
|
|
4086
|
+
sessions.forEach((record, index) => {
|
|
4087
|
+
const counted = countedList[index];
|
|
4088
|
+
if (counted === void 0) return;
|
|
3863
4089
|
const created = record.header.createdAt;
|
|
3864
4090
|
if (isFrozen(record, cutoff) && counted.size > 0 && typeof created === "number") cache[record.header.id] = {
|
|
3865
4091
|
createdAt: created,
|
|
3866
4092
|
counts: Object.fromEntries(counted)
|
|
3867
4093
|
};
|
|
3868
4094
|
if (inWindow(created, windowDays, nowMs)) mergeInto(totals, counted);
|
|
3869
|
-
}
|
|
4095
|
+
});
|
|
3870
4096
|
checkpoint.frozenSessions = cache;
|
|
3871
4097
|
checkpoint.frozenBefore = cutoff;
|
|
3872
4098
|
checkpoint.windowDays = windowDays;
|
|
@@ -3877,13 +4103,16 @@ async function scan(query, checkpoint, nowMs, windowDays) {
|
|
|
3877
4103
|
};
|
|
3878
4104
|
}
|
|
3879
4105
|
const recent = {};
|
|
3880
|
-
|
|
3881
|
-
if (isFrozen(record, checkpoint.frozenBefore)) continue;
|
|
4106
|
+
const recentList = await mapConcurrent(sessions.filter((record) => !isFrozen(record, checkpoint.frozenBefore)), SCAN_READ_CONCURRENCY, async (record) => {
|
|
3882
4107
|
try {
|
|
3883
|
-
|
|
4108
|
+
return countSkillInvocations((await query.readSession(record.header.id)).events);
|
|
3884
4109
|
} catch {
|
|
3885
|
-
|
|
4110
|
+
return;
|
|
3886
4111
|
}
|
|
4112
|
+
});
|
|
4113
|
+
for (const counted of recentList) {
|
|
4114
|
+
if (counted === void 0) continue;
|
|
4115
|
+
mergeInto(recent, counted);
|
|
3887
4116
|
}
|
|
3888
4117
|
const totals = {};
|
|
3889
4118
|
for (const [id, entry] of Object.entries(checkpoint.frozenSessions)) {
|
|
@@ -3921,7 +4150,7 @@ function createSkillStatsReader(query, ttlMs = 3e5, options = {}) {
|
|
|
3921
4150
|
lastFullReconcile: 0
|
|
3922
4151
|
};
|
|
3923
4152
|
const now = options.now ?? (() => Date.now());
|
|
3924
|
-
let cached;
|
|
4153
|
+
let cached = checkpoint.lastTotals !== void 0 ? [...checkpoint.lastTotals] : void 0;
|
|
3925
4154
|
let cachedAt = 0;
|
|
3926
4155
|
let refreshing = null;
|
|
3927
4156
|
let lastScanDurationMs = 0;
|
|
@@ -3932,11 +4161,12 @@ function createSkillStatsReader(query, ttlMs = 3e5, options = {}) {
|
|
|
3932
4161
|
if (cached !== void 0 && startedAt - cachedAt < ttl) return cached;
|
|
3933
4162
|
if (refreshing === null) {
|
|
3934
4163
|
const windowDays = options.windowDays?.() ?? 0;
|
|
3935
|
-
refreshing = scan(query, checkpoint, startedAt, windowDays).then(({ stats
|
|
4164
|
+
refreshing = scan(query, checkpoint, startedAt, windowDays).then(({ stats }) => {
|
|
3936
4165
|
cached = stats;
|
|
3937
4166
|
cachedAt = now();
|
|
3938
4167
|
lastScanDurationMs = Math.max(0, cachedAt - startedAt);
|
|
3939
|
-
|
|
4168
|
+
checkpoint.lastTotals = stats;
|
|
4169
|
+
options.onCheckpoint?.({
|
|
3940
4170
|
...checkpoint,
|
|
3941
4171
|
frozenSessions: { ...checkpoint.frozenSessions }
|
|
3942
4172
|
});
|
|
@@ -3984,6 +4214,7 @@ const HubSettingsSchema = z.object({
|
|
|
3984
4214
|
showGroupSummary: z.boolean().default(HUB_CONFIG_DEFAULTS.showGroupSummary),
|
|
3985
4215
|
dotModelColor: z.string().pattern(HEX_COLOR_RE),
|
|
3986
4216
|
dotUserColor: z.string().pattern(HEX_COLOR_RE),
|
|
4217
|
+
githubToken: z.string(),
|
|
3987
4218
|
statsWindowDays: z.number().min(0).max(3650).default(HUB_CONFIG_DEFAULTS.statsWindowDays),
|
|
3988
4219
|
statsScanMinutes: z.number().min(1).max(1440).default(HUB_CONFIG_DEFAULTS.statsScanMinutes)
|
|
3989
4220
|
});
|
|
@@ -4026,6 +4257,7 @@ function apply(ctx, config) {
|
|
|
4026
4257
|
disposeProvider = void 0;
|
|
4027
4258
|
}
|
|
4028
4259
|
const value = current();
|
|
4260
|
+
setGithubToken(value.githubToken);
|
|
4029
4261
|
if (value.announceToAgent) disposeSection = ctx.systemPrompt.section({
|
|
4030
4262
|
name: "plugin:dsh-skill-hub",
|
|
4031
4263
|
order: SECTION_ORDER,
|
|
@@ -4092,7 +4324,7 @@ function apply(ctx, config) {
|
|
|
4092
4324
|
const value = current().statsWindowDays;
|
|
4093
4325
|
return typeof value === "number" && value >= 0 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsWindowDays;
|
|
4094
4326
|
};
|
|
4095
|
-
|
|
4327
|
+
const reader = createSkillStatsReader(sctx.sessionQuery, () => scanMinutes() * 6e4, {
|
|
4096
4328
|
checkpoint: saved,
|
|
4097
4329
|
windowDays,
|
|
4098
4330
|
onCheckpoint: (next) => {
|
|
@@ -4101,7 +4333,9 @@ function apply(ctx, config) {
|
|
|
4101
4333
|
});
|
|
4102
4334
|
}
|
|
4103
4335
|
});
|
|
4336
|
+
stats = reader;
|
|
4104
4337
|
sync();
|
|
4338
|
+
reader();
|
|
4105
4339
|
})();
|
|
4106
4340
|
});
|
|
4107
4341
|
}
|