dsh-skill-hub 0.3.7 → 0.3.8

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.
Files changed (109) hide show
  1. package/README.md +40 -50
  2. package/README.zh.md +38 -48
  3. package/lib/client.js +2209 -1820
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +653 -419
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +1 -0
  7. package/lib/types/client/api.d.ts +12 -6
  8. package/lib/types/client/helpers.d.ts +0 -1
  9. package/lib/types/client/locales/common.d.ts +52 -0
  10. package/lib/types/client/locales/detail.d.ts +16 -0
  11. package/lib/types/client/locales/market.d.ts +73 -0
  12. package/lib/types/client/locales/settings.d.ts +41 -0
  13. package/lib/types/client/locales/skills.d.ts +65 -0
  14. package/lib/types/client/locales/sources.d.ts +35 -0
  15. package/lib/types/client/locales.d.ts +124 -118
  16. package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
  17. package/lib/types/client/panel/MarketView.d.ts +1 -0
  18. package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
  19. package/lib/types/client/panel/hooks/shared.d.ts +72 -0
  20. package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
  21. package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
  22. package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
  23. package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
  24. package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
  25. package/lib/types/client/panel/market-format.d.ts +6 -0
  26. package/lib/types/client/panel/useSkillHub.d.ts +45 -73
  27. package/lib/types/client/settings-card.d.ts +20 -0
  28. package/lib/types/client/settings-form.d.ts +6 -0
  29. package/lib/types/protocol/api.d.ts +38 -0
  30. package/lib/types/protocol/catalog.d.ts +155 -0
  31. package/lib/types/protocol/config.d.ts +113 -0
  32. package/lib/types/protocol/groups.d.ts +95 -0
  33. package/lib/types/protocol/market.d.ts +94 -0
  34. package/lib/types/protocol/repo.d.ts +107 -0
  35. package/lib/types/protocol/sources.d.ts +139 -0
  36. package/lib/types/protocol/stats.d.ts +46 -0
  37. package/lib/types/protocol.d.ts +13 -743
  38. package/lib/types/repo/github-client.d.ts +42 -0
  39. package/lib/types/repo.d.ts +6 -17
  40. package/lib/types/routes/catalog.d.ts +7 -0
  41. package/lib/types/routes/config.d.ts +7 -0
  42. package/lib/types/routes/groups.d.ts +7 -0
  43. package/lib/types/routes/helpers.d.ts +145 -0
  44. package/lib/types/routes/market.d.ts +8 -0
  45. package/lib/types/routes/repo-import.d.ts +7 -0
  46. package/lib/types/routes/route-state.d.ts +55 -0
  47. package/lib/types/routes/sources.d.ts +8 -0
  48. package/lib/types/routes.d.ts +5 -34
  49. package/lib/types/stats.d.ts +3 -2
  50. package/lib/types/store/errors.d.ts +10 -0
  51. package/lib/types/store/migrate.d.ts +44 -0
  52. package/lib/types/store/paths.d.ts +32 -0
  53. package/lib/types/store/store.d.ts +133 -0
  54. package/lib/types/store.d.ts +7 -146
  55. package/package.json +28 -27
  56. package/src/client/SkillHubSettingsCard.tsx +17 -3
  57. package/src/client/api.ts +101 -195
  58. package/src/client/helpers.ts +10 -1
  59. package/src/client/locales/common.ts +102 -0
  60. package/src/client/locales/detail.ts +30 -0
  61. package/src/client/locales/market.ts +144 -0
  62. package/src/client/locales/settings.ts +80 -0
  63. package/src/client/locales/skills.ts +128 -0
  64. package/src/client/locales/sources.ts +68 -0
  65. package/src/client/locales.ts +26 -512
  66. package/src/client/panel/MarketSourceRow.tsx +108 -0
  67. package/src/client/panel/MarketView.tsx +12 -253
  68. package/src/client/panel/RepoScanCard.tsx +171 -0
  69. package/src/client/panel/hooks/shared.ts +81 -0
  70. package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
  71. package/src/client/panel/hooks/useGroupFlow.ts +240 -0
  72. package/src/client/panel/hooks/useMarketFlow.ts +421 -0
  73. package/src/client/panel/hooks/useMetaFlow.ts +58 -0
  74. package/src/client/panel/hooks/useSourceFlow.ts +193 -0
  75. package/src/client/panel/market-format.ts +22 -0
  76. package/src/client/panel/useSkillHub.ts +141 -1032
  77. package/src/client/settings-card.tsx +51 -0
  78. package/src/client/settings-form.ts +18 -0
  79. package/src/index.ts +14 -4
  80. package/src/protocol/api.ts +38 -0
  81. package/src/protocol/catalog.ts +168 -0
  82. package/src/protocol/config.ts +145 -0
  83. package/src/protocol/groups.ts +106 -0
  84. package/src/protocol/market.ts +100 -0
  85. package/src/protocol/repo.ts +104 -0
  86. package/src/protocol/sources.ts +146 -0
  87. package/src/protocol/stats.ts +42 -0
  88. package/src/protocol.ts +83 -809
  89. package/src/repo/github-client.ts +131 -0
  90. package/src/repo.test.ts +24 -1
  91. package/src/repo.ts +50 -170
  92. package/src/routes/catalog.ts +309 -0
  93. package/src/routes/config.ts +119 -0
  94. package/src/routes/groups.ts +133 -0
  95. package/src/routes/helpers.ts +489 -0
  96. package/src/routes/market.ts +255 -0
  97. package/src/routes/repo-import.ts +267 -0
  98. package/src/routes/route-state.ts +89 -0
  99. package/src/routes/sources.ts +318 -0
  100. package/src/routes.test.ts +20 -0
  101. package/src/routes.ts +21 -1699
  102. package/src/stats.test.ts +58 -3
  103. package/src/stats.ts +45 -16
  104. package/src/store/errors.ts +14 -0
  105. package/src/store/migrate.ts +272 -0
  106. package/src/store/paths.ts +43 -0
  107. package/src/store/store.ts +532 -0
  108. package/src/store.test.ts +18 -0
  109. package/src/store.ts +7 -737
@@ -0,0 +1,532 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import type { DisabledSkill, HubConfig, MarketSourceRecord, MarketStatsSnapshot, SkillStatsCheckpoint, SkillTag, SourceRecord, TrashEntry } from '../protocol.ts'
4
+ import { StoreError } from './errors.ts'
5
+ import { hydrateMigratedState, migrateStore } from './migrate.ts'
6
+ import { DEFAULT_SCENE_NAME, STORE_VERSION, statePath, type StoreFile } from './paths.ts'
7
+
8
+ /** Sidecar state owner. */
9
+ export class SkillHubStore {
10
+ private entries = new Map<string, DisabledSkill>()
11
+ private config: Partial<HubConfig> = {}
12
+ private tagsById = new Map<string, SkillTag>()
13
+ private sourcesByRepo = new Map<string, SourceRecord>()
14
+ private marketSources: MarketSourceRecord[] = []
15
+ private trashByName = new Map<string, TrashEntry>()
16
+ private skillStats: SkillStatsCheckpoint | undefined = undefined
17
+ private marketStats: MarketStatsSnapshot | undefined = undefined
18
+ private collectionOrder: string[] = []
19
+ private sourceGroupOrder: string[] = []
20
+ private loaded = false
21
+ /** Serializes persist runs: concurrent mutators must not let an earlier
22
+ * snapshot overwrite a later one (rename is atomic, ordering is not). */
23
+ private writeChain: Promise<void> = Promise.resolve()
24
+
25
+ constructor(private readonly file: string = statePath()) {}
26
+
27
+ private async ensureLoaded(): Promise<void> {
28
+ if (this.loaded) return
29
+ this.loaded = true
30
+ try {
31
+ const raw = await readFile(this.file, 'utf8')
32
+ const parsed: unknown = JSON.parse(raw)
33
+ const migrated = migrateStore(parsed)
34
+ if (migrated === null) {
35
+ console.warn('[dsh-skill-hub] sidecar state uses a newer schema than this plugin supports; starting empty')
36
+ } else {
37
+ const state = hydrateMigratedState(migrated)
38
+ this.entries = state.entries
39
+ this.config = state.config
40
+ this.tagsById = state.tagsById
41
+ this.sourcesByRepo = state.sourcesByRepo
42
+ this.marketSources = state.marketSources
43
+ this.trashByName = state.trashByName
44
+ this.skillStats = state.skillStats
45
+ this.marketStats = state.marketStats
46
+ this.collectionOrder = state.collectionOrder
47
+ this.sourceGroupOrder = state.sourceGroupOrder
48
+ }
49
+ } catch (error) {
50
+ // Missing or unreadable state starts empty; never crash the plugin.
51
+ const code = (error as NodeJS.ErrnoException).code
52
+ if (code !== 'ENOENT') {
53
+ console.warn('[dsh-skill-hub] sidecar state unreadable, starting empty:', error instanceof Error ? error.message : error)
54
+ }
55
+ }
56
+ await this.ensureDefaultTag()
57
+ }
58
+
59
+ /**
60
+ * 保证存在默认场景:没有任何 default 标记的 tag 时创建「通用」。
61
+ * 新技能创建后自动归入它;用户可改名,但默认场景不可删除。
62
+ */
63
+ private async ensureDefaultTag(): Promise<void> {
64
+ if ([...this.tagsById.values()].some((tag) => tag.default === true)) return
65
+ const tag: SkillTag = { id: crypto.randomUUID(), name: DEFAULT_SCENE_NAME, skillNames: [], default: true }
66
+ this.tagsById.set(tag.id, tag)
67
+ await this.persist()
68
+ }
69
+
70
+ async listDisabled(): Promise<DisabledSkill[]> {
71
+ await this.ensureLoaded()
72
+ return [...this.entries.values()].sort((a, b) => a.name.localeCompare(b.name))
73
+ }
74
+
75
+ async getDisabled(name: string): Promise<DisabledSkill | undefined> {
76
+ await this.ensureLoaded()
77
+ return this.entries.get(name)
78
+ }
79
+
80
+ async addDisabled(entry: DisabledSkill): Promise<void> {
81
+ await this.ensureLoaded()
82
+ this.entries.set(entry.name, entry)
83
+ await this.persist()
84
+ }
85
+
86
+ async removeDisabled(name: string): Promise<void> {
87
+ await this.ensureLoaded()
88
+ if (!this.entries.delete(name)) return
89
+ await this.persist()
90
+ }
91
+
92
+ /** The hub's saved runtime configuration (fields absent here mean "not overridden"). */
93
+ async getConfig(): Promise<Partial<HubConfig>> {
94
+ await this.ensureLoaded()
95
+ return { ...this.config }
96
+ }
97
+
98
+ /**
99
+ * Persist a runtime-config patch, merged over the saved values. A field
100
+ * whose patch value is undefined is removed from the saved layer, so the
101
+ * setting re-inherits its default (the web card's "reset" path).
102
+ */
103
+ async setConfig(config: Partial<HubConfig>): Promise<void> {
104
+ await this.ensureLoaded()
105
+ const next: Partial<HubConfig> = { ...this.config }
106
+ for (const [key, value] of Object.entries(config) as Array<[keyof HubConfig, boolean | string | undefined]>) {
107
+ if (value === undefined) delete next[key]
108
+ else (next as unknown as Record<string, unknown>)[key] = value
109
+ }
110
+ this.config = next
111
+ await this.persist()
112
+ }
113
+
114
+ /** All user-defined tag groups, in creation order. */
115
+ async listTags(): Promise<SkillTag[]> {
116
+ await this.ensureLoaded()
117
+ return [...this.tagsById.values()]
118
+ }
119
+
120
+ /** One tag by id (undefined when absent). */
121
+ async getTag(id: string): Promise<SkillTag | undefined> {
122
+ await this.ensureLoaded()
123
+ return this.tagsById.get(id)
124
+ }
125
+
126
+ /**
127
+ * Create (no id) or rename (with id) a tag. Returns the saved tag.
128
+ * Creating assigns a fresh UUID; renaming keeps members.
129
+ */
130
+ async saveTag(input: { id?: string; name: string }): Promise<SkillTag> {
131
+ await this.ensureLoaded()
132
+ const name = input.name.trim()
133
+ if (name === '') throw new StoreError('validation', 'tag name must not be empty')
134
+ let tag: SkillTag
135
+ if (input.id !== undefined) {
136
+ const existing = this.tagsById.get(input.id)
137
+ if (existing === undefined) throw new StoreError('not-found', 'tag not found: ' + input.id)
138
+ tag = { ...existing, name }
139
+ } else {
140
+ tag = { id: crypto.randomUUID(), name, skillNames: [] }
141
+ }
142
+ this.tagsById.set(tag.id, tag)
143
+ await this.persist()
144
+ return tag
145
+ }
146
+
147
+ /** Delete a tag by id (no-op when absent). The default scene cannot be deleted. */
148
+ async deleteTag(id: string): Promise<void> {
149
+ await this.ensureLoaded()
150
+ const tag = this.tagsById.get(id)
151
+ if (tag?.default === true) throw new StoreError('conflict', 'the default scene cannot be deleted')
152
+ if (!this.tagsById.delete(id)) return
153
+ await this.persist()
154
+ }
155
+
156
+ /** The default scene (「通用」), guaranteed to exist after ensureLoaded. */
157
+ async getDefaultTag(): Promise<SkillTag | undefined> {
158
+ await this.ensureLoaded()
159
+ return [...this.tagsById.values()].find((tag) => tag.default === true)
160
+ }
161
+
162
+ /** Append one skill name to a tag (deduplicated; no-op when already a member). */
163
+ async addSkillToTag(id: string, name: string): Promise<SkillTag | undefined> {
164
+ await this.ensureLoaded()
165
+ const existing = this.tagsById.get(id)
166
+ if (existing === undefined || name.trim() === '' || existing.skillNames.includes(name)) return existing
167
+ const tag: SkillTag = { ...existing, skillNames: [...existing.skillNames, name] }
168
+ this.tagsById.set(id, tag)
169
+ await this.persist()
170
+ return tag
171
+ }
172
+
173
+ /**
174
+ * Replace a tag's member list wholesale (idempotent). Deduplicates and
175
+ * drops blank names; unknown skill names are kept (they may arrive later),
176
+ * the routes layer filters against the live catalog before persisting.
177
+ */
178
+ async setTagMembers(id: string, skillNames: readonly string[]): Promise<SkillTag | undefined> {
179
+ await this.ensureLoaded()
180
+ const existing = this.tagsById.get(id)
181
+ if (existing === undefined) return undefined
182
+ const names = [...new Set(skillNames.filter((n) => n.trim() !== ''))]
183
+ const tag: SkillTag = { ...existing, skillNames: names }
184
+ this.tagsById.set(id, tag)
185
+ await this.persist()
186
+ return tag
187
+ }
188
+
189
+ /** Remove one skill from every tag group (used when the skill is deleted). */
190
+ async removeSkillFromTags(name: string): Promise<void> {
191
+ await this.ensureLoaded()
192
+ let changed = false
193
+ for (const tag of this.tagsById.values()) {
194
+ if (!tag.skillNames.includes(name)) continue
195
+ tag.skillNames = tag.skillNames.filter((n) => n !== name)
196
+ changed = true
197
+ }
198
+ if (changed) await this.persist()
199
+ }
200
+
201
+ /** Reorder tag groups by orderedIds (drag-and-drop). */
202
+ async reorderTags(orderedIds: string[]): Promise<SkillTag[]> {
203
+ await this.ensureLoaded()
204
+ const currentIds = [...this.tagsById.keys()]
205
+ if (orderedIds.length !== currentIds.length) throw new StoreError('validation', 'orderedIds length mismatch')
206
+ const seen = new Set<string>()
207
+ for (const id of orderedIds) {
208
+ if (typeof id !== 'string' || id === '') throw new StoreError('validation', 'invalid tag id')
209
+ if (seen.has(id)) throw new StoreError('validation', 'duplicate tag id: ' + id)
210
+ if (!this.tagsById.has(id)) throw new StoreError('not-found', 'tag not found: ' + id)
211
+ seen.add(id)
212
+ }
213
+ const newMap = new Map<string, SkillTag>()
214
+ for (const id of orderedIds) newMap.set(id, this.tagsById.get(id)!)
215
+ this.tagsById = newMap
216
+ await this.persist()
217
+ return [...this.tagsById.values()]
218
+ }
219
+
220
+ /** Collection order for 来源分组拖拽 */
221
+ async getCollectionOrder(): Promise<string[]> {
222
+ await this.ensureLoaded()
223
+ return [...this.collectionOrder]
224
+ }
225
+
226
+ /** Reorder collections by orderedNames (drag-and-drop). */
227
+ async reorderCollections(orderedNames: string[]): Promise<string[]> {
228
+ await this.ensureLoaded()
229
+ const uniq = [...new Set(orderedNames.filter((n): n is string => typeof n === 'string' && n !== ''))]
230
+ this.collectionOrder = uniq
231
+ await this.persist()
232
+ return [...this.collectionOrder]
233
+ }
234
+
235
+ /** Source top-level group order for 来源分组(project / col:xxx / personal) */
236
+ async getSourceGroupOrder(): Promise<string[]> {
237
+ await this.ensureLoaded()
238
+ return [...this.sourceGroupOrder]
239
+ }
240
+
241
+ /** Reorder source top-level groups by orderedKeys */
242
+ async reorderSourceGroups(orderedKeys: string[]): Promise<string[]> {
243
+ await this.ensureLoaded()
244
+ const uniq = [...new Set(orderedKeys.filter((k): k is string => typeof k === 'string' && k !== ''))]
245
+ this.sourceGroupOrder = uniq
246
+ await this.persist()
247
+ return [...this.sourceGroupOrder]
248
+ }
249
+
250
+ // ------------------------------------------------------------ sources
251
+
252
+ /** All source records, sorted by repo. */
253
+ async listSources(): Promise<SourceRecord[]> {
254
+ await this.ensureLoaded()
255
+ return [...this.sourcesByRepo.values()].sort((a, b) => a.repo.localeCompare(b.repo))
256
+ }
257
+
258
+ /** One source record by repo (undefined when absent). */
259
+ async getSource(repo: string): Promise<SourceRecord | undefined> {
260
+ await this.ensureLoaded()
261
+ return this.sourcesByRepo.get(repo)
262
+ }
263
+
264
+ /** The source record that tracks a skill name (undefined when untracked). */
265
+ async getSourceForSkill(name: string): Promise<SourceRecord | undefined> {
266
+ await this.ensureLoaded()
267
+ for (const source of this.sourcesByRepo.values()) {
268
+ if (source.skills.includes(name)) return source
269
+ }
270
+ return undefined
271
+ }
272
+
273
+ /** Remove a source record entirely (no-op when absent). */
274
+ async deleteSource(repo: string): Promise<void> {
275
+ await this.ensureLoaded()
276
+ if (!this.sourcesByRepo.delete(repo)) return
277
+ await this.persist()
278
+ }
279
+
280
+ /**
281
+ * Upsert one skill into a source record. When the repo has no record yet a
282
+ * new one is created (root + commit snapshot from the caller).
283
+ */
284
+ async addSourceSkill(repo: string, root: string, commitSha: string, ref: string | undefined, skillName: string): Promise<void> {
285
+ await this.ensureLoaded()
286
+ const existing = this.sourcesByRepo.get(repo)
287
+ if (existing === undefined) {
288
+ this.sourcesByRepo.set(repo, {
289
+ repo,
290
+ ...(ref !== undefined && ref !== '' ? { ref } : {}),
291
+ root,
292
+ commitSha,
293
+ skills: [skillName],
294
+ })
295
+ } else {
296
+ const skills = existing.skills.includes(skillName) ? existing.skills : [...existing.skills, skillName].sort((a, b) => a.localeCompare(b))
297
+ this.sourcesByRepo.set(repo, { ...existing, skills })
298
+ }
299
+ await this.persist()
300
+ }
301
+
302
+ /** Replace a source's skill list (used after sync/confirm-delete). */
303
+ async setSourceSkills(repo: string, skills: readonly string[]): Promise<SourceRecord | undefined> {
304
+ await this.ensureLoaded()
305
+ const existing = this.sourcesByRepo.get(repo)
306
+ if (existing === undefined) return undefined
307
+ const names = [...new Set(skills.filter((n) => n.trim() !== ''))].sort((a, b) => a.localeCompare(b))
308
+ if (names.length === 0) {
309
+ this.sourcesByRepo.delete(repo)
310
+ } else {
311
+ this.sourcesByRepo.set(repo, { ...existing, skills: names })
312
+ }
313
+ await this.persist()
314
+ return this.sourcesByRepo.get(repo)
315
+ }
316
+
317
+ /** Remove one skill from every source record (used when the skill is deleted). */
318
+ async removeSkillFromSources(name: string): Promise<void> {
319
+ await this.ensureLoaded()
320
+ let changed = false
321
+ for (const [repo, source] of this.sourcesByRepo) {
322
+ if (!source.skills.includes(name)) continue
323
+ const skills = source.skills.filter((n) => n !== name)
324
+ if (skills.length === 0) this.sourcesByRepo.delete(repo)
325
+ else this.sourcesByRepo.set(repo, { ...source, skills })
326
+ changed = true
327
+ }
328
+ if (changed) await this.persist()
329
+ }
330
+
331
+ /** Update a source's commit snapshot. */
332
+ async setSourceCommit(repo: string, commitSha: string): Promise<void> {
333
+ await this.ensureLoaded()
334
+ const existing = this.sourcesByRepo.get(repo)
335
+ if (existing === undefined) return
336
+ this.sourcesByRepo.set(repo, { ...existing, commitSha })
337
+ await this.persist()
338
+ }
339
+
340
+ /** Update a source's pinned ref (release tag / branch) when the market syncs. */
341
+ async setSourceRef(repo: string, ref: string): Promise<void> {
342
+ await this.ensureLoaded()
343
+ const existing = this.sourcesByRepo.get(repo)
344
+ if (existing === undefined || ref.trim() === '') return
345
+ this.sourcesByRepo.set(repo, { ...existing, ref: ref.trim() })
346
+ await this.persist()
347
+ }
348
+
349
+ /**
350
+ * Merge per-path manifest entries into a source (incremental imports).
351
+ * When `dir` is given, every baseline path under that skill directory is
352
+ * dropped first, so files the upstream removed never linger in the
353
+ * baseline and skew later update diffs.
354
+ */
355
+ async mergeSourceManifest(repo: string, manifest: Record<string, number>, dir?: string): Promise<void> {
356
+ await this.ensureLoaded()
357
+ const existing = this.sourcesByRepo.get(repo)
358
+ if (existing === undefined || Object.keys(manifest).length === 0) return
359
+ const base: Record<string, number> = { ...(existing.manifest ?? {}) }
360
+ if (dir !== undefined && dir !== '') {
361
+ const prefix = dir + '/'
362
+ for (const path of Object.keys(base)) {
363
+ if (path.startsWith(prefix)) delete base[path]
364
+ }
365
+ }
366
+ this.sourcesByRepo.set(repo, { ...existing, manifest: { ...base, ...manifest } })
367
+ await this.persist()
368
+ }
369
+
370
+ /** skillName → collection name for every recorded origin (derived from sources). */
371
+ async listOrigins(): Promise<Record<string, string>> {
372
+ await this.ensureLoaded()
373
+ const origins: Record<string, string> = {}
374
+ for (const source of this.sourcesByRepo.values()) {
375
+ for (const name of source.skills) origins[name] = source.repo
376
+ }
377
+ return origins
378
+ }
379
+
380
+ // ------------------------------------------------------ market sources
381
+
382
+ /** The user's market sources, in addition order. */
383
+ async listMarketSources(): Promise<MarketSourceRecord[]> {
384
+ await this.ensureLoaded()
385
+ return [...this.marketSources]
386
+ }
387
+
388
+ /** One market source by repo (undefined when absent). */
389
+ async getMarketSource(repo: string): Promise<MarketSourceRecord | undefined> {
390
+ await this.ensureLoaded()
391
+ return this.marketSources.find((entry) => entry.repo === repo)
392
+ }
393
+
394
+ /** Add a repo (deduplicated), optionally with a pinned ref. Returns the fresh list. */
395
+ async addMarketSource(repo: string, ref?: string): Promise<MarketSourceRecord[]> {
396
+ await this.ensureLoaded()
397
+ const existing = this.marketSources.find((entry) => entry.repo === repo)
398
+ if (existing === undefined) {
399
+ this.marketSources.push(ref !== undefined && ref !== '' ? { repo, ref } : { repo })
400
+ } else if (ref !== undefined && ref !== '' && existing.ref !== ref) {
401
+ existing.ref = ref
402
+ }
403
+ await this.persist()
404
+ return [...this.marketSources]
405
+ }
406
+
407
+ /** Remove a repo (no-op when absent). Returns the fresh list. */
408
+ async removeMarketSource(repo: string): Promise<MarketSourceRecord[]> {
409
+ await this.ensureLoaded()
410
+ const index = this.marketSources.findIndex((entry) => entry.repo === repo)
411
+ if (index === -1) return [...this.marketSources]
412
+ this.marketSources.splice(index, 1)
413
+ await this.persist()
414
+ return [...this.marketSources]
415
+ }
416
+
417
+ /** Pin a market source to an explicit ref (branch/tag). Returns the record. */
418
+ async setMarketSourceRef(repo: string, ref: string): Promise<MarketSourceRecord | undefined> {
419
+ await this.ensureLoaded()
420
+ const entry = this.marketSources.find((item) => item.repo === repo)
421
+ if (entry === undefined || ref.trim() === '') return entry
422
+ entry.ref = ref.trim()
423
+ delete entry.commitSha
424
+ await this.persist()
425
+ return entry
426
+ }
427
+
428
+ /** Record the commit a market source's pinned ref resolved to (update baseline). */
429
+ async setMarketSourceCommit(repo: string, commitSha: string): Promise<void> {
430
+ await this.ensureLoaded()
431
+ const entry = this.marketSources.find((item) => item.repo === repo)
432
+ if (entry === undefined || commitSha === '') return
433
+ entry.commitSha = commitSha
434
+ await this.persist()
435
+ }
436
+
437
+ // ---------------------------------------------------------------- trash
438
+
439
+ /** All trashed skills, newest first. */
440
+ async listTrash(): Promise<TrashEntry[]> {
441
+ await this.ensureLoaded()
442
+ return [...this.trashByName.values()].sort((a, b) => b.movedAt - a.movedAt)
443
+ }
444
+
445
+ /** One trash entry by skill name (undefined when absent). */
446
+ async getTrash(name: string): Promise<TrashEntry | undefined> {
447
+ await this.ensureLoaded()
448
+ return this.trashByName.get(name)
449
+ }
450
+
451
+ /** Record a trashed skill. */
452
+ async addTrash(entry: TrashEntry): Promise<void> {
453
+ await this.ensureLoaded()
454
+ this.trashByName.set(entry.name, entry)
455
+ await this.persist()
456
+ }
457
+
458
+ /** Remove a trash record (after restore). */
459
+ async removeTrash(name: string): Promise<void> {
460
+ await this.ensureLoaded()
461
+ if (!this.trashByName.delete(name)) return
462
+ await this.persist()
463
+ }
464
+
465
+ /** The persisted usage-statistics checkpoint (undefined until first saved). */
466
+ async getSkillStatsState(): Promise<SkillStatsCheckpoint | undefined> {
467
+ await this.ensureLoaded()
468
+ return this.skillStats !== undefined
469
+ ? {
470
+ ...this.skillStats,
471
+ frozenSessions: { ...this.skillStats.frozenSessions },
472
+ ...(this.skillStats.lastTotals !== undefined ? { lastTotals: [...this.skillStats.lastTotals] } : {}),
473
+ }
474
+ : undefined
475
+ }
476
+
477
+ /** Persist a usage-statistics checkpoint (after every completed scan; cadence follows the scan TTL). */
478
+ async saveSkillStatsState(state: SkillStatsCheckpoint): Promise<void> {
479
+ await this.ensureLoaded()
480
+ this.skillStats = {
481
+ windowDays: state.windowDays,
482
+ frozenBefore: state.frozenBefore,
483
+ frozenSessions: { ...state.frozenSessions },
484
+ lastFullReconcile: state.lastFullReconcile,
485
+ ...(state.lastTotals !== undefined ? { lastTotals: [...state.lastTotals] } : {}),
486
+ }
487
+ await this.persist()
488
+ }
489
+
490
+ /** The persisted market-stats snapshot (undefined until first saved). */
491
+ async getMarketStatsState(): Promise<MarketStatsSnapshot | undefined> {
492
+ await this.ensureLoaded()
493
+ return this.marketStats !== undefined
494
+ ? { fetchedAt: this.marketStats.fetchedAt, stats: { ...this.marketStats.stats } }
495
+ : undefined
496
+ }
497
+
498
+ /** Persist a market-stats snapshot (written when a refresh fetched anything new). */
499
+ async saveMarketStatsState(state: MarketStatsSnapshot): Promise<void> {
500
+ await this.ensureLoaded()
501
+ this.marketStats = { fetchedAt: state.fetchedAt, stats: { ...state.stats } }
502
+ await this.persist()
503
+ }
504
+
505
+ private persist(): Promise<void> {
506
+ // The payload is built inside the queued step (not here), so every
507
+ // concurrent mutation made before a write actually lands is included in
508
+ // the final file instead of being rolled back by an older snapshot.
509
+ const run = this.writeChain.then(async () => {
510
+ const payload: StoreFile = {
511
+ version: STORE_VERSION,
512
+ disabled: [...this.entries.values()],
513
+ config: this.config,
514
+ ...(this.tagsById.size > 0 ? { tags: [...this.tagsById.values()] } : {}),
515
+ ...(this.sourcesByRepo.size > 0 ? { sources: [...this.sourcesByRepo.values()] } : {}),
516
+ ...(this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {}),
517
+ ...(this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {}),
518
+ ...(this.skillStats !== undefined ? { skillStats: this.skillStats } : {}),
519
+ ...(this.marketStats !== undefined ? { marketStats: this.marketStats } : {}),
520
+ ...(this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {}),
521
+ ...(this.sourceGroupOrder.length > 0 ? { sourceGroupOrder: [...this.sourceGroupOrder] } : {}),
522
+ }
523
+ const tmp = this.file + '.tmp'
524
+ await mkdir(dirname(this.file), { recursive: true })
525
+ await writeFile(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8')
526
+ await rename(tmp, this.file)
527
+ })
528
+ // Keep the chain alive on failure; callers still observe the rejection.
529
+ this.writeChain = run.catch(() => {})
530
+ return run
531
+ }
532
+ }
package/src/store.test.ts CHANGED
@@ -282,4 +282,22 @@ describe('SkillHubStore', () => {
282
282
  expect(await store.getSkillStatsState()).toBeUndefined()
283
283
  })
284
284
 
285
+ it('persists a marketStats snapshot round-trip and drops corrupt buckets', async () => {
286
+ expect(await store.getMarketStatsState()).toBeUndefined()
287
+ const snapshot = { fetchedAt: 9999, stats: { 'a/b': { stars: 1500, downloads: 50 } } }
288
+ await store.saveMarketStatsState(snapshot)
289
+ const reloaded = new SkillHubStore(file)
290
+ expect(await reloaded.getMarketStatsState()).toEqual(snapshot)
291
+ const raw = JSON.parse(await readFile(file, 'utf8')) as { marketStats?: unknown }
292
+ expect(raw.marketStats).toEqual(snapshot)
293
+ // 坏桶只丢 marketStats,不影响其它字段。
294
+ await writeFile(file, JSON.stringify({
295
+ version: 4,
296
+ disabled: [],
297
+ marketStats: { fetchedAt: 'x', stats: { 'a/b': { stars: 'many', downloads: null } } },
298
+ }), 'utf8')
299
+ const fresh = new SkillHubStore(file)
300
+ expect(await fresh.getMarketStatsState()).toBeUndefined()
301
+ })
302
+
285
303
  })