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
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
* cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { mkdir
|
|
7
|
-
import { dirname, join } from 'node:path'
|
|
6
|
+
import { mkdir } from 'node:fs/promises'
|
|
8
7
|
import { randomUUID } from 'node:crypto'
|
|
9
8
|
import {
|
|
10
9
|
SKILL_HUB_API,
|
|
@@ -28,12 +27,11 @@ import {
|
|
|
28
27
|
skillManifest,
|
|
29
28
|
skillPathIn,
|
|
30
29
|
} from '../repo.ts'
|
|
31
|
-
import { rootPath } from '../skillfs.ts'
|
|
30
|
+
import { occupancyOf, rootPath } from '../skillfs.ts'
|
|
32
31
|
import { errorText } from '../error-text.ts'
|
|
33
32
|
import {
|
|
34
33
|
homeOf,
|
|
35
34
|
knownSkillNames,
|
|
36
|
-
pathExists,
|
|
37
35
|
queryParam,
|
|
38
36
|
readString,
|
|
39
37
|
readStrings,
|
|
@@ -42,32 +40,20 @@ import {
|
|
|
42
40
|
type RouteSpec,
|
|
43
41
|
type SkillHubRouteDeps,
|
|
44
42
|
} from './helpers.ts'
|
|
45
|
-
import { gcImportJobs, importJobs, type ImportJob } from './route-state.ts'
|
|
43
|
+
import { gcImportJobs, importJobs, retireNamePlaceholders, type ImportJob } from './route-state.ts'
|
|
46
44
|
|
|
47
45
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
46
|
+
* 导入让位(issue #16):磁盘上已有同名占位但**没有启用的技能**时,把占位移入
|
|
47
|
+
* 回收站,把名字让给即将导入的真技能。占位有两种 —— 已禁用的发现文件(目录包或
|
|
48
|
+
* 扁平,后者曾长期漏处理:`clearDisabledPlaceholder` 只认 `<name>` 这个目录,
|
|
49
|
+
* 扁平占位占的是 `<name>.md.disabled`,判据为假,分支根本进不去)和没有 `SKILL.md`
|
|
50
|
+
* 的空壳目录。判据统一走 `occupancyOf`,形态判断集中在 `retireNamePlaceholders`。
|
|
51
51
|
*
|
|
52
|
-
*
|
|
53
|
-
* 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
|
|
52
|
+
* 返回 false = 磁盘上有启用的同名技能(真货),一律不动,交回原本的「跳过」。
|
|
54
53
|
*/
|
|
55
54
|
async function clearDisabledPlaceholder(deps: SkillHubRouteDeps, targetRoot: string, name: string): Promise<boolean> {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const dir = join(targetRoot, name)
|
|
59
|
-
const sourceToTrash = record.path.endsWith('SKILL.md.disabled') ? dirname(record.path) : record.path
|
|
60
|
-
if (sourceToTrash !== dir) return false
|
|
61
|
-
// 目录里还有 SKILL.md 说明它其实是启用态的真实技能,不动。
|
|
62
|
-
if (await pathExists(join(dir, 'SKILL.md'))) return false
|
|
63
|
-
// 与 skill/delete 的回收站语义一致:同级 .trash/ 下按「名字-时间戳」落盘,可恢复。
|
|
64
|
-
const trashDir = join(targetRoot, '.trash')
|
|
65
|
-
await mkdir(trashDir, { recursive: true })
|
|
66
|
-
const target = join(trashDir, name + '-' + Date.now())
|
|
67
|
-
await rename(sourceToTrash, target)
|
|
68
|
-
await deps.store.addTrash({ name, path: target, movedAt: Date.now(), sourcePath: sourceToTrash })
|
|
69
|
-
await deps.store.removeDisabled(name)
|
|
70
|
-
return true
|
|
55
|
+
if ((await occupancyOf(targetRoot, name)).enabled.length > 0) return false
|
|
56
|
+
return (await retireNamePlaceholders(deps, targetRoot, name)) !== undefined
|
|
71
57
|
}
|
|
72
58
|
|
|
73
59
|
/** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
@@ -195,10 +181,11 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
195
181
|
job.done += 1
|
|
196
182
|
continue
|
|
197
183
|
}
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
184
|
+
// 磁盘上有同名占位。若它只是一个已禁用的占位(目录包或扁平)或一个空壳
|
|
185
|
+
// 目录,先把旧东西移入回收站再继续导入;有启用的同名技能时退回「跳过」
|
|
186
|
+
// (issue #16)。
|
|
187
|
+
const occupancy = await occupancyOf(targetRoot, entry.name)
|
|
188
|
+
if (occupancy.enabled.length > 0 || occupancy.disabled.length > 0 || occupancy.bareDir !== undefined) {
|
|
202
189
|
let cleared = false
|
|
203
190
|
try {
|
|
204
191
|
cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name)
|
|
@@ -211,7 +198,7 @@ export function repoImportRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
211
198
|
job.done += 1
|
|
212
199
|
continue
|
|
213
200
|
}
|
|
214
|
-
//
|
|
201
|
+
// 占位已经进了回收站(可恢复)。即使随后的下载失败也必须如实
|
|
215
202
|
// 上报,否则用户不知道自己的占位被挪走了。
|
|
216
203
|
job.replaced.push(entry.name)
|
|
217
204
|
}
|
|
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promis
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
-
import { IMPORT_JOB_MAX, gcImportJobs, importJobs,
|
|
5
|
+
import { IMPORT_JOB_MAX, gcImportJobs, importJobs, replaceInPlace, type ImportJob } from './route-state.ts'
|
|
6
6
|
|
|
7
7
|
/** One import job with sane defaults; override what the test cares about. */
|
|
8
8
|
function job(jobId: string, createdAt: number, status: ImportJob['status'] = 'done'): ImportJob {
|
|
@@ -50,7 +50,7 @@ describe('gcImportJobs', () => {
|
|
|
50
50
|
})
|
|
51
51
|
})
|
|
52
52
|
|
|
53
|
-
describe('
|
|
53
|
+
describe('replaceInPlace', () => {
|
|
54
54
|
let dir: string
|
|
55
55
|
|
|
56
56
|
afterEach(async () => { await rm(dir, { recursive: true, force: true }) })
|
|
@@ -60,7 +60,7 @@ describe('replaceSkillDir', () => {
|
|
|
60
60
|
const target = join(dir, 'skill')
|
|
61
61
|
await mkdir(target, { recursive: true })
|
|
62
62
|
await writeFile(join(target, 'old.txt'), 'old', 'utf8')
|
|
63
|
-
await
|
|
63
|
+
await replaceInPlace(target, async () => {
|
|
64
64
|
// 下载方负责重建目标目录(downloadRepoSkill 就是这么做的)。
|
|
65
65
|
await mkdir(target, { recursive: true })
|
|
66
66
|
await writeFile(join(target, 'new.txt'), 'new', 'utf8')
|
|
@@ -76,7 +76,7 @@ describe('replaceSkillDir', () => {
|
|
|
76
76
|
const target = join(dir, 'skill')
|
|
77
77
|
await mkdir(target, { recursive: true })
|
|
78
78
|
await writeFile(join(target, 'old.txt'), 'old', 'utf8')
|
|
79
|
-
await expect(
|
|
79
|
+
await expect(replaceInPlace(target, async () => { throw new Error('download failed') })).rejects.toThrow('download failed')
|
|
80
80
|
expect(await readFile(join(target, 'old.txt'), 'utf8')).toBe('old')
|
|
81
81
|
expect((await readdir(dir)).filter((name) => name.startsWith('.'))).toEqual([])
|
|
82
82
|
})
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { rm, rename } from 'node:fs/promises'
|
|
7
7
|
import { basename, dirname, join } from 'node:path'
|
|
8
8
|
import { errorText } from '../error-text.ts'
|
|
9
|
+
import { moveIntoTrash, occupancyOf, trashSourceOf } from '../skillfs.ts'
|
|
9
10
|
import type { SkillHubRouteDeps } from './helpers.ts'
|
|
10
11
|
|
|
11
12
|
/** Minimum interval between network update checks per source (GitHub rate limits). */
|
|
@@ -79,21 +80,55 @@ export function gcImportJobs(): void {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Replace whatever currently occupies a skill slot with a fresh download;
|
|
85
|
+
* restores the previous state on failure.
|
|
86
|
+
*
|
|
87
|
+
* `target` is the entry to move aside — a bundle **directory** or a flat
|
|
88
|
+
* `<name>.md` file, whichever the download is replacing (the rename/rm below
|
|
89
|
+
* are form-neutral). The backup is dot-prefixed (discovery skips dot entries),
|
|
90
|
+
* so even a failed cleanup can never register as a duplicate skill.
|
|
91
|
+
*/
|
|
92
|
+
export async function replaceInPlace(target: string, download: () => Promise<unknown>): Promise<void> {
|
|
93
|
+
const backup = join(dirname(target), '.' + basename(target) + '.sync-bak-' + Date.now())
|
|
94
|
+
await rename(target, backup)
|
|
89
95
|
try {
|
|
90
96
|
await download()
|
|
91
97
|
} catch (error) {
|
|
92
|
-
await rename(backup,
|
|
93
|
-
//
|
|
94
|
-
console.warn('[dsh-skill-hub] restoring ' +
|
|
98
|
+
await rename(backup, target).catch((rollbackError) => {
|
|
99
|
+
// 回滚失败会让技能只剩点前缀的备份:必须留痕。
|
|
100
|
+
console.warn('[dsh-skill-hub] restoring ' + target + ' from ' + backup + ' failed:', errorText(rollbackError))
|
|
95
101
|
})
|
|
96
102
|
throw error
|
|
97
103
|
}
|
|
98
104
|
await rm(backup, { recursive: true, force: true })
|
|
99
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 让位:把一个名字下的「非启用态占位」移入回收站并清掉它的 sidecar 禁用记录。
|
|
109
|
+
* 只动这两类,**启用的技能一律不碰**:
|
|
110
|
+
* - 禁用发现文件(`<name>/SKILL.md.disabled` 或 `<name>.md.disabled`,两种形态都认);
|
|
111
|
+
* - 同名目录存在但既无 `SKILL.md` 也无 `SKILL.md.disabled`(导入中断/手工删除的空壳)。
|
|
112
|
+
*
|
|
113
|
+
* 回收站按名字索引,所以多形态只会记下最后一条的恢复指针;其余的那份会在
|
|
114
|
+
* 「清空回收站」时被孤儿回收扫掉(见 sourceTrashClear)。
|
|
115
|
+
*
|
|
116
|
+
* @returns 被移入回收站的路径列表,没有可让位的东西时 undefined。
|
|
117
|
+
*/
|
|
118
|
+
export async function retireNamePlaceholders(deps: SkillHubRouteDeps, base: string, name: string): Promise<string[] | undefined> {
|
|
119
|
+
const occupancy = await occupancyOf(base, name)
|
|
120
|
+
// trashSourceOf 让「目录包禁用件」与「空壳目录」自然去重(同一个目录只搬一次)。
|
|
121
|
+
const sources = [...new Set([
|
|
122
|
+
...occupancy.disabled.map((path) => trashSourceOf(path)),
|
|
123
|
+
...(occupancy.bareDir !== undefined ? [occupancy.bareDir] : []),
|
|
124
|
+
])]
|
|
125
|
+
if (sources.length === 0) return undefined
|
|
126
|
+
const trashed: string[] = []
|
|
127
|
+
for (const source of sources) {
|
|
128
|
+
const moved = await moveIntoTrash(source)
|
|
129
|
+
await deps.store.addTrash({ name, path: moved.path, movedAt: Date.now(), sourcePath: moved.source })
|
|
130
|
+
trashed.push(moved.path)
|
|
131
|
+
}
|
|
132
|
+
await deps.store.removeDisabled(name)
|
|
133
|
+
return trashed
|
|
134
|
+
}
|
package/src/routes/sources.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* handler 逻辑不变。
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { mkdir, rename } from 'node:fs/promises'
|
|
7
|
+
import { mkdir, readdir, rename, rm } from 'node:fs/promises'
|
|
8
8
|
import { join } from 'node:path'
|
|
9
9
|
import { isSkillName } from '@deepseek-ai/dsh-skill'
|
|
10
10
|
import {
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
skillFileAt,
|
|
31
31
|
skillManifest,
|
|
32
32
|
} from '../repo.ts'
|
|
33
|
-
import { clearTrash, restoreSkill, rootOfPath, rootPath, trashSkill } from '../skillfs.ts'
|
|
33
|
+
import { clearTrash, occupancyOf, restoreSkill, rootOfPath, rootPath, trashSkill, trashSourceOf, WRITABLE_ROOTS } from '../skillfs.ts'
|
|
34
34
|
import { errorText } from '../error-text.ts'
|
|
35
35
|
import {
|
|
36
36
|
buildCollections,
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
type RouteSpec,
|
|
44
44
|
type SkillHubRouteDeps,
|
|
45
45
|
} from './helpers.ts'
|
|
46
|
-
import { MIN_CHECK_INTERVAL_MS, lastSourceCheck,
|
|
46
|
+
import { MIN_CHECK_INTERVAL_MS, lastSourceCheck, replaceInPlace, retireNamePlaceholders } from './route-state.ts'
|
|
47
47
|
|
|
48
48
|
/** 来源跟踪域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
49
49
|
export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
@@ -149,18 +149,28 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
149
149
|
const files = collectRepoSkillFiles(tree, entry.dir)
|
|
150
150
|
if (files.length === 0) { failed.push({ name, error: 'skill missing upstream' }); continue }
|
|
151
151
|
const targetDir = join(targetRoot, name)
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
152
|
+
const record = await deps.store.getDisabled(name)
|
|
153
|
+
const occupancy = await occupancyOf(targetRoot, name)
|
|
154
|
+
// 落盘位置永远是目录包,所以本地无论是目录包还是扁平文件(启用或禁用)
|
|
155
|
+
// 都得先让开,否则同名两形态会并存(issue #16 类)。
|
|
156
|
+
const download = () => downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot)
|
|
157
|
+
// 1) 禁用件与空壳让位(顺带清掉 sidecar 禁用记录)。
|
|
158
|
+
await retireNamePlaceholders(deps, targetRoot, name)
|
|
159
|
+
// 2) 启用件按「替换」语义处理:先挪到点前缀备份,下载失败则原样恢复。
|
|
160
|
+
const enabled = occupancy.enabled[0]
|
|
161
|
+
if (enabled !== undefined) {
|
|
162
|
+
await replaceInPlace(trashSourceOf(enabled), download)
|
|
157
163
|
} else {
|
|
158
|
-
await
|
|
164
|
+
await download()
|
|
159
165
|
}
|
|
160
|
-
if (
|
|
166
|
+
if (record !== undefined) {
|
|
161
167
|
// Preserve the disabled state: the fresh SKILL.md must not
|
|
162
|
-
// re-enter discovery.
|
|
163
|
-
|
|
168
|
+
// re-enter discovery. 记录必须**无条件写回** —— 上面的
|
|
169
|
+
// retireNamePlaceholders 已经把它删过一遍,只按「path 变了才写」
|
|
170
|
+
// 判断会让「同步一个已禁用的技能」丢掉禁用状态与记录(活体实测过)。
|
|
171
|
+
const disabledPath = join(targetDir, 'SKILL.md.disabled')
|
|
172
|
+
await rename(join(targetDir, 'SKILL.md'), disabledPath)
|
|
173
|
+
await deps.store.addDisabled({ ...record, path: disabledPath })
|
|
164
174
|
} else {
|
|
165
175
|
await deps.store.removeDisabled(name)
|
|
166
176
|
}
|
|
@@ -206,24 +216,29 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
206
216
|
failed.push({ name, error: 'skill is not tracked by this source' })
|
|
207
217
|
continue
|
|
208
218
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
219
|
+
// Resolve the on-disk occupation instead of assuming a `<root>/<name>`
|
|
220
|
+
// directory: the tracked skill can equally be a flat `<name>.md` file
|
|
221
|
+
// or a hub-disabled placeholder (issue #16 类).
|
|
222
|
+
const base = rootPath('user-dsh', home)
|
|
223
|
+
const occupancy = await occupancyOf(base, name)
|
|
224
|
+
const existing = occupancy.enabled[0] ?? occupancy.disabled[0] ?? occupancy.bareDir
|
|
225
|
+
if (existing === undefined) {
|
|
226
|
+
failed.push({ name, error: 'skill not found on disk' })
|
|
212
227
|
continue
|
|
213
228
|
}
|
|
214
|
-
if (
|
|
215
|
-
failed.push({ name, error: 'skill
|
|
229
|
+
if (rootOfPath(existing, home) === undefined) {
|
|
230
|
+
failed.push({ name, error: 'skill path is outside the hub writable roots' })
|
|
216
231
|
continue
|
|
217
232
|
}
|
|
218
233
|
try {
|
|
219
234
|
// 入回收站前快照来源与场景归属,恢复时挂回(见 sourceRestore)。
|
|
220
235
|
const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id)
|
|
221
|
-
const { path } = await trashSkill(
|
|
236
|
+
const { path, source: trashSource } = await trashSkill(existing)
|
|
222
237
|
await deps.store.addTrash({
|
|
223
238
|
name,
|
|
224
239
|
path,
|
|
225
240
|
movedAt: Date.now(),
|
|
226
|
-
sourcePath,
|
|
241
|
+
sourcePath: trashSource,
|
|
227
242
|
origin: { repo: source.repo, root: source.root, ...(source.ref !== undefined && source.ref !== '' ? { ref: source.ref } : {}), commitSha: source.commitSha },
|
|
228
243
|
...(tagIds.length > 0 ? { tagIds } : {}),
|
|
229
244
|
})
|
|
@@ -267,7 +282,7 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
267
282
|
for (const tagId of entry.tagIds ?? []) {
|
|
268
283
|
await deps.store.addSkillToTag(tagId, name)
|
|
269
284
|
}
|
|
270
|
-
await deps.store.removeTrash(
|
|
285
|
+
await deps.store.removeTrash(entry.path)
|
|
271
286
|
deps.invalidate?.()
|
|
272
287
|
writeJson(res, 200, { ok: true, name, path } satisfies SourceRestoreResponse)
|
|
273
288
|
},
|
|
@@ -281,6 +296,7 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
281
296
|
const home = homeOf(deps)
|
|
282
297
|
const entries = await deps.store.listTrash()
|
|
283
298
|
const deleted: string[] = []
|
|
299
|
+
const removedPaths: string[] = []
|
|
284
300
|
const failed: Array<{ name: string; error: string }> = []
|
|
285
301
|
for (const entry of entries) {
|
|
286
302
|
try {
|
|
@@ -288,14 +304,54 @@ export function sourceRoutes(deps: SkillHubRouteDeps): RouteSpec[] {
|
|
|
288
304
|
// A permanently deleted skill must not linger in user groups.
|
|
289
305
|
await deps.store.removeSkillFromTags(entry.name)
|
|
290
306
|
deleted.push(entry.name)
|
|
307
|
+
removedPaths.push(entry.path)
|
|
291
308
|
} catch (error) {
|
|
292
309
|
failed.push({ name: entry.name, error: errorText(error) })
|
|
293
310
|
}
|
|
294
311
|
}
|
|
295
|
-
for (const
|
|
296
|
-
|
|
297
|
-
|
|
312
|
+
for (const path of removedPaths) await deps.store.removeTrash(path)
|
|
313
|
+
// 回收站记录按名字索引:同名技能先后两次入站时旧记录被覆盖,但它的
|
|
314
|
+
// `<name>-<时间戳>` 目录还留在盘上,任何记录都指不到它 → 永久孤儿。
|
|
315
|
+
// 「清空回收站」正是它的回收时机,顺手扫掉并如实上报。
|
|
316
|
+
const orphans = await sweepOrphanTrash(deps, home, failed)
|
|
317
|
+
if (deleted.length > 0 || orphans.length > 0) deps.invalidate?.()
|
|
318
|
+
writeJson(res, 200, {
|
|
319
|
+
ok: true,
|
|
320
|
+
deleted,
|
|
321
|
+
failed,
|
|
322
|
+
...(orphans.length > 0 ? { orphans } : {}),
|
|
323
|
+
} satisfies SourceTrashClearResponse)
|
|
298
324
|
},
|
|
299
325
|
},
|
|
300
326
|
]
|
|
301
327
|
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 删除两个可写根 `.trash` 下不被任何回收站记录引用的残留目录。
|
|
331
|
+
* 只扫 `.trash` 的直接子项、跳过点开头项,失败项并入 `failed`(保留可重试)。
|
|
332
|
+
*/
|
|
333
|
+
async function sweepOrphanTrash(deps: SkillHubRouteDeps, home: string, failed: Array<{ name: string; error: string }>): Promise<string[]> {
|
|
334
|
+
const referenced = new Set((await deps.store.listTrash()).map((entry) => entry.path))
|
|
335
|
+
const orphans: string[] = []
|
|
336
|
+
for (const root of WRITABLE_ROOTS) {
|
|
337
|
+
const trashDir = join(rootPath(root, home), '.trash')
|
|
338
|
+
let names: string[]
|
|
339
|
+
try {
|
|
340
|
+
names = await readdir(trashDir)
|
|
341
|
+
} catch {
|
|
342
|
+
continue // 该根没有回收站目录
|
|
343
|
+
}
|
|
344
|
+
for (const name of names) {
|
|
345
|
+
if (name.startsWith('.')) continue
|
|
346
|
+
const full = join(trashDir, name)
|
|
347
|
+
if (referenced.has(full)) continue
|
|
348
|
+
try {
|
|
349
|
+
await rm(full, { recursive: true, force: true })
|
|
350
|
+
orphans.push(full)
|
|
351
|
+
} catch (error) {
|
|
352
|
+
failed.push({ name, error: errorText(error) })
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return orphans
|
|
357
|
+
}
|