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.
- 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 +28 -27
- 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
|
@@ -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
|
})
|