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.
Files changed (51) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +4 -3
  3. package/README.zh.md +4 -3
  4. package/lib/client.js +10 -6
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +428 -156
  7. package/lib/types/client/api.d.ts +3 -3
  8. package/lib/types/protocol/config.d.ts +17 -11
  9. package/lib/types/protocol/market.d.ts +11 -0
  10. package/lib/types/protocol/sources.d.ts +5 -0
  11. package/lib/types/protocol.d.ts +1 -1
  12. package/lib/types/reconcile.d.ts +24 -0
  13. package/lib/types/repo/github-client.d.ts +11 -4
  14. package/lib/types/repo.d.ts +1 -1
  15. package/lib/types/routes/deps.d.ts +6 -1
  16. package/lib/types/routes/route-state.d.ts +22 -2
  17. package/lib/types/skillfs/scan.d.ts +32 -5
  18. package/lib/types/skillfs.d.ts +34 -2
  19. package/lib/types/store/migrate.d.ts +1 -1
  20. package/lib/types/store/store.d.ts +10 -5
  21. package/package.json +21 -20
  22. package/src/client/api.ts +4 -3
  23. package/src/client/panel/hooks/useMarketFlow.ts +4 -2
  24. package/src/client/settings-card.tsx +2 -1
  25. package/src/client/settings-form.ts +6 -3
  26. package/src/index.ts +12 -2
  27. package/src/protocol/config.ts +27 -24
  28. package/src/protocol/market.ts +12 -0
  29. package/src/protocol/sources.ts +5 -0
  30. package/src/protocol.ts +1 -0
  31. package/src/reconcile.test.ts +74 -1
  32. package/src/reconcile.ts +50 -1
  33. package/src/repo/github-client.ts +26 -14
  34. package/src/repo.test.ts +20 -1
  35. package/src/repo.ts +1 -1
  36. package/src/routes/catalog-data.ts +7 -4
  37. package/src/routes/catalog.ts +51 -28
  38. package/src/routes/deps.ts +7 -2
  39. package/src/routes/market.ts +13 -2
  40. package/src/routes/repo-import.ts +17 -30
  41. package/src/routes/route-state.test.ts +4 -4
  42. package/src/routes/route-state.ts +45 -10
  43. package/src/routes/sources.ts +79 -23
  44. package/src/routes.test.ts +300 -0
  45. package/src/skillfs/scan.ts +119 -17
  46. package/src/skillfs.test.ts +64 -3
  47. package/src/skillfs.ts +51 -8
  48. package/src/store/migrate.ts +6 -4
  49. package/src/store/store.ts +21 -11
  50. package/src/store.test.ts +17 -1
  51. 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 a skill (directory bundle or flat .md file) into the root's trash.
87
- * The skill stays recoverable: the source is renamed into
88
- * <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
89
- * SKILL.md path and moved as a whole directory.
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 trashSkill(sourcePath: string): Promise<{ path: string; source: string }> {
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
- /** Repair one file on disk when its frontmatter is auto-fixable. Returns the new text. */
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
- await writeFile(path, repairedText, 'utf8')
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
  }
@@ -90,7 +90,7 @@ export interface HydratedState {
90
90
  tagsById: Map<string, SkillTag>
91
91
  sourcesByRepo: Map<string, SourceRecord>
92
92
  marketSources: MarketSourceRecord[]
93
- trashByName: Map<string, TrashEntry>
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 trashByName = new Map<string, TrashEntry>()
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
- trashByName.set(item.name, {
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, trashByName, skillStats, marketStats, collectionOrder, sourceGroupOrder }
299
+ return { entries, config, tagsById, sourcesByRepo, marketSources, trashByPath, skillStats, marketStats, collectionOrder, sourceGroupOrder }
298
300
  }
@@ -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 trashByName = new Map<string, TrashEntry>()
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.trashByName = state.trashByName
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.trashByName.values()].sort((a, b) => b.movedAt - a.movedAt)
447
+ return [...this.trashByPath.values()].sort((a, b) => b.movedAt - a.movedAt)
448
448
  }
449
449
 
450
- /** One trash entry by skill name (undefined when absent). */
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
- return this.trashByName.get(name)
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
- /** Record a trashed skill. */
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.trashByName.set(entry.name, entry)
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(name: string): Promise<void> {
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.trashByName.delete(name)) return
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.trashByName.size > 0 ? { trash: [...this.trashByName.values()] } : {}),
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
- const remaining = response.headers.get('x-ratelimit-remaining')
67
- const reset = response.headers.get('x-ratelimit-reset')
68
- if ((response.status === 403 || response.status === 429) && remaining === '0' && reset !== null) {
69
- const at = new Date(Number(reset) * 1000)
70
- return failure('github rate limit reached; retry after ' + at.toLocaleTimeString() + ' or set GITHUB_TOKEN')
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
  }