dsh-skill-hub 0.3.19 → 0.3.21
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/CONTRIBUTING.md +1 -1
- package/README.md +4 -3
- package/README.zh.md +4 -3
- package/lib/client.js +10 -6
- package/lib/client.js.map +1 -1
- package/lib/index.js +428 -156
- package/lib/types/client/api.d.ts +3 -3
- package/lib/types/protocol/config.d.ts +17 -11
- package/lib/types/protocol/market.d.ts +11 -0
- package/lib/types/protocol/sources.d.ts +5 -0
- package/lib/types/protocol.d.ts +1 -1
- package/lib/types/reconcile.d.ts +24 -0
- package/lib/types/repo/github-client.d.ts +11 -4
- package/lib/types/repo.d.ts +1 -1
- package/lib/types/routes/deps.d.ts +6 -1
- package/lib/types/routes/route-state.d.ts +22 -2
- package/lib/types/skillfs/scan.d.ts +32 -5
- package/lib/types/skillfs.d.ts +34 -2
- package/lib/types/store/migrate.d.ts +1 -1
- package/lib/types/store/store.d.ts +10 -5
- package/package.json +21 -20
- package/src/client/api.ts +4 -3
- package/src/client/panel/hooks/useMarketFlow.ts +4 -2
- package/src/client/settings-card.tsx +2 -1
- package/src/client/settings-form.ts +6 -3
- package/src/index.ts +12 -2
- package/src/protocol/config.ts +27 -24
- package/src/protocol/market.ts +12 -0
- package/src/protocol/sources.ts +5 -0
- package/src/protocol.ts +1 -0
- package/src/reconcile.test.ts +74 -1
- package/src/reconcile.ts +50 -1
- package/src/repo/github-client.ts +26 -14
- package/src/repo.test.ts +20 -1
- package/src/repo.ts +1 -1
- package/src/routes/catalog-data.ts +7 -4
- package/src/routes/catalog.ts +51 -28
- package/src/routes/deps.ts +7 -2
- package/src/routes/market.ts +13 -2
- package/src/routes/repo-import.ts +17 -30
- package/src/routes/route-state.test.ts +4 -4
- package/src/routes/route-state.ts +45 -10
- package/src/routes/sources.ts +79 -23
- package/src/routes.test.ts +300 -0
- package/src/skillfs/scan.ts +119 -17
- package/src/skillfs.test.ts +64 -3
- package/src/skillfs.ts +51 -8
- package/src/store/migrate.ts +6 -4
- package/src/store/store.ts +21 -11
- package/src/store.test.ts +17 -1
- package/src/update.ts +6 -6
package/src/skillfs.ts
CHANGED
|
@@ -83,14 +83,18 @@ export async function enableSkill(disabledPath: string): Promise<string> {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* Move
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
86
|
+
* Move one already-resolved skill (a bundle directory or a flat .md file) into
|
|
87
|
+
* its root's trash as `<root>/.trash/<name>-<timestamp>`.
|
|
88
|
+
*
|
|
89
|
+
* The layout is the restore contract, not an implementation detail:
|
|
90
|
+
* `restoreSkill` renames the entry back and only proves the parent is `.trash`,
|
|
91
|
+
* and `clearTrash` deletes in place. Every producer of a trash entry must go
|
|
92
|
+
* through here — skill delete, the hub-disabled whole-group delete, and repo
|
|
93
|
+
* import's placeholder replacement all move skills for the same recovery flow.
|
|
94
|
+
*
|
|
90
95
|
* @returns the trashed path plus the original path that was moved.
|
|
91
96
|
*/
|
|
92
|
-
export async function
|
|
93
|
-
const source = basename(sourcePath) === 'SKILL.md' ? dirname(sourcePath) : sourcePath
|
|
97
|
+
export async function moveIntoTrash(source: string): Promise<{ path: string; source: string }> {
|
|
94
98
|
const trashDir = join(dirname(source), '.trash')
|
|
95
99
|
await mkdir(trashDir, { recursive: true })
|
|
96
100
|
const target = join(trashDir, basename(source) + '-' + Date.now())
|
|
@@ -98,6 +102,29 @@ export async function trashSkill(sourcePath: string): Promise<{ path: string; so
|
|
|
98
102
|
return { path: target, source }
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The filesystem entry behind a discovery file: a directory bundle is addressed
|
|
107
|
+
* by its `SKILL.md` (or `SKILL.md.disabled`) and moved as a whole directory, a
|
|
108
|
+
* flat `<name>.md` file is moved as itself. Single definition of that mapping —
|
|
109
|
+
* delete, restore, sync-replace and placeholder retirement must all agree on it,
|
|
110
|
+
* or one of them moves a bundle's discovery file and leaves an empty directory.
|
|
111
|
+
*/
|
|
112
|
+
export function trashSourceOf(discoveryPath: string): string {
|
|
113
|
+
const base = basename(discoveryPath)
|
|
114
|
+
return base === 'SKILL.md' || base === 'SKILL.md.disabled' ? dirname(discoveryPath) : discoveryPath
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Move a skill (directory bundle or flat .md file) into the root's trash.
|
|
119
|
+
* The skill stays recoverable: the source is renamed into
|
|
120
|
+
* <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
|
|
121
|
+
* SKILL.md (or SKILL.md.disabled) path and moved as a whole directory.
|
|
122
|
+
* @returns the trashed path plus the original path that was moved.
|
|
123
|
+
*/
|
|
124
|
+
export async function trashSkill(sourcePath: string): Promise<{ path: string; source: string }> {
|
|
125
|
+
return moveIntoTrash(trashSourceOf(sourcePath))
|
|
126
|
+
}
|
|
127
|
+
|
|
101
128
|
/** Restore a trashed skill (directory or flat file) to its original location. */
|
|
102
129
|
export async function restoreSkill(entry: TrashEntry, home = dshHome()): Promise<string> {
|
|
103
130
|
if (basename(dirname(entry.path)) !== '.trash') {
|
|
@@ -127,7 +154,15 @@ function skillFileKind(path: string): 'directory' | 'flat' | undefined {
|
|
|
127
154
|
return undefined
|
|
128
155
|
}
|
|
129
156
|
|
|
130
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Repair one file on disk when its frontmatter is auto-fixable. Returns the new text.
|
|
159
|
+
*
|
|
160
|
+
* The write is atomic (sibling temp file + rename), matching the sidecar's
|
|
161
|
+
* persist path: this rewrites a real skill file in place, and a crash midway
|
|
162
|
+
* through a plain write would leave a truncated SKILL.md — exactly the failure
|
|
163
|
+
* the store layer already guards against. The `.tmp` sibling is never picked
|
|
164
|
+
* up by discovery (only `SKILL.md` and `*.md` are scanned).
|
|
165
|
+
*/
|
|
131
166
|
export async function fixDiagnosticFile(path: string, home = dshHome()): Promise<string> {
|
|
132
167
|
const root = rootOfPath(path, home)
|
|
133
168
|
if (root === undefined) throw new TypeError('not a hub writable skill path: ' + path)
|
|
@@ -137,6 +172,14 @@ export async function fixDiagnosticFile(path: string, home = dshHome()): Promise
|
|
|
137
172
|
// Validate the repaired text parses as a legal skill (prevents writing a still-broken file).
|
|
138
173
|
const parsed = parseFrontmatter(repairedText)
|
|
139
174
|
if ('error' in parsed) throw new TypeError('repaired frontmatter still invalid: ' + parsed.error)
|
|
140
|
-
|
|
175
|
+
const tmp = path + '.tmp'
|
|
176
|
+
await writeFile(tmp, repairedText, 'utf8')
|
|
177
|
+
try {
|
|
178
|
+
await rename(tmp, path)
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// Never leave a stray temp file behind when the rename is what failed.
|
|
181
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
141
184
|
return path
|
|
142
185
|
}
|
package/src/store/migrate.ts
CHANGED
|
@@ -90,7 +90,7 @@ export interface HydratedState {
|
|
|
90
90
|
tagsById: Map<string, SkillTag>
|
|
91
91
|
sourcesByRepo: Map<string, SourceRecord>
|
|
92
92
|
marketSources: MarketSourceRecord[]
|
|
93
|
-
|
|
93
|
+
trashByPath: Map<string, TrashEntry>
|
|
94
94
|
skillStats: SkillStatsCheckpoint | undefined
|
|
95
95
|
marketStats: MarketStatsSnapshot | undefined
|
|
96
96
|
collectionOrder: string[]
|
|
@@ -179,14 +179,16 @@ export function hydrateMigratedState(migrated: MigratedStore): HydratedState {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
const
|
|
182
|
+
const trashByPath = new Map<string, TrashEntry>()
|
|
183
183
|
if (Array.isArray(migrated.trash)) {
|
|
184
184
|
for (const entry of migrated.trash as unknown[]) {
|
|
185
185
|
const item = entry as { name?: unknown; path?: unknown; movedAt?: unknown; sourcePath?: unknown } | null
|
|
186
186
|
if (item !== null && typeof item === 'object' && typeof item.name === 'string' && typeof item.path === 'string') {
|
|
187
187
|
const origin = (item as Record<string, unknown>).origin as { repo?: unknown; root?: unknown; ref?: unknown; commitSha?: unknown } | undefined
|
|
188
188
|
const tagIds = (item as Record<string, unknown>).tagIds
|
|
189
|
-
|
|
189
|
+
// 按 path 建索引:回收站里同名可以有多条(跨形态/先后两次入站),按名字
|
|
190
|
+
// 索引会让后一条静默覆盖前一条、把前一份目录留成谁都指不到的孤儿。
|
|
191
|
+
trashByPath.set(item.path, {
|
|
190
192
|
name: item.name,
|
|
191
193
|
path: item.path,
|
|
192
194
|
movedAt: typeof item.movedAt === 'number' ? item.movedAt : 0,
|
|
@@ -294,5 +296,5 @@ export function hydrateMigratedState(migrated: MigratedStore): HydratedState {
|
|
|
294
296
|
const collectionOrder = cleanStringList(migrated.collectionOrder)
|
|
295
297
|
const sourceGroupOrder = cleanStringList(migrated.sourceGroupOrder)
|
|
296
298
|
|
|
297
|
-
return { entries, config, tagsById, sourcesByRepo, marketSources,
|
|
299
|
+
return { entries, config, tagsById, sourcesByRepo, marketSources, trashByPath, skillStats, marketStats, collectionOrder, sourceGroupOrder }
|
|
298
300
|
}
|
package/src/store/store.ts
CHANGED
|
@@ -13,7 +13,7 @@ export class SkillHubStore {
|
|
|
13
13
|
private tagsById = new Map<string, SkillTag>()
|
|
14
14
|
private sourcesByRepo = new Map<string, SourceRecord>()
|
|
15
15
|
private marketSources: MarketSourceRecord[] = []
|
|
16
|
-
private
|
|
16
|
+
private trashByPath = new Map<string, TrashEntry>()
|
|
17
17
|
private skillStats: SkillStatsCheckpoint | undefined = undefined
|
|
18
18
|
private marketStats: MarketStatsSnapshot | undefined = undefined
|
|
19
19
|
private collectionOrder: string[] = []
|
|
@@ -41,7 +41,7 @@ export class SkillHubStore {
|
|
|
41
41
|
this.tagsById = state.tagsById
|
|
42
42
|
this.sourcesByRepo = state.sourcesByRepo
|
|
43
43
|
this.marketSources = state.marketSources
|
|
44
|
-
this.
|
|
44
|
+
this.trashByPath = state.trashByPath
|
|
45
45
|
this.skillStats = state.skillStats
|
|
46
46
|
this.marketStats = state.marketStats
|
|
47
47
|
this.collectionOrder = state.collectionOrder
|
|
@@ -444,26 +444,36 @@ export class SkillHubStore {
|
|
|
444
444
|
/** All trashed skills, newest first. */
|
|
445
445
|
async listTrash(): Promise<TrashEntry[]> {
|
|
446
446
|
await this.ensureLoaded()
|
|
447
|
-
return [...this.
|
|
447
|
+
return [...this.trashByPath.values()].sort((a, b) => b.movedAt - a.movedAt)
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
-
/** One trash entry by skill name
|
|
450
|
+
/** One trash entry by skill name — the newest when a name has several. */
|
|
451
451
|
async getTrash(name: string): Promise<TrashEntry | undefined> {
|
|
452
452
|
await this.ensureLoaded()
|
|
453
|
-
|
|
453
|
+
let newest: TrashEntry | undefined
|
|
454
|
+
for (const entry of this.trashByPath.values()) {
|
|
455
|
+
if (entry.name !== name) continue
|
|
456
|
+
if (newest === undefined || entry.movedAt > newest.movedAt) newest = entry
|
|
457
|
+
}
|
|
458
|
+
return newest
|
|
454
459
|
}
|
|
455
460
|
|
|
456
|
-
/**
|
|
461
|
+
/**
|
|
462
|
+
* Record a trashed skill, keyed by its trashed path. Keying by name used to
|
|
463
|
+
* silently drop the previous record when the same name landed in the trash
|
|
464
|
+
* twice (a re-delete, or the two on-disk discovery forms), orphaning its
|
|
465
|
+
* directory with nothing left pointing at it.
|
|
466
|
+
*/
|
|
457
467
|
async addTrash(entry: TrashEntry): Promise<void> {
|
|
458
468
|
await this.ensureLoaded()
|
|
459
|
-
this.
|
|
469
|
+
this.trashByPath.set(entry.path, entry)
|
|
460
470
|
await this.persist()
|
|
461
471
|
}
|
|
462
472
|
|
|
463
|
-
/** Remove a trash record (after restore). */
|
|
464
|
-
async removeTrash(
|
|
473
|
+
/** Remove a trash record by its trashed path (after restore or permanent clear). */
|
|
474
|
+
async removeTrash(path: string): Promise<void> {
|
|
465
475
|
await this.ensureLoaded()
|
|
466
|
-
if (!this.
|
|
476
|
+
if (!this.trashByPath.delete(path)) return
|
|
467
477
|
await this.persist()
|
|
468
478
|
}
|
|
469
479
|
|
|
@@ -521,7 +531,7 @@ export class SkillHubStore {
|
|
|
521
531
|
...(this.tagsById.size > 0 ? { tags: [...this.tagsById.values()] } : {}),
|
|
522
532
|
...(this.sourcesByRepo.size > 0 ? { sources: [...this.sourcesByRepo.values()] } : {}),
|
|
523
533
|
...(this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {}),
|
|
524
|
-
...(this.
|
|
534
|
+
...(this.trashByPath.size > 0 ? { trash: [...this.trashByPath.values()] } : {}),
|
|
525
535
|
...(this.skillStats !== undefined ? { skillStats: this.skillStats } : {}),
|
|
526
536
|
...(this.marketStats !== undefined ? { marketStats: this.marketStats } : {}),
|
|
527
537
|
...(this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {}),
|
package/src/store.test.ts
CHANGED
|
@@ -206,12 +206,28 @@ describe('SkillHubStore', () => {
|
|
|
206
206
|
await store.addTrash({ name: 'older', path: '/tmp/.trash/older-1', movedAt: 10 })
|
|
207
207
|
const trash = await store.listTrash()
|
|
208
208
|
expect(trash.map((entry) => entry.name)).toEqual(['gone-skill', 'older']) // newest first
|
|
209
|
-
await store.removeTrash('gone-skill')
|
|
209
|
+
await store.removeTrash('/tmp/.trash/gone-skill-123')
|
|
210
210
|
const reloaded = new SkillHubStore(file)
|
|
211
211
|
expect(await reloaded.listTrash()).toHaveLength(1)
|
|
212
212
|
expect(await reloaded.getTrash('older')).toMatchObject({ name: 'older' })
|
|
213
213
|
})
|
|
214
214
|
|
|
215
|
+
it('keeps several trash entries sharing one skill name (no silent overwrite)', async () => {
|
|
216
|
+
// 同一个名字先后入站两次(重复删除,或磁盘上两种发现形态各进一次):
|
|
217
|
+
// 按名字索引会让后一条覆盖前一条,前一份目录就成了谁都指不到的孤儿。
|
|
218
|
+
await store.addTrash({ name: 'dup', path: '/tmp/.trash/dup-1', movedAt: 1, sourcePath: '/tmp/skills/dup' })
|
|
219
|
+
await store.addTrash({ name: 'dup', path: '/tmp/.trash/dup.md.disabled-2', movedAt: 2, sourcePath: '/tmp/skills/dup.md.disabled' })
|
|
220
|
+
const reloaded = new SkillHubStore(file)
|
|
221
|
+
expect((await reloaded.listTrash()).map((entry) => entry.path).sort()).toEqual([
|
|
222
|
+
'/tmp/.trash/dup-1',
|
|
223
|
+
'/tmp/.trash/dup.md.disabled-2',
|
|
224
|
+
])
|
|
225
|
+
// getTrash 按名字取最新的一条(恢复入口用)。
|
|
226
|
+
expect(await reloaded.getTrash('dup')).toMatchObject({ path: '/tmp/.trash/dup.md.disabled-2' })
|
|
227
|
+
await reloaded.removeTrash('/tmp/.trash/dup.md.disabled-2')
|
|
228
|
+
expect(await reloaded.getTrash('dup')).toMatchObject({ path: '/tmp/.trash/dup-1' })
|
|
229
|
+
})
|
|
230
|
+
|
|
215
231
|
it('persists the trash source path', async () => {
|
|
216
232
|
await store.addTrash({ name: 'flat-gone', path: '/tmp/.trash/flat-gone.md-1', movedAt: 7, sourcePath: '/tmp/skills/flat-gone.md' })
|
|
217
233
|
const reloaded = new SkillHubStore(file)
|
package/src/update.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { createRequire } from 'node:module'
|
|
11
11
|
import { errorText } from './error-text.ts'
|
|
12
|
-
import { apiHeaders } from './repo.ts'
|
|
12
|
+
import { apiHeaders, rateLimitResetAt } from './repo.ts'
|
|
13
13
|
import type { UpdateCheckResponse } from './protocol.ts'
|
|
14
14
|
|
|
15
15
|
/** Repository checked for releases. Keep in sync with package.json. */
|
|
@@ -63,11 +63,11 @@ export async function checkLatestRelease(repo = UPDATE_REPO, fetchImpl: typeof f
|
|
|
63
63
|
|
|
64
64
|
if (!response.ok) {
|
|
65
65
|
// Rate-limit exhaustion gets a friendlier message with the reset time.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return failure('github rate limit reached; retry after ' +
|
|
66
|
+
// The header parsing lives in github-client so the repo layer and this
|
|
67
|
+
// check can never disagree on what counts as exhaustion.
|
|
68
|
+
const resetAt = rateLimitResetAt(response)
|
|
69
|
+
if (resetAt !== undefined) {
|
|
70
|
+
return failure('github rate limit reached; retry after ' + resetAt.toLocaleTimeString() + ' or set GITHUB_TOKEN')
|
|
71
71
|
}
|
|
72
72
|
return failure('GitHub HTTP ' + response.status)
|
|
73
73
|
}
|