dsh-skill-hub 0.3.6 → 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 +2569 -1646
- package/lib/client.js.map +1 -1
- package/lib/index.js +988 -400
- package/lib/types/client/SkillHubSettingsCard.d.ts +1 -2
- package/lib/types/client/api.d.ts +15 -5
- package/lib/types/client/helpers.d.ts +10 -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 +147 -111
- package/lib/types/client/panel/DisabledRow.d.ts +2 -0
- 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/SkillDetailView.d.ts +5 -1
- package/lib/types/client/panel/dialogs.d.ts +19 -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 +54 -71
- package/lib/types/client/settings-card.d.ts +20 -17
- package/lib/types/client/settings-form.d.ts +6 -1
- 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 -720
- package/lib/types/repo/github-client.d.ts +42 -0
- package/lib/types/repo.d.ts +9 -22
- 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/skillfs.d.ts +13 -0
- 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 -5
- package/src/client/api.ts +108 -184
- package/src/client/helpers.ts +50 -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 -452
- package/src/client/panel/DisabledRow.tsx +22 -7
- package/src/client/panel/MarketSourceRow.tsx +108 -0
- package/src/client/panel/MarketView.tsx +12 -246
- package/src/client/panel/RepoScanCard.tsx +171 -0
- package/src/client/panel/ScenesView.tsx +1 -1
- package/src/client/panel/SkillDetailView.tsx +35 -11
- package/src/client/panel/SkillHubPanel.tsx +42 -4
- package/src/client/panel/SkillRow.tsx +5 -2
- package/src/client/panel/SourcesView.tsx +2 -17
- package/src/client/panel/dialogs.tsx +61 -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/panel.module.css +1 -8
- package/src/client/panel/useSkillHub.ts +142 -948
- package/src/client/settings-card.tsx +51 -42
- package/src/client/settings-form.ts +19 -5
- 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 -775
- package/src/repo/github-client.ts +131 -0
- package/src/repo.test.ts +24 -1
- package/src/repo.ts +69 -147
- 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 +42 -0
- package/src/routes.ts +21 -1528
- package/src/skillfs.ts +188 -3
- 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/src/store.ts
CHANGED
|
@@ -10,742 +10,12 @@
|
|
|
10
10
|
*
|
|
11
11
|
* State file: $DSH_HOME/dsh-skill-hub.json — a small JSON document written
|
|
12
12
|
* atomically (tmp file + rename).
|
|
13
|
+
*
|
|
14
|
+
* 按职责拆分后的重导出入口:老 `from './store.ts'` 写法保持可用,
|
|
15
|
+
* 新代码可按需直引 `from './store/<module>.ts'`。
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
/** 默认场景名(系统预置的兜底场景,新技能自动归入)。 */
|
|
21
|
-
export const DEFAULT_SCENE_NAME = '通用'
|
|
22
|
-
|
|
23
|
-
/** Wire shape persisted on disk. */
|
|
24
|
-
interface StoreFile {
|
|
25
|
-
version: number
|
|
26
|
-
disabled: DisabledSkill[]
|
|
27
|
-
/** Runtime configuration edited from the web settings card (hub-owned, not settings-service). */
|
|
28
|
-
config?: Partial<HubConfig>
|
|
29
|
-
/** User-defined tag groups (pure organization; skill files untouched). */
|
|
30
|
-
tags?: SkillTag[]
|
|
31
|
-
/** Upstream source tracking records (repo + commit snapshot). */
|
|
32
|
-
sources?: SourceRecord[]
|
|
33
|
-
/** User-added market sources (owner/repo slugs + optional pinned ref). */
|
|
34
|
-
marketSources?: MarketSourceRecord[]
|
|
35
|
-
/** Trashed skills (removed after upstream deletion, restorable). */
|
|
36
|
-
trash?: TrashEntry[]
|
|
37
|
-
/** Usage-statistics incremental-scan checkpoint (frozen watermark + totals). */
|
|
38
|
-
skillStats?: SkillStatsCheckpoint
|
|
39
|
-
/** Drag-reorder: collection name order for 来源分组 */
|
|
40
|
-
collectionOrder?: string[]
|
|
41
|
-
/** Drag-reorder: 来源顶层分组整体顺序(project / col:xxx / uncategorized-source) */
|
|
42
|
-
sourceGroupOrder?: string[]
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Resolve the DSH home directory (the filesystem provider's user-dsh root base). */
|
|
46
|
-
export function dshHome(): string {
|
|
47
|
-
return process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Resolve the sidecar state path (injectable in tests). */
|
|
51
|
-
export function statePath(home = dshHome()): string {
|
|
52
|
-
return join(home, 'dsh-skill-hub.json')
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Current sidecar schema version. Bump on breaking shape changes and add a migration below. */
|
|
56
|
-
export const STORE_VERSION = 4
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Business-rule failure the routes layer can map onto a 4xx status instead
|
|
60
|
-
* of a blanket 500: user input is invalid (validation → 400), the target
|
|
61
|
-
* does not exist (not-found → 404), or the operation conflicts with an
|
|
62
|
-
* invariant (conflict → 409).
|
|
63
|
-
*/
|
|
64
|
-
export class StoreError extends Error {
|
|
65
|
-
readonly kind: 'validation' | 'not-found' | 'conflict'
|
|
66
|
-
constructor(kind: StoreError['kind'], message: string) {
|
|
67
|
-
super(message)
|
|
68
|
-
this.name = 'StoreError'
|
|
69
|
-
this.kind = kind
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Normalize an arbitrary parsed sidecar document to the current schema.
|
|
75
|
-
* v1 to v2: scenes are dropped (the one-click role merged into group
|
|
76
|
-
* switches), and origins are migrated into source records (repo = origin
|
|
77
|
-
* value, commit snapshot empty — the first update check backfills it).
|
|
78
|
-
* v2 to v3: market sources become records ({ repo, ref?, commitSha? })
|
|
79
|
-
* instead of bare repo slugs, so a source can pin a release/branch version.
|
|
80
|
-
* Returns null when the file claims a newer schema than this plugin
|
|
81
|
-
* understands, so the caller starts empty instead of risking data loss.
|
|
82
|
-
*/
|
|
83
|
-
function migrateStore(parsed: unknown): { version: number; disabled: unknown; config?: unknown; tags?: unknown; sources?: unknown; marketSources?: unknown; trash?: unknown; skillStats?: unknown; collectionOrder?: unknown; sourceGroupOrder?: unknown } | null {
|
|
84
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null
|
|
85
|
-
const record = parsed as Record<string, unknown>
|
|
86
|
-
const version = typeof record.version === 'number' ? record.version : 0
|
|
87
|
-
if (version > STORE_VERSION) return null
|
|
88
|
-
const disabled = Array.isArray(record.disabled) ? record.disabled : []
|
|
89
|
-
const config = typeof record.config === 'object' && record.config !== null && !Array.isArray(record.config) ? record.config : undefined
|
|
90
|
-
const tags = Array.isArray(record.tags) ? record.tags : undefined
|
|
91
|
-
const trash = Array.isArray(record.trash) ? record.trash : undefined
|
|
92
|
-
// v2 stored bare slugs; v3 stores records. Normalize both shapes here.
|
|
93
|
-
const marketSources = Array.isArray(record.marketSources)
|
|
94
|
-
? record.marketSources.map((entry) => typeof entry === 'string'
|
|
95
|
-
? { repo: entry }
|
|
96
|
-
: entry)
|
|
97
|
-
: undefined
|
|
98
|
-
|
|
99
|
-
// v2 stores structured source records; v1 only had a skillName to
|
|
100
|
-
// collection string map (origins). Migrate each origin into a SourceRecord
|
|
101
|
-
// whose repo is the collection string itself and whose commit snapshot is
|
|
102
|
-
// empty.
|
|
103
|
-
let sources: unknown
|
|
104
|
-
if (Array.isArray(record.sources)) {
|
|
105
|
-
sources = record.sources
|
|
106
|
-
} else if (version < 2 && typeof record.origins === 'object' && record.origins !== null && !Array.isArray(record.origins)) {
|
|
107
|
-
const byOrigin = new Map<string, string[]>()
|
|
108
|
-
for (const [name, origin] of Object.entries(record.origins as Record<string, unknown>)) {
|
|
109
|
-
if (typeof origin !== 'string' || origin === '') continue
|
|
110
|
-
const list = byOrigin.get(origin)
|
|
111
|
-
if (list === undefined) byOrigin.set(origin, [name])
|
|
112
|
-
else list.push(name)
|
|
113
|
-
}
|
|
114
|
-
sources = [...byOrigin.entries()].map(([repo, skillNames]) => ({
|
|
115
|
-
repo,
|
|
116
|
-
root: 'skills',
|
|
117
|
-
commitSha: '',
|
|
118
|
-
skills: skillNames.sort((a, b) => a.localeCompare(b)),
|
|
119
|
-
}))
|
|
120
|
-
}
|
|
121
|
-
return {
|
|
122
|
-
version: STORE_VERSION,
|
|
123
|
-
disabled,
|
|
124
|
-
...(config !== undefined ? { config } : {}),
|
|
125
|
-
...(tags !== undefined ? { tags } : {}),
|
|
126
|
-
...(sources !== undefined ? { sources } : {}),
|
|
127
|
-
...(marketSources !== undefined ? { marketSources } : {}),
|
|
128
|
-
...(trash !== undefined ? { trash } : {}),
|
|
129
|
-
// v4 (skillStats) is a pure addition — older files simply lack the field,
|
|
130
|
-
// and the loader validates its shape, so pass it through untouched.
|
|
131
|
-
...(record.skillStats !== undefined ? { skillStats: record.skillStats } : {}),
|
|
132
|
-
...(Array.isArray(record.collectionOrder) ? { collectionOrder: record.collectionOrder } : {}),
|
|
133
|
-
...(Array.isArray(record.sourceGroupOrder) ? { sourceGroupOrder: record.sourceGroupOrder } : {}),
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Sidecar state owner. */
|
|
138
|
-
export class SkillHubStore {
|
|
139
|
-
private entries = new Map<string, DisabledSkill>()
|
|
140
|
-
private config: Partial<HubConfig> = {}
|
|
141
|
-
private tagsById = new Map<string, SkillTag>()
|
|
142
|
-
private sourcesByRepo = new Map<string, SourceRecord>()
|
|
143
|
-
private marketSources: MarketSourceRecord[] = []
|
|
144
|
-
private trashByName = new Map<string, TrashEntry>()
|
|
145
|
-
private skillStats: SkillStatsCheckpoint | undefined = undefined
|
|
146
|
-
private collectionOrder: string[] = []
|
|
147
|
-
private sourceGroupOrder: string[] = []
|
|
148
|
-
private loaded = false
|
|
149
|
-
/** Serializes persist runs: concurrent mutators must not let an earlier
|
|
150
|
-
* snapshot overwrite a later one (rename is atomic, ordering is not). */
|
|
151
|
-
private writeChain: Promise<void> = Promise.resolve()
|
|
152
|
-
|
|
153
|
-
constructor(private readonly file: string = statePath()) {}
|
|
154
|
-
|
|
155
|
-
private async ensureLoaded(): Promise<void> {
|
|
156
|
-
if (this.loaded) return
|
|
157
|
-
this.loaded = true
|
|
158
|
-
try {
|
|
159
|
-
const raw = await readFile(this.file, 'utf8')
|
|
160
|
-
const parsed: unknown = JSON.parse(raw)
|
|
161
|
-
const migrated = migrateStore(parsed)
|
|
162
|
-
if (migrated === null) {
|
|
163
|
-
console.warn('[dsh-skill-hub] sidecar state uses a newer schema than this plugin supports; starting empty')
|
|
164
|
-
} else {
|
|
165
|
-
if (Array.isArray(migrated.disabled)) {
|
|
166
|
-
for (const entry of migrated.disabled) {
|
|
167
|
-
if (typeof entry?.name === 'string' && typeof entry?.path === 'string') {
|
|
168
|
-
this.entries.set(entry.name, entry)
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
const saved = migrated.config as { enabled?: unknown; announceToAgent?: unknown; dotModelColor?: unknown; dotUserColor?: unknown; showUseCount?: unknown; showUseTime?: unknown; showGroupSummary?: unknown } | undefined
|
|
173
|
-
if (typeof saved === 'object' && saved !== null) {
|
|
174
|
-
if (typeof saved.enabled === 'boolean') this.config.enabled = saved.enabled
|
|
175
|
-
if (typeof saved.announceToAgent === 'boolean') this.config.announceToAgent = saved.announceToAgent
|
|
176
|
-
if (typeof saved.dotModelColor === 'string' && saved.dotModelColor !== '') this.config.dotModelColor = saved.dotModelColor
|
|
177
|
-
if (typeof saved.dotUserColor === 'string' && saved.dotUserColor !== '') this.config.dotUserColor = saved.dotUserColor
|
|
178
|
-
if (typeof saved.showUseCount === 'boolean') this.config.showUseCount = saved.showUseCount
|
|
179
|
-
if (typeof saved.showUseTime === 'boolean') this.config.showUseTime = saved.showUseTime
|
|
180
|
-
if (typeof saved.showGroupSummary === 'boolean') this.config.showGroupSummary = saved.showGroupSummary
|
|
181
|
-
}
|
|
182
|
-
if (Array.isArray(migrated.tags)) {
|
|
183
|
-
for (const entry of migrated.tags as unknown[]) {
|
|
184
|
-
const tag = entry as { id?: unknown; name?: unknown; skillNames?: unknown; default?: unknown } | null
|
|
185
|
-
if (tag !== null && typeof tag === 'object' && typeof tag.id === 'string' && typeof tag.name === 'string' && Array.isArray(tag.skillNames)) {
|
|
186
|
-
this.tagsById.set(tag.id, {
|
|
187
|
-
id: tag.id,
|
|
188
|
-
name: tag.name,
|
|
189
|
-
skillNames: tag.skillNames.filter((n): n is string => typeof n === 'string'),
|
|
190
|
-
...(tag.default === true ? { default: true } : {}),
|
|
191
|
-
})
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
if (Array.isArray(migrated.sources)) {
|
|
196
|
-
for (const entry of migrated.sources as unknown[]) {
|
|
197
|
-
const source = entry as { repo?: unknown; ref?: unknown; root?: unknown; commitSha?: unknown; skills?: unknown; manifest?: unknown } | null
|
|
198
|
-
if (source !== null && typeof source === 'object' && typeof source.repo === 'string' && source.repo !== '' && Array.isArray(source.skills)) {
|
|
199
|
-
const manifest = source.manifest as Record<string, unknown> | undefined
|
|
200
|
-
const rawRoot = typeof source.root === 'string' && source.root !== '' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(source.root) ? source.root : 'skills'
|
|
201
|
-
this.sourcesByRepo.set(source.repo, {
|
|
202
|
-
repo: source.repo,
|
|
203
|
-
...(typeof source.ref === 'string' && source.ref !== '' ? { ref: source.ref } : {}),
|
|
204
|
-
root: rawRoot,
|
|
205
|
-
commitSha: typeof source.commitSha === 'string' ? source.commitSha : '',
|
|
206
|
-
skills: source.skills.filter((n): n is string => typeof n === 'string'),
|
|
207
|
-
...(manifest !== null && typeof manifest === 'object' && !Array.isArray(manifest)
|
|
208
|
-
? { manifest: Object.fromEntries(Object.entries(manifest).filter(([, size]) => typeof size === 'number')) as Record<string, number> }
|
|
209
|
-
: {}),
|
|
210
|
-
})
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (Array.isArray(migrated.marketSources)) {
|
|
215
|
-
for (const entry of migrated.marketSources as unknown[]) {
|
|
216
|
-
const item = entry as { repo?: unknown; ref?: unknown; commitSha?: unknown } | null
|
|
217
|
-
if (item !== null && typeof item === 'object' && typeof item.repo === 'string' && item.repo !== '' && !this.marketSources.some((s) => s.repo === item.repo)) {
|
|
218
|
-
this.marketSources.push({
|
|
219
|
-
repo: item.repo,
|
|
220
|
-
...(typeof item.ref === 'string' && item.ref !== '' ? { ref: item.ref } : {}),
|
|
221
|
-
...(typeof item.commitSha === 'string' && item.commitSha !== '' ? { commitSha: item.commitSha } : {}),
|
|
222
|
-
})
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
if (Array.isArray(migrated.trash)) {
|
|
227
|
-
for (const entry of migrated.trash as unknown[]) {
|
|
228
|
-
const item = entry as { name?: unknown; path?: unknown; movedAt?: unknown; sourcePath?: unknown } | null
|
|
229
|
-
if (item !== null && typeof item === 'object' && typeof item.name === 'string' && typeof item.path === 'string') {
|
|
230
|
-
const origin = (item as Record<string, unknown>).origin as { repo?: unknown; root?: unknown; ref?: unknown; commitSha?: unknown } | undefined
|
|
231
|
-
const tagIds = (item as Record<string, unknown>).tagIds
|
|
232
|
-
this.trashByName.set(item.name, {
|
|
233
|
-
name: item.name,
|
|
234
|
-
path: item.path,
|
|
235
|
-
movedAt: typeof item.movedAt === 'number' ? item.movedAt : 0,
|
|
236
|
-
...(typeof item.sourcePath === 'string' && item.sourcePath !== '' ? { sourcePath: item.sourcePath } : {}),
|
|
237
|
-
...(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)
|
|
238
|
-
? {
|
|
239
|
-
origin: {
|
|
240
|
-
repo: origin.repo,
|
|
241
|
-
root: origin.root,
|
|
242
|
-
...(typeof origin.ref === 'string' && origin.ref !== '' ? { ref: origin.ref } : {}),
|
|
243
|
-
commitSha: typeof origin.commitSha === 'string' ? origin.commitSha : '',
|
|
244
|
-
},
|
|
245
|
-
}
|
|
246
|
-
: {}),
|
|
247
|
-
...(Array.isArray(tagIds) ? { tagIds: tagIds.filter((id): id is string => typeof id === 'string') } : {}),
|
|
248
|
-
})
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
const savedStats = migrated.skillStats as Partial<SkillStatsCheckpoint> | null | undefined
|
|
253
|
-
if (savedStats !== null && typeof savedStats === 'object'
|
|
254
|
-
&& typeof savedStats.frozenBefore === 'number' && typeof savedStats.lastFullReconcile === 'number'
|
|
255
|
-
&& typeof savedStats.windowDays === 'number'
|
|
256
|
-
&& typeof savedStats.frozenSessions === 'object' && savedStats.frozenSessions !== null) {
|
|
257
|
-
// Validate entry shapes too: a corrupt bucket degrades to a fresh
|
|
258
|
-
// checkpoint (one extra full reconciliation), never to bad counts.
|
|
259
|
-
const sessions: SkillStatsCheckpoint['frozenSessions'] = {}
|
|
260
|
-
for (const [id, entry] of Object.entries(savedStats.frozenSessions)) {
|
|
261
|
-
if (entry === null || typeof entry !== 'object' || typeof entry.createdAt !== 'number'
|
|
262
|
-
|| typeof entry.counts !== 'object' || entry.counts === null) continue
|
|
263
|
-
const counts: Record<string, { count: number; lastUsed: number }> = {}
|
|
264
|
-
for (const [name, stat] of Object.entries(entry.counts)) {
|
|
265
|
-
if (stat !== null && typeof stat === 'object'
|
|
266
|
-
&& typeof (stat as { count?: unknown }).count === 'number'
|
|
267
|
-
&& typeof (stat as { lastUsed?: unknown }).lastUsed === 'number') {
|
|
268
|
-
counts[name] = { count: (stat as { count: number }).count, lastUsed: (stat as { lastUsed: number }).lastUsed }
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
sessions[id] = { createdAt: entry.createdAt, counts }
|
|
272
|
-
}
|
|
273
|
-
this.skillStats = {
|
|
274
|
-
windowDays: savedStats.windowDays,
|
|
275
|
-
frozenBefore: savedStats.frozenBefore,
|
|
276
|
-
frozenSessions: sessions,
|
|
277
|
-
lastFullReconcile: savedStats.lastFullReconcile,
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
const rawColOrder = (migrated as Record<string, unknown>).collectionOrder as unknown
|
|
281
|
-
if (Array.isArray(rawColOrder)) {
|
|
282
|
-
this.collectionOrder = rawColOrder.filter((n): n is string => typeof n === 'string' && n !== '')
|
|
283
|
-
}
|
|
284
|
-
const rawSrcOrder = (migrated as Record<string, unknown>).sourceGroupOrder as unknown
|
|
285
|
-
if (Array.isArray(rawSrcOrder)) {
|
|
286
|
-
this.sourceGroupOrder = rawSrcOrder.filter((n): n is string => typeof n === 'string' && n !== '')
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
} catch (error) {
|
|
290
|
-
// Missing or unreadable state starts empty; never crash the plugin.
|
|
291
|
-
const code = (error as NodeJS.ErrnoException).code
|
|
292
|
-
if (code !== 'ENOENT') {
|
|
293
|
-
console.warn('[dsh-skill-hub] sidecar state unreadable, starting empty:', error instanceof Error ? error.message : error)
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
await this.ensureDefaultTag()
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* 保证存在默认场景:没有任何 default 标记的 tag 时创建「通用」。
|
|
301
|
-
* 新技能创建后自动归入它;用户可改名,但默认场景不可删除。
|
|
302
|
-
*/
|
|
303
|
-
private async ensureDefaultTag(): Promise<void> {
|
|
304
|
-
if ([...this.tagsById.values()].some((tag) => tag.default === true)) return
|
|
305
|
-
const tag: SkillTag = { id: crypto.randomUUID(), name: DEFAULT_SCENE_NAME, skillNames: [], default: true }
|
|
306
|
-
this.tagsById.set(tag.id, tag)
|
|
307
|
-
await this.persist()
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
async listDisabled(): Promise<DisabledSkill[]> {
|
|
311
|
-
await this.ensureLoaded()
|
|
312
|
-
return [...this.entries.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
async getDisabled(name: string): Promise<DisabledSkill | undefined> {
|
|
316
|
-
await this.ensureLoaded()
|
|
317
|
-
return this.entries.get(name)
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
async addDisabled(entry: DisabledSkill): Promise<void> {
|
|
321
|
-
await this.ensureLoaded()
|
|
322
|
-
this.entries.set(entry.name, entry)
|
|
323
|
-
await this.persist()
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
async removeDisabled(name: string): Promise<void> {
|
|
327
|
-
await this.ensureLoaded()
|
|
328
|
-
if (!this.entries.delete(name)) return
|
|
329
|
-
await this.persist()
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/** The hub's saved runtime configuration (fields absent here mean "not overridden"). */
|
|
333
|
-
async getConfig(): Promise<Partial<HubConfig>> {
|
|
334
|
-
await this.ensureLoaded()
|
|
335
|
-
return { ...this.config }
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Persist a runtime-config patch, merged over the saved values. A field
|
|
340
|
-
* whose patch value is undefined is removed from the saved layer, so the
|
|
341
|
-
* setting re-inherits its default (the web card's "reset" path).
|
|
342
|
-
*/
|
|
343
|
-
async setConfig(config: Partial<HubConfig>): Promise<void> {
|
|
344
|
-
await this.ensureLoaded()
|
|
345
|
-
const next: Partial<HubConfig> = { ...this.config }
|
|
346
|
-
for (const [key, value] of Object.entries(config) as Array<[keyof HubConfig, boolean | string | undefined]>) {
|
|
347
|
-
if (value === undefined) delete next[key]
|
|
348
|
-
else (next as unknown as Record<string, unknown>)[key] = value
|
|
349
|
-
}
|
|
350
|
-
this.config = next
|
|
351
|
-
await this.persist()
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/** All user-defined tag groups, in creation order. */
|
|
355
|
-
async listTags(): Promise<SkillTag[]> {
|
|
356
|
-
await this.ensureLoaded()
|
|
357
|
-
return [...this.tagsById.values()]
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** One tag by id (undefined when absent). */
|
|
361
|
-
async getTag(id: string): Promise<SkillTag | undefined> {
|
|
362
|
-
await this.ensureLoaded()
|
|
363
|
-
return this.tagsById.get(id)
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* Create (no id) or rename (with id) a tag. Returns the saved tag.
|
|
368
|
-
* Creating assigns a fresh UUID; renaming keeps members.
|
|
369
|
-
*/
|
|
370
|
-
async saveTag(input: { id?: string; name: string }): Promise<SkillTag> {
|
|
371
|
-
await this.ensureLoaded()
|
|
372
|
-
const name = input.name.trim()
|
|
373
|
-
if (name === '') throw new StoreError('validation', 'tag name must not be empty')
|
|
374
|
-
let tag: SkillTag
|
|
375
|
-
if (input.id !== undefined) {
|
|
376
|
-
const existing = this.tagsById.get(input.id)
|
|
377
|
-
if (existing === undefined) throw new StoreError('not-found', 'tag not found: ' + input.id)
|
|
378
|
-
tag = { ...existing, name }
|
|
379
|
-
} else {
|
|
380
|
-
tag = { id: crypto.randomUUID(), name, skillNames: [] }
|
|
381
|
-
}
|
|
382
|
-
this.tagsById.set(tag.id, tag)
|
|
383
|
-
await this.persist()
|
|
384
|
-
return tag
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** Delete a tag by id (no-op when absent). The default scene cannot be deleted. */
|
|
388
|
-
async deleteTag(id: string): Promise<void> {
|
|
389
|
-
await this.ensureLoaded()
|
|
390
|
-
const tag = this.tagsById.get(id)
|
|
391
|
-
if (tag?.default === true) throw new StoreError('conflict', 'the default scene cannot be deleted')
|
|
392
|
-
if (!this.tagsById.delete(id)) return
|
|
393
|
-
await this.persist()
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/** The default scene (「通用」), guaranteed to exist after ensureLoaded. */
|
|
397
|
-
async getDefaultTag(): Promise<SkillTag | undefined> {
|
|
398
|
-
await this.ensureLoaded()
|
|
399
|
-
return [...this.tagsById.values()].find((tag) => tag.default === true)
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** Append one skill name to a tag (deduplicated; no-op when already a member). */
|
|
403
|
-
async addSkillToTag(id: string, name: string): Promise<SkillTag | undefined> {
|
|
404
|
-
await this.ensureLoaded()
|
|
405
|
-
const existing = this.tagsById.get(id)
|
|
406
|
-
if (existing === undefined || name.trim() === '' || existing.skillNames.includes(name)) return existing
|
|
407
|
-
const tag: SkillTag = { ...existing, skillNames: [...existing.skillNames, name] }
|
|
408
|
-
this.tagsById.set(id, tag)
|
|
409
|
-
await this.persist()
|
|
410
|
-
return tag
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Replace a tag's member list wholesale (idempotent). Deduplicates and
|
|
415
|
-
* drops blank names; unknown skill names are kept (they may arrive later),
|
|
416
|
-
* the routes layer filters against the live catalog before persisting.
|
|
417
|
-
*/
|
|
418
|
-
async setTagMembers(id: string, skillNames: readonly string[]): Promise<SkillTag | undefined> {
|
|
419
|
-
await this.ensureLoaded()
|
|
420
|
-
const existing = this.tagsById.get(id)
|
|
421
|
-
if (existing === undefined) return undefined
|
|
422
|
-
const names = [...new Set(skillNames.filter((n) => n.trim() !== ''))]
|
|
423
|
-
const tag: SkillTag = { ...existing, skillNames: names }
|
|
424
|
-
this.tagsById.set(id, tag)
|
|
425
|
-
await this.persist()
|
|
426
|
-
return tag
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/** Remove one skill from every tag group (used when the skill is deleted). */
|
|
430
|
-
async removeSkillFromTags(name: string): Promise<void> {
|
|
431
|
-
await this.ensureLoaded()
|
|
432
|
-
let changed = false
|
|
433
|
-
for (const tag of this.tagsById.values()) {
|
|
434
|
-
if (!tag.skillNames.includes(name)) continue
|
|
435
|
-
tag.skillNames = tag.skillNames.filter((n) => n !== name)
|
|
436
|
-
changed = true
|
|
437
|
-
}
|
|
438
|
-
if (changed) await this.persist()
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/** Reorder tag groups by orderedIds (drag-and-drop). */
|
|
442
|
-
async reorderTags(orderedIds: string[]): Promise<SkillTag[]> {
|
|
443
|
-
await this.ensureLoaded()
|
|
444
|
-
const currentIds = [...this.tagsById.keys()]
|
|
445
|
-
if (orderedIds.length !== currentIds.length) throw new StoreError('validation', 'orderedIds length mismatch')
|
|
446
|
-
const seen = new Set<string>()
|
|
447
|
-
for (const id of orderedIds) {
|
|
448
|
-
if (typeof id !== 'string' || id === '') throw new StoreError('validation', 'invalid tag id')
|
|
449
|
-
if (seen.has(id)) throw new StoreError('validation', 'duplicate tag id: ' + id)
|
|
450
|
-
if (!this.tagsById.has(id)) throw new StoreError('not-found', 'tag not found: ' + id)
|
|
451
|
-
seen.add(id)
|
|
452
|
-
}
|
|
453
|
-
const newMap = new Map<string, SkillTag>()
|
|
454
|
-
for (const id of orderedIds) newMap.set(id, this.tagsById.get(id)!)
|
|
455
|
-
this.tagsById = newMap
|
|
456
|
-
await this.persist()
|
|
457
|
-
return [...this.tagsById.values()]
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/** Collection order for 来源分组拖拽 */
|
|
461
|
-
async getCollectionOrder(): Promise<string[]> {
|
|
462
|
-
await this.ensureLoaded()
|
|
463
|
-
return [...this.collectionOrder]
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** Reorder collections by orderedNames (drag-and-drop). */
|
|
467
|
-
async reorderCollections(orderedNames: string[]): Promise<string[]> {
|
|
468
|
-
await this.ensureLoaded()
|
|
469
|
-
const uniq = [...new Set(orderedNames.filter((n): n is string => typeof n === 'string' && n !== ''))]
|
|
470
|
-
this.collectionOrder = uniq
|
|
471
|
-
await this.persist()
|
|
472
|
-
return [...this.collectionOrder]
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/** Source top-level group order for 来源分组(project / col:xxx / personal) */
|
|
476
|
-
async getSourceGroupOrder(): Promise<string[]> {
|
|
477
|
-
await this.ensureLoaded()
|
|
478
|
-
return [...this.sourceGroupOrder]
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/** Reorder source top-level groups by orderedKeys */
|
|
482
|
-
async reorderSourceGroups(orderedKeys: string[]): Promise<string[]> {
|
|
483
|
-
await this.ensureLoaded()
|
|
484
|
-
const uniq = [...new Set(orderedKeys.filter((k): k is string => typeof k === 'string' && k !== ''))]
|
|
485
|
-
this.sourceGroupOrder = uniq
|
|
486
|
-
await this.persist()
|
|
487
|
-
return [...this.sourceGroupOrder]
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// ------------------------------------------------------------ sources
|
|
491
|
-
|
|
492
|
-
/** All source records, sorted by repo. */
|
|
493
|
-
async listSources(): Promise<SourceRecord[]> {
|
|
494
|
-
await this.ensureLoaded()
|
|
495
|
-
return [...this.sourcesByRepo.values()].sort((a, b) => a.repo.localeCompare(b.repo))
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/** One source record by repo (undefined when absent). */
|
|
499
|
-
async getSource(repo: string): Promise<SourceRecord | undefined> {
|
|
500
|
-
await this.ensureLoaded()
|
|
501
|
-
return this.sourcesByRepo.get(repo)
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/** The source record that tracks a skill name (undefined when untracked). */
|
|
505
|
-
async getSourceForSkill(name: string): Promise<SourceRecord | undefined> {
|
|
506
|
-
await this.ensureLoaded()
|
|
507
|
-
for (const source of this.sourcesByRepo.values()) {
|
|
508
|
-
if (source.skills.includes(name)) return source
|
|
509
|
-
}
|
|
510
|
-
return undefined
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/** Remove a source record entirely (no-op when absent). */
|
|
514
|
-
async deleteSource(repo: string): Promise<void> {
|
|
515
|
-
await this.ensureLoaded()
|
|
516
|
-
if (!this.sourcesByRepo.delete(repo)) return
|
|
517
|
-
await this.persist()
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/**
|
|
521
|
-
* Upsert one skill into a source record. When the repo has no record yet a
|
|
522
|
-
* new one is created (root + commit snapshot from the caller).
|
|
523
|
-
*/
|
|
524
|
-
async addSourceSkill(repo: string, root: string, commitSha: string, ref: string | undefined, skillName: string): Promise<void> {
|
|
525
|
-
await this.ensureLoaded()
|
|
526
|
-
const existing = this.sourcesByRepo.get(repo)
|
|
527
|
-
if (existing === undefined) {
|
|
528
|
-
this.sourcesByRepo.set(repo, {
|
|
529
|
-
repo,
|
|
530
|
-
...(ref !== undefined && ref !== '' ? { ref } : {}),
|
|
531
|
-
root,
|
|
532
|
-
commitSha,
|
|
533
|
-
skills: [skillName],
|
|
534
|
-
})
|
|
535
|
-
} else {
|
|
536
|
-
const skills = existing.skills.includes(skillName) ? existing.skills : [...existing.skills, skillName].sort((a, b) => a.localeCompare(b))
|
|
537
|
-
this.sourcesByRepo.set(repo, { ...existing, skills })
|
|
538
|
-
}
|
|
539
|
-
await this.persist()
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/** Replace a source's skill list (used after sync/confirm-delete). */
|
|
543
|
-
async setSourceSkills(repo: string, skills: readonly string[]): Promise<SourceRecord | undefined> {
|
|
544
|
-
await this.ensureLoaded()
|
|
545
|
-
const existing = this.sourcesByRepo.get(repo)
|
|
546
|
-
if (existing === undefined) return undefined
|
|
547
|
-
const names = [...new Set(skills.filter((n) => n.trim() !== ''))].sort((a, b) => a.localeCompare(b))
|
|
548
|
-
if (names.length === 0) {
|
|
549
|
-
this.sourcesByRepo.delete(repo)
|
|
550
|
-
} else {
|
|
551
|
-
this.sourcesByRepo.set(repo, { ...existing, skills: names })
|
|
552
|
-
}
|
|
553
|
-
await this.persist()
|
|
554
|
-
return this.sourcesByRepo.get(repo)
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/** Remove one skill from every source record (used when the skill is deleted). */
|
|
558
|
-
async removeSkillFromSources(name: string): Promise<void> {
|
|
559
|
-
await this.ensureLoaded()
|
|
560
|
-
let changed = false
|
|
561
|
-
for (const [repo, source] of this.sourcesByRepo) {
|
|
562
|
-
if (!source.skills.includes(name)) continue
|
|
563
|
-
const skills = source.skills.filter((n) => n !== name)
|
|
564
|
-
if (skills.length === 0) this.sourcesByRepo.delete(repo)
|
|
565
|
-
else this.sourcesByRepo.set(repo, { ...source, skills })
|
|
566
|
-
changed = true
|
|
567
|
-
}
|
|
568
|
-
if (changed) await this.persist()
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/** Update a source's commit snapshot. */
|
|
572
|
-
async setSourceCommit(repo: string, commitSha: string): Promise<void> {
|
|
573
|
-
await this.ensureLoaded()
|
|
574
|
-
const existing = this.sourcesByRepo.get(repo)
|
|
575
|
-
if (existing === undefined) return
|
|
576
|
-
this.sourcesByRepo.set(repo, { ...existing, commitSha })
|
|
577
|
-
await this.persist()
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/** Update a source's pinned ref (release tag / branch) when the market syncs. */
|
|
581
|
-
async setSourceRef(repo: string, ref: string): Promise<void> {
|
|
582
|
-
await this.ensureLoaded()
|
|
583
|
-
const existing = this.sourcesByRepo.get(repo)
|
|
584
|
-
if (existing === undefined || ref.trim() === '') return
|
|
585
|
-
this.sourcesByRepo.set(repo, { ...existing, ref: ref.trim() })
|
|
586
|
-
await this.persist()
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Merge per-path manifest entries into a source (incremental imports).
|
|
591
|
-
* When `dir` is given, every baseline path under that skill directory is
|
|
592
|
-
* dropped first, so files the upstream removed never linger in the
|
|
593
|
-
* baseline and skew later update diffs.
|
|
594
|
-
*/
|
|
595
|
-
async mergeSourceManifest(repo: string, manifest: Record<string, number>, dir?: string): Promise<void> {
|
|
596
|
-
await this.ensureLoaded()
|
|
597
|
-
const existing = this.sourcesByRepo.get(repo)
|
|
598
|
-
if (existing === undefined || Object.keys(manifest).length === 0) return
|
|
599
|
-
const base: Record<string, number> = { ...(existing.manifest ?? {}) }
|
|
600
|
-
if (dir !== undefined && dir !== '') {
|
|
601
|
-
const prefix = dir + '/'
|
|
602
|
-
for (const path of Object.keys(base)) {
|
|
603
|
-
if (path.startsWith(prefix)) delete base[path]
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
this.sourcesByRepo.set(repo, { ...existing, manifest: { ...base, ...manifest } })
|
|
607
|
-
await this.persist()
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/** skillName → collection name for every recorded origin (derived from sources). */
|
|
611
|
-
async listOrigins(): Promise<Record<string, string>> {
|
|
612
|
-
await this.ensureLoaded()
|
|
613
|
-
const origins: Record<string, string> = {}
|
|
614
|
-
for (const source of this.sourcesByRepo.values()) {
|
|
615
|
-
for (const name of source.skills) origins[name] = source.repo
|
|
616
|
-
}
|
|
617
|
-
return origins
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// ------------------------------------------------------ market sources
|
|
621
|
-
|
|
622
|
-
/** The user's market sources, in addition order. */
|
|
623
|
-
async listMarketSources(): Promise<MarketSourceRecord[]> {
|
|
624
|
-
await this.ensureLoaded()
|
|
625
|
-
return [...this.marketSources]
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/** One market source by repo (undefined when absent). */
|
|
629
|
-
async getMarketSource(repo: string): Promise<MarketSourceRecord | undefined> {
|
|
630
|
-
await this.ensureLoaded()
|
|
631
|
-
return this.marketSources.find((entry) => entry.repo === repo)
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
/** Add a repo (deduplicated), optionally with a pinned ref. Returns the fresh list. */
|
|
635
|
-
async addMarketSource(repo: string, ref?: string): Promise<MarketSourceRecord[]> {
|
|
636
|
-
await this.ensureLoaded()
|
|
637
|
-
const existing = this.marketSources.find((entry) => entry.repo === repo)
|
|
638
|
-
if (existing === undefined) {
|
|
639
|
-
this.marketSources.push(ref !== undefined && ref !== '' ? { repo, ref } : { repo })
|
|
640
|
-
} else if (ref !== undefined && ref !== '' && existing.ref !== ref) {
|
|
641
|
-
existing.ref = ref
|
|
642
|
-
}
|
|
643
|
-
await this.persist()
|
|
644
|
-
return [...this.marketSources]
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/** Remove a repo (no-op when absent). Returns the fresh list. */
|
|
648
|
-
async removeMarketSource(repo: string): Promise<MarketSourceRecord[]> {
|
|
649
|
-
await this.ensureLoaded()
|
|
650
|
-
const index = this.marketSources.findIndex((entry) => entry.repo === repo)
|
|
651
|
-
if (index === -1) return [...this.marketSources]
|
|
652
|
-
this.marketSources.splice(index, 1)
|
|
653
|
-
await this.persist()
|
|
654
|
-
return [...this.marketSources]
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
/** Pin a market source to an explicit ref (branch/tag). Returns the record. */
|
|
658
|
-
async setMarketSourceRef(repo: string, ref: string): Promise<MarketSourceRecord | undefined> {
|
|
659
|
-
await this.ensureLoaded()
|
|
660
|
-
const entry = this.marketSources.find((item) => item.repo === repo)
|
|
661
|
-
if (entry === undefined || ref.trim() === '') return entry
|
|
662
|
-
entry.ref = ref.trim()
|
|
663
|
-
delete entry.commitSha
|
|
664
|
-
await this.persist()
|
|
665
|
-
return entry
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/** Record the commit a market source's pinned ref resolved to (update baseline). */
|
|
669
|
-
async setMarketSourceCommit(repo: string, commitSha: string): Promise<void> {
|
|
670
|
-
await this.ensureLoaded()
|
|
671
|
-
const entry = this.marketSources.find((item) => item.repo === repo)
|
|
672
|
-
if (entry === undefined || commitSha === '') return
|
|
673
|
-
entry.commitSha = commitSha
|
|
674
|
-
await this.persist()
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// ---------------------------------------------------------------- trash
|
|
678
|
-
|
|
679
|
-
/** All trashed skills, newest first. */
|
|
680
|
-
async listTrash(): Promise<TrashEntry[]> {
|
|
681
|
-
await this.ensureLoaded()
|
|
682
|
-
return [...this.trashByName.values()].sort((a, b) => b.movedAt - a.movedAt)
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/** One trash entry by skill name (undefined when absent). */
|
|
686
|
-
async getTrash(name: string): Promise<TrashEntry | undefined> {
|
|
687
|
-
await this.ensureLoaded()
|
|
688
|
-
return this.trashByName.get(name)
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
/** Record a trashed skill. */
|
|
692
|
-
async addTrash(entry: TrashEntry): Promise<void> {
|
|
693
|
-
await this.ensureLoaded()
|
|
694
|
-
this.trashByName.set(entry.name, entry)
|
|
695
|
-
await this.persist()
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
/** Remove a trash record (after restore). */
|
|
699
|
-
async removeTrash(name: string): Promise<void> {
|
|
700
|
-
await this.ensureLoaded()
|
|
701
|
-
if (!this.trashByName.delete(name)) return
|
|
702
|
-
await this.persist()
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
/** The persisted usage-statistics checkpoint (undefined until first saved). */
|
|
706
|
-
async getSkillStatsState(): Promise<SkillStatsCheckpoint | undefined> {
|
|
707
|
-
await this.ensureLoaded()
|
|
708
|
-
return this.skillStats !== undefined
|
|
709
|
-
? { ...this.skillStats, frozenSessions: { ...this.skillStats.frozenSessions } }
|
|
710
|
-
: undefined
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
/** Persist a usage-statistics checkpoint (written at most ~once a day, on full reconciliations). */
|
|
714
|
-
async saveSkillStatsState(state: SkillStatsCheckpoint): Promise<void> {
|
|
715
|
-
await this.ensureLoaded()
|
|
716
|
-
this.skillStats = {
|
|
717
|
-
windowDays: state.windowDays,
|
|
718
|
-
frozenBefore: state.frozenBefore,
|
|
719
|
-
frozenSessions: { ...state.frozenSessions },
|
|
720
|
-
lastFullReconcile: state.lastFullReconcile,
|
|
721
|
-
}
|
|
722
|
-
await this.persist()
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
private persist(): Promise<void> {
|
|
726
|
-
// The payload is built inside the queued step (not here), so every
|
|
727
|
-
// concurrent mutation made before a write actually lands is included in
|
|
728
|
-
// the final file instead of being rolled back by an older snapshot.
|
|
729
|
-
const run = this.writeChain.then(async () => {
|
|
730
|
-
const payload: StoreFile = {
|
|
731
|
-
version: STORE_VERSION,
|
|
732
|
-
disabled: [...this.entries.values()],
|
|
733
|
-
config: this.config,
|
|
734
|
-
...(this.tagsById.size > 0 ? { tags: [...this.tagsById.values()] } : {}),
|
|
735
|
-
...(this.sourcesByRepo.size > 0 ? { sources: [...this.sourcesByRepo.values()] } : {}),
|
|
736
|
-
...(this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {}),
|
|
737
|
-
...(this.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {}),
|
|
738
|
-
...(this.skillStats !== undefined ? { skillStats: this.skillStats } : {}),
|
|
739
|
-
...(this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {}),
|
|
740
|
-
...(this.sourceGroupOrder.length > 0 ? { sourceGroupOrder: [...this.sourceGroupOrder] } : {}),
|
|
741
|
-
}
|
|
742
|
-
const tmp = this.file + '.tmp'
|
|
743
|
-
await mkdir(dirname(this.file), { recursive: true })
|
|
744
|
-
await writeFile(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8')
|
|
745
|
-
await rename(tmp, this.file)
|
|
746
|
-
})
|
|
747
|
-
// Keep the chain alive on failure; callers still observe the rejection.
|
|
748
|
-
this.writeChain = run.catch(() => {})
|
|
749
|
-
return run
|
|
750
|
-
}
|
|
751
|
-
}
|
|
18
|
+
export { DEFAULT_SCENE_NAME, STORE_VERSION, dshHome, statePath, type StoreFile } from './store/paths.ts'
|
|
19
|
+
export { StoreError } from './store/errors.ts'
|
|
20
|
+
export { migrateStore, hydrateMigratedState, type MigratedStore, type HydratedState } from './store/migrate.ts'
|
|
21
|
+
export { SkillHubStore } from './store/store.ts'
|