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/protocol/config.ts
CHANGED
|
@@ -89,29 +89,30 @@ export const HUB_CONFIG_DEFAULTS = {
|
|
|
89
89
|
} as const
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
92
|
+
* Fill a raw settings value into the effective hub config: schema defaults for
|
|
93
|
+
* anything missing, and the numeric stats knobs clamped to sane ranges
|
|
94
|
+
* (window ≥ 0, scan interval ≥ 1 minute).
|
|
95
|
+
*
|
|
96
|
+
* There is deliberately no "saved sidecar layer" parameter. Until dsh 0.1.7 the
|
|
97
|
+
* hub kept its own runtime overrides in the sidecar and merged them here; the
|
|
98
|
+
* settings namespace is now the only user layer (`updateConfig` writes through
|
|
99
|
+
* `ctx.settings.mutate`), so a merge against a sidecar layer could never
|
|
100
|
+
* observe anything but `{}` — see the git history of `resolveHubConfig`.
|
|
97
101
|
*/
|
|
98
|
-
export function resolveHubConfig(
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const githubToken = saved.githubToken !== undefined ? saved.githubToken : base.githubToken
|
|
102
|
-
const windowDays = clampNumber(saved.statsWindowDays ?? base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays
|
|
103
|
-
const scanMinutes = clampNumber(saved.statsScanMinutes ?? base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes
|
|
102
|
+
export function resolveHubConfig(base: Partial<HubConfig> = {}): HubConfig {
|
|
103
|
+
const windowDays = clampNumber(base.statsWindowDays, 0) ?? HUB_CONFIG_DEFAULTS.statsWindowDays
|
|
104
|
+
const scanMinutes = clampNumber(base.statsScanMinutes, 1) ?? HUB_CONFIG_DEFAULTS.statsScanMinutes
|
|
104
105
|
return {
|
|
105
|
-
enabled:
|
|
106
|
-
announceToAgent:
|
|
107
|
-
showUseCount:
|
|
108
|
-
showUseTime:
|
|
109
|
-
showGroupSummary:
|
|
106
|
+
enabled: base.enabled ?? HUB_CONFIG_DEFAULTS.enabled,
|
|
107
|
+
announceToAgent: base.announceToAgent ?? HUB_CONFIG_DEFAULTS.announceToAgent,
|
|
108
|
+
showUseCount: base.showUseCount ?? HUB_CONFIG_DEFAULTS.showUseCount,
|
|
109
|
+
showUseTime: base.showUseTime ?? HUB_CONFIG_DEFAULTS.showUseTime,
|
|
110
|
+
showGroupSummary: base.showGroupSummary ?? HUB_CONFIG_DEFAULTS.showGroupSummary,
|
|
110
111
|
statsWindowDays: windowDays,
|
|
111
112
|
statsScanMinutes: scanMinutes,
|
|
112
|
-
...(dotModelColor !== undefined ? { dotModelColor } : {}),
|
|
113
|
-
...(dotUserColor !== undefined ? { dotUserColor } : {}),
|
|
114
|
-
...(githubToken !== undefined ? { githubToken } : {}),
|
|
113
|
+
...(base.dotModelColor !== undefined ? { dotModelColor: base.dotModelColor } : {}),
|
|
114
|
+
...(base.dotUserColor !== undefined ? { dotUserColor: base.dotUserColor } : {}),
|
|
115
|
+
...(base.githubToken !== undefined ? { githubToken: base.githubToken } : {}),
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
@@ -148,7 +149,13 @@ export interface ConfigResponse {
|
|
|
148
149
|
githubTokenSet: boolean
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* POST /api/skill-hub/config — a partial patch; omitted fields keep their values.
|
|
154
|
+
*
|
|
155
|
+
* Only the fields the handler actually parses belong here: the stats knobs are
|
|
156
|
+
* owned by the settings namespace (`statsWindowDays` / `statsScanMinutes` are
|
|
157
|
+
* still part of {@link HubSettingsValue}), not by this route.
|
|
158
|
+
*/
|
|
152
159
|
export interface ConfigRequest {
|
|
153
160
|
/** Set the field; null clears the saved override so it re-inherits the default. */
|
|
154
161
|
enabled?: boolean | null
|
|
@@ -164,10 +171,6 @@ export interface ConfigRequest {
|
|
|
164
171
|
dotModelColor?: string | null
|
|
165
172
|
/** Set the dot color; null clears the saved override so it re-inherits the default. */
|
|
166
173
|
dotUserColor?: string | null
|
|
167
|
-
/** 统计滚动窗口天数(0 = 全部历史);null 清除覆盖回默认。 */
|
|
168
|
-
statsWindowDays?: number | null
|
|
169
|
-
/** 自动统计扫描间隔(分钟);null 清除覆盖回默认。 */
|
|
170
|
-
statsScanMinutes?: number | null
|
|
171
174
|
/** Set the GitHub token; null/empty clears it back to anonymous. */
|
|
172
175
|
githubToken?: string | null
|
|
173
176
|
}
|
package/src/protocol/market.ts
CHANGED
|
@@ -27,6 +27,18 @@ export interface MarketSourceResponse {
|
|
|
27
27
|
repos: MarketSourceRecord[]
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* POST /api/skill-hub/market/source — same list, plus the slug the host
|
|
32
|
+
* resolved from the input. Clients must scan *this* slug rather than assume
|
|
33
|
+
* the acted-on source sits at some position in `repos`: re-adding a repo the
|
|
34
|
+
* user already tracks only updates its `ref` in place and never reorders the
|
|
35
|
+
* list.
|
|
36
|
+
*/
|
|
37
|
+
export interface MarketSourceAddResponse extends MarketSourceResponse {
|
|
38
|
+
/** Normalized `owner/repo` the host recorded for the submitted input. */
|
|
39
|
+
repo: string
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
/** POST /api/skill-hub/market/source/ref — pin a market source to an explicit ref. */
|
|
31
43
|
export interface MarketSourceRefRequest {
|
|
32
44
|
repo: string
|
package/src/protocol/sources.ts
CHANGED
|
@@ -138,6 +138,11 @@ export interface SourceTrashClearResponse {
|
|
|
138
138
|
deleted: string[]
|
|
139
139
|
/** 未能删除的技能(保留在回收站中)。 */
|
|
140
140
|
failed: Array<{ name: string; error: string }>
|
|
141
|
+
/**
|
|
142
|
+
* `.trash` 里没有对应记录的残留目录(同名技能先后两次入站时,旧记录被覆盖,
|
|
143
|
+
* 目录却留了下来)。清空回收站时一并回收,这里上报它们的路径;没有则省略。
|
|
144
|
+
*/
|
|
145
|
+
orphans?: string[]
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
/** 项目级技能来源(它们有 workspace 归属,不属于「个人」组)。 */
|
package/src/protocol.ts
CHANGED
package/src/reconcile.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
-
import { reconcileDisabledSkills } from './reconcile.ts'
|
|
5
|
+
import { pruneStaleDisabledSkills, reconcileDisabledSkills } from './reconcile.ts'
|
|
6
6
|
import { SkillHubStore, statePath } from './store.ts'
|
|
7
7
|
|
|
8
8
|
describe('reconcileDisabledSkills', () => {
|
|
@@ -73,4 +73,77 @@ describe('reconcileDisabledSkills', () => {
|
|
|
73
73
|
expect(await reconcileDisabledSkills(store, home)).toEqual([])
|
|
74
74
|
expect(await store.listDisabled()).toHaveLength(1)
|
|
75
75
|
})
|
|
76
|
+
|
|
77
|
+
it('reconciles a root-level SKILL.md.disabled (a flat skill literally named SKILL)', async () => {
|
|
78
|
+
// scanRoot 会把根级 SKILL.md 文件当一个扁平技能,它的禁用对应物就不能被漏掉。
|
|
79
|
+
await writeFile(join(home, 'skills', 'SKILL.md.disabled'), '---\nname: root-flat\ndescription: Root flat\n---', 'utf8')
|
|
80
|
+
const added = await reconcileDisabledSkills(store, home)
|
|
81
|
+
expect(added.map((entry) => entry.name)).toEqual(['root-flat'])
|
|
82
|
+
expect((await store.getDisabled('root-flat'))?.path).toBe(join(home, 'skills', 'SKILL.md.disabled'))
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('pruneStaleDisabledSkills', () => {
|
|
87
|
+
let dir: string
|
|
88
|
+
let home: string
|
|
89
|
+
let agentsHome: string
|
|
90
|
+
let previousAgentsHome: string | undefined
|
|
91
|
+
let store: SkillHubStore
|
|
92
|
+
|
|
93
|
+
beforeEach(async () => {
|
|
94
|
+
dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-prune-'))
|
|
95
|
+
home = join(dir, 'home')
|
|
96
|
+
agentsHome = join(dir, 'agents')
|
|
97
|
+
await mkdir(join(home, 'skills'), { recursive: true })
|
|
98
|
+
await mkdir(join(agentsHome, 'skills'), { recursive: true })
|
|
99
|
+
previousAgentsHome = process.env.DSH_AGENTS_HOME
|
|
100
|
+
process.env.DSH_AGENTS_HOME = agentsHome
|
|
101
|
+
store = new SkillHubStore(statePath(home))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
afterEach(async () => {
|
|
105
|
+
if (previousAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME
|
|
106
|
+
else process.env.DSH_AGENTS_HOME = previousAgentsHome
|
|
107
|
+
await rm(dir, { recursive: true, force: true })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('drops a record whose file is gone and frees the name', async () => {
|
|
111
|
+
await store.addDisabled({ name: 'ghost', description: 'x', path: join(home, 'skills', 'ghost', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
|
|
112
|
+
const result = await pruneStaleDisabledSkills(store, home)
|
|
113
|
+
expect(result).toEqual({ repaired: [], removed: ['ghost'] })
|
|
114
|
+
expect(await store.listDisabled()).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('repoints a record to the surviving flat placeholder instead of dropping it', async () => {
|
|
118
|
+
// 记录指目录形态,磁盘上只剩扁平形态(导入让位过一次的典型残留)。
|
|
119
|
+
await writeFile(join(home, 'skills', 'code-review.md.disabled'), '---\nname: code-review\ndescription: stub\n---', 'utf8')
|
|
120
|
+
await store.addDisabled({ name: 'code-review', description: 'stub', path: join(home, 'skills', 'code-review', 'SKILL.md.disabled'), root: 'user-dsh', disabledAt: 1 })
|
|
121
|
+
const result = await pruneStaleDisabledSkills(store, home)
|
|
122
|
+
expect(result.removed).toEqual([])
|
|
123
|
+
expect(result.repaired.map((entry) => entry.path)).toEqual([join(home, 'skills', 'code-review.md.disabled')])
|
|
124
|
+
expect((await store.getDisabled('code-review'))?.path).toBe(join(home, 'skills', 'code-review.md.disabled'))
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('repoints to the bundle placeholder when that is the surviving form', async () => {
|
|
128
|
+
await mkdir(join(home, 'skills', 'paused'))
|
|
129
|
+
await writeFile(join(home, 'skills', 'paused', 'SKILL.md.disabled'), '---\nname: paused\ndescription: b\n---', 'utf8')
|
|
130
|
+
await store.addDisabled({ name: 'paused', description: 'b', path: join(home, 'skills', 'paused.md.disabled'), root: 'user-dsh', disabledAt: 1 })
|
|
131
|
+
const result = await pruneStaleDisabledSkills(store, home)
|
|
132
|
+
expect(result.repaired.map((entry) => entry.path)).toEqual([join(home, 'skills', 'paused', 'SKILL.md.disabled')])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('leaves records whose file still exists alone, and is idempotent', async () => {
|
|
136
|
+
await writeFile(join(home, 'skills', 'alive.md.disabled'), '---\nname: alive\ndescription: x\n---', 'utf8')
|
|
137
|
+
await store.addDisabled({ name: 'alive', description: 'x', path: join(home, 'skills', 'alive.md.disabled'), root: 'user-dsh', disabledAt: 1 })
|
|
138
|
+
expect(await pruneStaleDisabledSkills(store, home)).toEqual({ repaired: [], removed: [] })
|
|
139
|
+
expect(await pruneStaleDisabledSkills(store, home)).toEqual({ repaired: [], removed: [] })
|
|
140
|
+
expect(await store.listDisabled()).toHaveLength(1)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('prunes records from the other writable root too', async () => {
|
|
144
|
+
await store.addDisabled({ name: 'agent-ghost', description: 'x', path: join(agentsHome, 'skills', 'agent-ghost', 'SKILL.md.disabled'), root: 'user-agents', disabledAt: 1 })
|
|
145
|
+
await writeFile(join(agentsHome, 'skills', 'agent-ghost.md.disabled'), '---\nname: agent-ghost\ndescription: x\n---', 'utf8')
|
|
146
|
+
const result = await pruneStaleDisabledSkills(store, home)
|
|
147
|
+
expect(result.repaired.map((entry) => entry.path)).toEqual([join(agentsHome, 'skills', 'agent-ghost.md.disabled')])
|
|
148
|
+
})
|
|
76
149
|
})
|
package/src/reconcile.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { readFile, stat } from 'node:fs/promises'
|
|
14
14
|
import type { DisabledSkill } from './protocol.ts'
|
|
15
|
-
import { parseFrontmatter, rootPath, scanDisabledRoot, WRITABLE_ROOTS } from './skillfs.ts'
|
|
15
|
+
import { disabledOccupancyIn, parseFrontmatter, rootPath, scanDisabledRoot, WRITABLE_ROOTS } from './skillfs.ts'
|
|
16
16
|
import { dshHome } from './store.ts'
|
|
17
17
|
|
|
18
18
|
/** Narrow store view used by the reconcile (SkillHubStore satisfies it). */
|
|
@@ -21,6 +21,11 @@ export interface DisabledReconcileStore {
|
|
|
21
21
|
addDisabled(entry: DisabledSkill): Promise<void>
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Narrow store view used by the reverse reconcile (adds record removal). */
|
|
25
|
+
export interface DisabledPruneStore extends DisabledReconcileStore {
|
|
26
|
+
removeDisabled(name: string): Promise<void>
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
/**
|
|
25
30
|
* Add sidecar disabled records for every `.disabled` discovery file that has
|
|
26
31
|
* none yet. Existing records (matched by path or by name) win, so this never
|
|
@@ -60,3 +65,47 @@ export async function reconcileDisabledSkills(store: DisabledReconcileStore, hom
|
|
|
60
65
|
}
|
|
61
66
|
return added
|
|
62
67
|
}
|
|
68
|
+
|
|
69
|
+
/** 反向对账的结果:修好路径的记录 + 被清掉的死记录。 */
|
|
70
|
+
export interface DisabledPruneResult {
|
|
71
|
+
/** 记录还在、但 path 指向的文件已不在:已改指向现存的 `.disabled` 文件。 */
|
|
72
|
+
repaired: DisabledSkill[]
|
|
73
|
+
/** 文件与禁用占位都已不存在:记录已删除,名字被释放。 */
|
|
74
|
+
removed: string[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 反向对账:`reconcileDisabledSkills` 只处理「磁盘有 `.disabled`、sidecar 没记录」。
|
|
79
|
+
* 反方向(sidecar 有记录、磁盘上那个文件已经不在了)以前永不清理,于是留下一条
|
|
80
|
+
* 干掉不掉的记录:`/toggle` 启用会 rename 失败,`/create` 被 409 挡住,`/skill/delete`
|
|
81
|
+
* 也 rename 失败 —— 名字被永久锁死(issue #16 类,实测 G5)。
|
|
82
|
+
*
|
|
83
|
+
* 两种处理,都不动启用态技能:
|
|
84
|
+
* - 名字在该 root 下仍有 `.disabled` 占位(目录包或扁平形态)→ 把记录指向真实文件;
|
|
85
|
+
* - 一个都没有 → 删记录,把名字还给用户。
|
|
86
|
+
*
|
|
87
|
+
* 只处理合法可写 root 的记录;root 值异常(历史损坏数据)时原样保留,不擅自删。
|
|
88
|
+
*/
|
|
89
|
+
export async function pruneStaleDisabledSkills(store: DisabledPruneStore, home = dshHome()): Promise<DisabledPruneResult> {
|
|
90
|
+
const result: DisabledPruneResult = { repaired: [], removed: [] }
|
|
91
|
+
for (const record of await store.listDisabled()) {
|
|
92
|
+
let exists = false
|
|
93
|
+
try {
|
|
94
|
+
exists = (await stat(record.path)).isFile()
|
|
95
|
+
} catch {
|
|
96
|
+
exists = false
|
|
97
|
+
}
|
|
98
|
+
if (exists) continue
|
|
99
|
+
if (!WRITABLE_ROOTS.includes(record.root)) continue
|
|
100
|
+
const occupancy = await disabledOccupancyIn(rootPath(record.root, home), record.name)
|
|
101
|
+
if (occupancy === undefined) {
|
|
102
|
+
await store.removeDisabled(record.name)
|
|
103
|
+
result.removed.push(record.name)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
const repaired: DisabledSkill = { ...record, path: occupancy }
|
|
107
|
+
await store.addDisabled(repaired)
|
|
108
|
+
result.repaired.push(repaired)
|
|
109
|
+
}
|
|
110
|
+
return result
|
|
111
|
+
}
|
|
@@ -66,21 +66,34 @@ function etagCacheSet(url: string, etag: string, json: unknown): void {
|
|
|
66
66
|
}
|
|
67
67
|
etagCache.set(url, { etag, json })
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* When the response shows an exhausted GitHub rate limit, the instant it
|
|
72
|
+
* resets; otherwise undefined. Shared by the repo fetch layer and the plugin's
|
|
73
|
+
* own update check so both agree on what counts as a rate-limit rejection
|
|
74
|
+
* (403/429 + `x-ratelimit-remaining: 0` + a parseable `x-ratelimit-reset`)
|
|
75
|
+
* instead of each re-deriving it from the headers.
|
|
76
|
+
*/
|
|
77
|
+
export function rateLimitResetAt(response: Response): Date | undefined {
|
|
78
|
+
if (response.status !== 403 && response.status !== 429) return undefined
|
|
79
|
+
if (response.headers.get('x-ratelimit-remaining') !== '0') return undefined
|
|
80
|
+
const reset = response.headers.get('x-ratelimit-reset')
|
|
81
|
+
if (reset === null) return undefined
|
|
82
|
+
const at = new Date(Number(reset) * 1000)
|
|
83
|
+
return Number.isNaN(at.getTime()) ? undefined : at
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
/**
|
|
70
87
|
* Build a RepoFetchError; when the response shows an exhausted rate limit,
|
|
71
88
|
* report the reset time instead of a bare HTTP status.
|
|
72
89
|
*/
|
|
73
90
|
export function fetchError(context: string, response: Response, fallbackStatus = 502): RepoFetchError {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
'github rate limit reached (anonymous quota exhausted); retry after ' + at.toLocaleTimeString() + ' or set GITHUB_TOKEN',
|
|
81
|
-
403,
|
|
82
|
-
)
|
|
83
|
-
}
|
|
91
|
+
const resetAt = rateLimitResetAt(response)
|
|
92
|
+
if (resetAt !== undefined) {
|
|
93
|
+
return new RepoFetchError(
|
|
94
|
+
'github rate limit reached (anonymous quota exhausted); retry after ' + resetAt.toLocaleTimeString() + ' or set GITHUB_TOKEN',
|
|
95
|
+
403,
|
|
96
|
+
)
|
|
84
97
|
}
|
|
85
98
|
return new RepoFetchError(context + ' (HTTP ' + response.status + ')', response.status === 404 ? 404 : fallbackStatus)
|
|
86
99
|
}
|
|
@@ -111,10 +124,9 @@ export async function fetchJson(url: string, fetchImpl: typeof fetch, context: s
|
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
/**
|
|
114
|
-
* Load
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* the exact same content a later download would fetch.
|
|
127
|
+
* Load one JSON document from the GitHub API, reusing an ETag when the URL was
|
|
128
|
+
* fetched before. `fetchImpl`/`context` exist so callers can stub the transport
|
|
129
|
+
* and shape the error text; the payload is whatever the endpoint returned.
|
|
118
130
|
*/
|
|
119
131
|
export async function fetchJsonCached(url: string, fetchImpl: typeof fetch, context: string): Promise<{ json: unknown; response: Response }> {
|
|
120
132
|
const cached = etagCache.get(url)
|
package/src/repo.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
-
import { NO_COMPRESSION, apiHeaders, collectRepoSkillFiles, diffRemoteSkills, discoverRepoEntries, downloadGitHubFile, downloadRepoSkill, getLatestReleaseTag, getRepoStats, listRepoBranches, normalizeRepoInput, originForRoot, relativeToSkillDir, repoSkillEntry, repoSlug, skillDirOf, skillFileAt, skillManifest } from './repo.ts'
|
|
5
|
+
import { NO_COMPRESSION, apiHeaders, collectRepoSkillFiles, diffRemoteSkills, discoverRepoEntries, downloadGitHubFile, downloadRepoSkill, getLatestReleaseTag, getRepoStats, listRepoBranches, normalizeRepoInput, originForRoot, rateLimitResetAt, relativeToSkillDir, repoSkillEntry, repoSlug, skillDirOf, skillFileAt, skillManifest } from './repo.ts'
|
|
6
6
|
import type { RepoTreeItem } from './repo.ts'
|
|
7
7
|
import type { RepoSkillEntry } from './protocol.ts'
|
|
8
8
|
|
|
@@ -449,3 +449,22 @@ describe('GitHub 请求头', () => {
|
|
|
449
449
|
expect(apiHeaders().accept).toBe('application/vnd.github+json')
|
|
450
450
|
})
|
|
451
451
|
})
|
|
452
|
+
|
|
453
|
+
describe('rateLimitResetAt', () => {
|
|
454
|
+
const response = (status: number, headers: Record<string, string>): Response => new Response('', { status, headers })
|
|
455
|
+
const exhausted = { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1700000000' }
|
|
456
|
+
|
|
457
|
+
it('reports the reset instant for an exhausted 403 or 429', () => {
|
|
458
|
+
expect(rateLimitResetAt(response(403, exhausted))?.getTime()).toBe(1700000000 * 1000)
|
|
459
|
+
expect(rateLimitResetAt(response(429, exhausted))?.getTime()).toBe(1700000000 * 1000)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('is undefined for anything that is not an exhausted rate limit', () => {
|
|
463
|
+
expect(rateLimitResetAt(response(500, exhausted))).toBeUndefined()
|
|
464
|
+
expect(rateLimitResetAt(response(404, exhausted))).toBeUndefined()
|
|
465
|
+
expect(rateLimitResetAt(response(403, { 'x-ratelimit-remaining': '57', 'x-ratelimit-reset': '1700000000' }))).toBeUndefined()
|
|
466
|
+
expect(rateLimitResetAt(response(403, { 'x-ratelimit-remaining': '0' }))).toBeUndefined()
|
|
467
|
+
// 头部存在但不是数字:宁可退回普通 HTTP 文案,也不要报 "Invalid Date"。
|
|
468
|
+
expect(rateLimitResetAt(response(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': 'nope' }))).toBeUndefined()
|
|
469
|
+
})
|
|
470
|
+
})
|
package/src/repo.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
// 后向兼容:老 `from './repo.ts'` 写法继续可用,新代码可直引 github-client。
|
|
17
|
-
export { NO_COMPRESSION, RepoFetchError, apiHeaders, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, setGithubToken } from './repo/github-client.ts'
|
|
17
|
+
export { NO_COMPRESSION, RepoFetchError, apiHeaders, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, rateLimitResetAt, setGithubToken } from './repo/github-client.ts'
|
|
18
18
|
|
|
19
19
|
export type { RepoRef, RepoTreeItem, RepoFile } from './repo/types.ts'
|
|
20
20
|
|
|
@@ -199,10 +199,13 @@ export async function buildCatalog(deps: SkillHubRouteDeps, cwd?: string): Promi
|
|
|
199
199
|
if (iface !== undefined) applyInterface(row, iface)
|
|
200
200
|
return row
|
|
201
201
|
})
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
202
|
+
// 两个 root 的诊断扫描互不依赖:面板每 5 秒轮询一次 /catalog,串行会让响应
|
|
203
|
+
// 随技能数量线性变慢。扫描内部的逐条读盘也已是并发的(见 scanDiagnostics)。
|
|
204
|
+
const [userDshDiagnostics, userAgentsDiagnostics] = await Promise.all([
|
|
205
|
+
scanDiagnostics('user-dsh', home),
|
|
206
|
+
scanDiagnostics('user-agents', home),
|
|
207
|
+
])
|
|
208
|
+
const diagnostics = [...userDshDiagnostics, ...userAgentsDiagnostics]
|
|
206
209
|
return {
|
|
207
210
|
ok: true,
|
|
208
211
|
pluginVersion: CURRENT_VERSION,
|
package/src/routes/catalog.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* create / stats。从 routes.ts 原样搬出,handler 逻辑不变。
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
7
|
+
import { dirname } from 'node:path'
|
|
8
8
|
import type { SkillDefinition } from '@deepseek-ai/dsh-skill'
|
|
9
9
|
import { isSkillName } from '@deepseek-ai/dsh-skill'
|
|
10
10
|
import {
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type ToggleResponse,
|
|
19
19
|
type WritableRoot,
|
|
20
20
|
} from '../protocol.ts'
|
|
21
|
-
import { createSkill, disableSkill, enableSkill, parseFrontmatter, readSkillInterface, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
|
|
21
|
+
import { createSkill, disableSkill, disabledOccupancyIn, enableSkill, occupancyOf, parseFrontmatter, readSkillInterface, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
|
|
22
22
|
import { errorText } from '../error-text.ts'
|
|
23
23
|
import {
|
|
24
24
|
applyInterface,
|
|
@@ -146,23 +146,27 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
146
146
|
// 已禁用的技能不在 registry 中,resolve 会 404,这里单独处理:允许整组删除未开启的技能
|
|
147
147
|
let trashResult: { path: string; source: string } | null = null
|
|
148
148
|
if (!resolved.ok) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
await
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
149
|
+
const disabled = await deps.store.getDisabled(name)
|
|
150
|
+
if (disabled !== undefined) {
|
|
151
|
+
// sidecar 里的路径可能是外部写入/损坏的:移动前必须确认它落在
|
|
152
|
+
// 可写根内(正常路径由 disableSkill 保证,这里只拦异常数据)。
|
|
153
|
+
if (rootOfPath(disabled.path, homeOf(deps)) === undefined) {
|
|
154
|
+
writeError(res, 409, 'disabled skill path is outside the hub writable roots')
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
// 记录还在、文件已经不在(手工删了 / 从备份恢复过 sidecar):这条记录
|
|
158
|
+
// 只是残留,清掉即算删除完成,否则它会永久占住这个名字。
|
|
159
|
+
if (!await pathExists(disabled.path)) {
|
|
160
|
+
await deps.store.removeDisabled(name)
|
|
161
|
+
await deps.store.removeSkillFromTags(name)
|
|
162
|
+
deps.invalidate?.()
|
|
163
|
+
writeJson(res, 200, { ok: true, name, path: disabled.path } satisfies SkillDeleteResponse)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
// 禁用态:SKILL.md.disabled 或 *.md.disabled,直接将其所在技能整体移入回收站
|
|
167
|
+
// (trashSkill 认两种发现文件名,目录包按整目录搬)。
|
|
168
|
+
trashResult = await trashSkill(disabled.path)
|
|
169
|
+
} else {
|
|
166
170
|
writeError(res, resolved.status, resolved.error); return
|
|
167
171
|
}
|
|
168
172
|
}
|
|
@@ -201,13 +205,28 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
201
205
|
if (body.enabled === true) {
|
|
202
206
|
const record = await deps.store.getDisabled(name)
|
|
203
207
|
if (record === undefined) { writeError(res, 404, 'skill is not hub-disabled: ' + name); return }
|
|
208
|
+
// 记录里的路径可能已经失效(文件被手工删/搬走)。先在同 root 下找现存的
|
|
209
|
+
// 禁用占位:找得到就把记录指向它,找不到说明这条记录是残留 —— 清掉并把
|
|
210
|
+
// 名字还给用户,而不是让「启用」永远失败把名字锁死。
|
|
211
|
+
let disabledPath = record.path
|
|
212
|
+
if (!await pathExists(disabledPath)) {
|
|
213
|
+
// root 从记录路径反推,不直接信 sidecar 里的 root 字段(可能是损坏数据)。
|
|
214
|
+
const root = rootOfPath(disabledPath, homeOf(deps))
|
|
215
|
+
const occupancy = root === undefined ? undefined : await disabledOccupancyIn(rootPath(root, homeOf(deps)), name)
|
|
216
|
+
if (occupancy === undefined) { await deps.store.removeDisabled(name)
|
|
217
|
+
deps.invalidate?.()
|
|
218
|
+
writeError(res, 409, 'disabled record was stale (its file no longer exists); the record has been removed — retry now')
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
disabledPath = occupancy
|
|
222
|
+
}
|
|
204
223
|
try {
|
|
205
|
-
await enableSkill(
|
|
224
|
+
await enableSkill(disabledPath)
|
|
206
225
|
} catch (error) {
|
|
207
226
|
// The renamed file may have vanished (external cleanup); report
|
|
208
227
|
// precisely instead of a generic 500, like toggle-batch does.
|
|
209
228
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
210
|
-
writeError(res, 409, 'disabled skill file is missing on disk: ' +
|
|
229
|
+
writeError(res, 409, 'disabled skill file is missing on disk: ' + disabledPath)
|
|
211
230
|
return
|
|
212
231
|
}
|
|
213
232
|
throw error
|
|
@@ -280,12 +299,16 @@ export function catalogRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
280
299
|
const existing = await deps.skills.get(name)
|
|
281
300
|
if (existing !== undefined) { writeError(res, 409, 'skill name already exists: ' + name); return }
|
|
282
301
|
if (await deps.store.getDisabled(name) !== undefined) { writeError(res, 409, 'skill name is disabled: re-enable it from the disabled list first'); return }
|
|
283
|
-
//
|
|
284
|
-
// frontmatter
|
|
285
|
-
//
|
|
286
|
-
const
|
|
287
|
-
if (
|
|
288
|
-
writeError(res, 409, 'skill
|
|
302
|
+
// 建档前先看磁盘:三种占位都会让新目录与旧文件并存成同名两形态 ——
|
|
303
|
+
// 已启用的发现文件(frontmatter 坏了因此注册表看不见)、已禁用的占位
|
|
304
|
+
// (目录包或扁平)、以及没有 SKILL.md 的空壳目录(正是发现诊断在报的东西)。
|
|
305
|
+
const occupancy = await occupancyOf(rootPath(root, homeOf(deps)), name)
|
|
306
|
+
if (occupancy.enabled.length > 0) {
|
|
307
|
+
writeError(res, 409, 'a skill file already occupies this name on disk: ' + name + ' (check the discovery diagnostics)')
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
if (occupancy.disabled.length > 0 || occupancy.bareDir !== undefined) {
|
|
311
|
+
writeError(res, 409, 'name is occupied by a disabled placeholder or an empty directory on disk: ' + name + ' (check the discovery diagnostics)')
|
|
289
312
|
return
|
|
290
313
|
}
|
|
291
314
|
const path = await createSkill(root, name, readString(body, 'description'), homeOf(deps))
|
package/src/routes/deps.ts
CHANGED
|
@@ -43,10 +43,15 @@ export interface SkillHubRouteDeps {
|
|
|
43
43
|
|
|
44
44
|
/** The resolved hub config a route sees (the shared resolver fills defaults). */
|
|
45
45
|
export function configOf(deps: SkillHubRouteDeps): HubConfig {
|
|
46
|
-
return resolveHubConfig(
|
|
46
|
+
return resolveHubConfig(deps.config?.() ?? {})
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The raw saved config layer as persisted in the sidecar, reported for display
|
|
51
|
+
* only. Since dsh 0.1.7 the settings namespace is the one user layer, so this
|
|
52
|
+
* is legacy data: nothing writes it any more and the effective config above
|
|
53
|
+
* never merges it.
|
|
54
|
+
*/
|
|
50
55
|
export function savedOf(deps: SkillHubRouteDeps): Partial<HubConfig> {
|
|
51
56
|
return deps.saved?.() ?? {}
|
|
52
57
|
}
|
package/src/routes/market.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import {
|
|
8
8
|
SKILL_HUB_API,
|
|
9
9
|
type MarketCheckResponse,
|
|
10
|
+
type MarketSourceAddResponse,
|
|
10
11
|
type MarketSourceResponse,
|
|
11
12
|
type MarketSourcesResponse,
|
|
12
13
|
type MarketSourceVersionsResponse,
|
|
@@ -60,6 +61,13 @@ export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
60
61
|
}
|
|
61
62
|
try {
|
|
62
63
|
const latestTag = await getLatestReleaseTag(source.repo)
|
|
64
|
+
// Book the attempt as soon as the GitHub call has been spent, before
|
|
65
|
+
// the unpinned branch below can `continue`: otherwise an unpinned
|
|
66
|
+
// source never records a timestamp and the 5-minute throttle promised
|
|
67
|
+
// above never applies to it (every check re-hits /releases/latest).
|
|
68
|
+
// A call that throws is deliberately not booked, so a failing repo is
|
|
69
|
+
// retried on the next check — same as the source check in sources.ts.
|
|
70
|
+
lastMarketCheck.set(source.repo, now)
|
|
63
71
|
if (source.ref === undefined) {
|
|
64
72
|
// Not pinned yet: report the latest release so the UI can
|
|
65
73
|
// suggest pinning, but never claim an update.
|
|
@@ -67,7 +75,6 @@ export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
67
75
|
continue
|
|
68
76
|
}
|
|
69
77
|
const latest = await getLatestCommit(source.repo, source.ref)
|
|
70
|
-
lastMarketCheck.set(source.repo, now)
|
|
71
78
|
const commitMoved = source.commitSha !== undefined && latest.commitSha !== source.commitSha
|
|
72
79
|
const newRelease = latestTag !== undefined && latestTag !== source.ref
|
|
73
80
|
results.push({ ...base, updateAvailable: commitMoved || newRelease, commitSha: latest.commitSha, ...(newRelease ? { latestTag } : {}) })
|
|
@@ -99,7 +106,11 @@ export function marketRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
99
106
|
// An explicit @ref pins the source immediately (e.g. repo@v1.2.3);
|
|
100
107
|
// otherwise the first scan resolves latest-release / branch choice.
|
|
101
108
|
const ref = parsed.ref
|
|
102
|
-
|
|
109
|
+
const slug = repoSlug(parsed)
|
|
110
|
+
// Return the normalized slug explicitly: the client scans it right
|
|
111
|
+
// away, and `repos` order carries no "this one" signal (re-adding an
|
|
112
|
+
// existing repo only rewrites its ref in place).
|
|
113
|
+
writeJson(res, 200, { ok: true, repo: slug, repos: await deps.store.addMarketSource(slug, ref) } satisfies MarketSourceAddResponse)
|
|
103
114
|
},
|
|
104
115
|
},
|
|
105
116
|
{
|