dsh-skill-hub 0.3.20 → 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.map +1 -1
- package/lib/index.js +303 -69
- package/lib/types/protocol/sources.d.ts +5 -0
- package/lib/types/reconcile.d.ts +24 -0
- package/lib/types/routes/route-state.d.ts +22 -2
- package/lib/types/skillfs/scan.d.ts +26 -0
- package/lib/types/skillfs.d.ts +9 -1
- package/lib/types/store/migrate.d.ts +1 -1
- package/lib/types/store/store.d.ts +10 -5
- package/package.json +1 -1
- package/src/index.ts +11 -1
- package/src/protocol/sources.ts +5 -0
- package/src/reconcile.test.ts +74 -1
- package/src/reconcile.ts +50 -1
- package/src/routes/catalog.ts +50 -22
- package/src/routes/repo-import.ts +16 -26
- 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 +232 -0
- package/src/skillfs/scan.ts +91 -2
- package/src/skillfs.ts +14 -2
- package/src/store/migrate.ts +6 -4
- package/src/store/store.ts +21 -11
- package/src/store.test.ts +17 -1
package/lib/index.js
CHANGED
|
@@ -333,13 +333,13 @@ function hydrateMigratedState(migrated) {
|
|
|
333
333
|
...typeof item.commitSha === "string" && item.commitSha !== "" ? { commitSha: item.commitSha } : {}
|
|
334
334
|
});
|
|
335
335
|
}
|
|
336
|
-
const
|
|
336
|
+
const trashByPath = /* @__PURE__ */ new Map();
|
|
337
337
|
if (Array.isArray(migrated.trash)) for (const entry of migrated.trash) {
|
|
338
338
|
const item = entry;
|
|
339
339
|
if (item !== null && typeof item === "object" && typeof item.name === "string" && typeof item.path === "string") {
|
|
340
340
|
const origin = item.origin;
|
|
341
341
|
const tagIds = item.tagIds;
|
|
342
|
-
|
|
342
|
+
trashByPath.set(item.path, {
|
|
343
343
|
name: item.name,
|
|
344
344
|
path: item.path,
|
|
345
345
|
movedAt: typeof item.movedAt === "number" ? item.movedAt : 0,
|
|
@@ -430,7 +430,7 @@ function hydrateMigratedState(migrated) {
|
|
|
430
430
|
tagsById,
|
|
431
431
|
sourcesByRepo,
|
|
432
432
|
marketSources,
|
|
433
|
-
|
|
433
|
+
trashByPath,
|
|
434
434
|
skillStats,
|
|
435
435
|
marketStats,
|
|
436
436
|
collectionOrder,
|
|
@@ -691,7 +691,7 @@ var SkillHubStore = class {
|
|
|
691
691
|
tagsById = /* @__PURE__ */ new Map();
|
|
692
692
|
sourcesByRepo = /* @__PURE__ */ new Map();
|
|
693
693
|
marketSources = [];
|
|
694
|
-
|
|
694
|
+
trashByPath = /* @__PURE__ */ new Map();
|
|
695
695
|
skillStats = void 0;
|
|
696
696
|
marketStats = void 0;
|
|
697
697
|
collectionOrder = [];
|
|
@@ -717,7 +717,7 @@ var SkillHubStore = class {
|
|
|
717
717
|
this.tagsById = state.tagsById;
|
|
718
718
|
this.sourcesByRepo = state.sourcesByRepo;
|
|
719
719
|
this.marketSources = state.marketSources;
|
|
720
|
-
this.
|
|
720
|
+
this.trashByPath = state.trashByPath;
|
|
721
721
|
this.skillStats = state.skillStats;
|
|
722
722
|
this.marketStats = state.marketStats;
|
|
723
723
|
this.collectionOrder = state.collectionOrder;
|
|
@@ -1095,23 +1095,33 @@ var SkillHubStore = class {
|
|
|
1095
1095
|
/** All trashed skills, newest first. */
|
|
1096
1096
|
async listTrash() {
|
|
1097
1097
|
await this.ensureLoaded();
|
|
1098
|
-
return [...this.
|
|
1098
|
+
return [...this.trashByPath.values()].sort((a, b) => b.movedAt - a.movedAt);
|
|
1099
1099
|
}
|
|
1100
|
-
/** One trash entry by skill name
|
|
1100
|
+
/** One trash entry by skill name — the newest when a name has several. */
|
|
1101
1101
|
async getTrash(name) {
|
|
1102
1102
|
await this.ensureLoaded();
|
|
1103
|
-
|
|
1103
|
+
let newest;
|
|
1104
|
+
for (const entry of this.trashByPath.values()) {
|
|
1105
|
+
if (entry.name !== name) continue;
|
|
1106
|
+
if (newest === void 0 || entry.movedAt > newest.movedAt) newest = entry;
|
|
1107
|
+
}
|
|
1108
|
+
return newest;
|
|
1104
1109
|
}
|
|
1105
|
-
/**
|
|
1110
|
+
/**
|
|
1111
|
+
* Record a trashed skill, keyed by its trashed path. Keying by name used to
|
|
1112
|
+
* silently drop the previous record when the same name landed in the trash
|
|
1113
|
+
* twice (a re-delete, or the two on-disk discovery forms), orphaning its
|
|
1114
|
+
* directory with nothing left pointing at it.
|
|
1115
|
+
*/
|
|
1106
1116
|
async addTrash(entry) {
|
|
1107
1117
|
await this.ensureLoaded();
|
|
1108
|
-
this.
|
|
1118
|
+
this.trashByPath.set(entry.path, entry);
|
|
1109
1119
|
await this.persist();
|
|
1110
1120
|
}
|
|
1111
|
-
/** Remove a trash record (after restore). */
|
|
1112
|
-
async removeTrash(
|
|
1121
|
+
/** Remove a trash record by its trashed path (after restore or permanent clear). */
|
|
1122
|
+
async removeTrash(path) {
|
|
1113
1123
|
await this.ensureLoaded();
|
|
1114
|
-
if (!this.
|
|
1124
|
+
if (!this.trashByPath.delete(path)) return;
|
|
1115
1125
|
await this.persist();
|
|
1116
1126
|
}
|
|
1117
1127
|
/** The persisted usage-statistics checkpoint (undefined until first saved). */
|
|
@@ -1163,7 +1173,7 @@ var SkillHubStore = class {
|
|
|
1163
1173
|
...this.tagsById.size > 0 ? { tags: [...this.tagsById.values()] } : {},
|
|
1164
1174
|
...this.sourcesByRepo.size > 0 ? { sources: [...this.sourcesByRepo.values()] } : {},
|
|
1165
1175
|
...this.marketSources.length > 0 ? { marketSources: [...this.marketSources] } : {},
|
|
1166
|
-
...this.
|
|
1176
|
+
...this.trashByPath.size > 0 ? { trash: [...this.trashByPath.values()] } : {},
|
|
1167
1177
|
...this.skillStats !== void 0 ? { skillStats: this.skillStats } : {},
|
|
1168
1178
|
...this.marketStats !== void 0 ? { marketStats: this.marketStats } : {},
|
|
1169
1179
|
...this.collectionOrder.length > 0 ? { collectionOrder: [...this.collectionOrder] } : {},
|
|
@@ -1464,7 +1474,7 @@ async function scanDisabledRoot(base) {
|
|
|
1464
1474
|
try {
|
|
1465
1475
|
if ((await stat(candidate)).isFile()) paths.push(candidate);
|
|
1466
1476
|
} catch {}
|
|
1467
|
-
} else if (name.endsWith(".md.disabled")
|
|
1477
|
+
} else if (name.endsWith(".md.disabled")) paths.push(absolute);
|
|
1468
1478
|
}
|
|
1469
1479
|
return paths.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
1470
1480
|
}
|
|
@@ -1543,7 +1553,9 @@ async function findProjectRoot(cwd) {
|
|
|
1543
1553
|
* a defect.
|
|
1544
1554
|
*/
|
|
1545
1555
|
async function scanDiagnostics(root, home = dshHome()) {
|
|
1546
|
-
|
|
1556
|
+
const entries = await listSkillEntries(root, home);
|
|
1557
|
+
const discovered = [];
|
|
1558
|
+
const perEntry = await mapConcurrent(entries, 8, async (entry) => {
|
|
1547
1559
|
const found = [];
|
|
1548
1560
|
let text;
|
|
1549
1561
|
try {
|
|
@@ -1575,6 +1587,10 @@ async function scanDiagnostics(root, home = dshHome()) {
|
|
|
1575
1587
|
});
|
|
1576
1588
|
const { value } = parsed;
|
|
1577
1589
|
const pathName = entry.kind === "directory" ? basename(entry.directory) : basename(entry.path, ".md");
|
|
1590
|
+
discovered.push({
|
|
1591
|
+
path: entry.path,
|
|
1592
|
+
keys: [.../* @__PURE__ */ new Set([pathName, value.name])]
|
|
1593
|
+
});
|
|
1578
1594
|
if (value.name !== pathName) found.push({
|
|
1579
1595
|
path: entry.path,
|
|
1580
1596
|
root,
|
|
@@ -1586,7 +1602,65 @@ async function scanDiagnostics(root, home = dshHome()) {
|
|
|
1586
1602
|
reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill`
|
|
1587
1603
|
});
|
|
1588
1604
|
return found;
|
|
1589
|
-
})
|
|
1605
|
+
});
|
|
1606
|
+
const owners = /* @__PURE__ */ new Map();
|
|
1607
|
+
for (const item of discovered) for (const key of item.keys) {
|
|
1608
|
+
const list = owners.get(key);
|
|
1609
|
+
if (list === void 0) owners.set(key, [item.path]);
|
|
1610
|
+
else list.push(item.path);
|
|
1611
|
+
}
|
|
1612
|
+
const collisions = [];
|
|
1613
|
+
for (const item of discovered) {
|
|
1614
|
+
const shared = item.keys.filter((key) => (owners.get(key)?.length ?? 0) > 1);
|
|
1615
|
+
if (shared.length === 0) continue;
|
|
1616
|
+
collisions.push({
|
|
1617
|
+
path: item.path,
|
|
1618
|
+
root,
|
|
1619
|
+
reason: `another skill in this root registers the same name (${shared.map((key) => "\"" + key + "\"").join(", ")}); the registry keeps only one of them, so this one is unreachable — delete or rename one of the two`
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
return [...perEntry.flat(), ...collisions];
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* 某个名字在一个 root 下现存的禁用发现文件(两种形态都认),没有则 undefined。
|
|
1626
|
+
* 目录包形态优先(`<name>/SKILL.md.disabled`),其次扁平 `<name>.md.disabled`。
|
|
1627
|
+
* 单点定义「禁用态在磁盘上长什么样」,避免各处再拼一次路径(issue #16 类)。
|
|
1628
|
+
*/
|
|
1629
|
+
async function disabledOccupancyIn(base, name) {
|
|
1630
|
+
return (await occupancyOf(base, name)).disabled[0];
|
|
1631
|
+
}
|
|
1632
|
+
/** stat 一次给出形态;不存在或不可读时 undefined。 */
|
|
1633
|
+
async function statKind(path) {
|
|
1634
|
+
try {
|
|
1635
|
+
return (await stat(path)).isDirectory() ? "directory" : "file";
|
|
1636
|
+
} catch {
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
/** 读一个名字在某个 skills root 下的全部占位。只读,不修改磁盘。 */
|
|
1641
|
+
async function occupancyOf(base, name) {
|
|
1642
|
+
const bundleDir = join(base, name);
|
|
1643
|
+
const bundleSkill = join(bundleDir, "SKILL.md");
|
|
1644
|
+
const bundleDisabled = join(bundleDir, "SKILL.md.disabled");
|
|
1645
|
+
const flatSkill = join(base, name + ".md");
|
|
1646
|
+
const flatDisabled = join(base, name + ".md.disabled");
|
|
1647
|
+
const [bundleSkillKind, bundleDisabledKind, flatSkillKind, flatDisabledKind, bundleDirKind] = await Promise.all([
|
|
1648
|
+
statKind(bundleSkill),
|
|
1649
|
+
statKind(bundleDisabled),
|
|
1650
|
+
statKind(flatSkill),
|
|
1651
|
+
statKind(flatDisabled),
|
|
1652
|
+
statKind(bundleDir)
|
|
1653
|
+
]);
|
|
1654
|
+
const result = {
|
|
1655
|
+
enabled: [],
|
|
1656
|
+
disabled: []
|
|
1657
|
+
};
|
|
1658
|
+
if (bundleSkillKind === "file") result.enabled.push(bundleSkill);
|
|
1659
|
+
if (flatSkillKind === "file") result.enabled.push(flatSkill);
|
|
1660
|
+
if (bundleDisabledKind === "file") result.disabled.push(bundleDisabled);
|
|
1661
|
+
if (flatDisabledKind === "file") result.disabled.push(flatDisabled);
|
|
1662
|
+
if (bundleDirKind === "directory" && bundleSkillKind !== "file" && bundleDisabledKind !== "file") result.bareDir = bundleDir;
|
|
1663
|
+
return result;
|
|
1590
1664
|
}
|
|
1591
1665
|
//#endregion
|
|
1592
1666
|
//#region src/skillfs.ts
|
|
@@ -1668,14 +1742,25 @@ async function moveIntoTrash(source) {
|
|
|
1668
1742
|
};
|
|
1669
1743
|
}
|
|
1670
1744
|
/**
|
|
1745
|
+
* The filesystem entry behind a discovery file: a directory bundle is addressed
|
|
1746
|
+
* by its `SKILL.md` (or `SKILL.md.disabled`) and moved as a whole directory, a
|
|
1747
|
+
* flat `<name>.md` file is moved as itself. Single definition of that mapping —
|
|
1748
|
+
* delete, restore, sync-replace and placeholder retirement must all agree on it,
|
|
1749
|
+
* or one of them moves a bundle's discovery file and leaves an empty directory.
|
|
1750
|
+
*/
|
|
1751
|
+
function trashSourceOf(discoveryPath) {
|
|
1752
|
+
const base = basename(discoveryPath);
|
|
1753
|
+
return base === "SKILL.md" || base === "SKILL.md.disabled" ? dirname(discoveryPath) : discoveryPath;
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1671
1756
|
* Move a skill (directory bundle or flat .md file) into the root's trash.
|
|
1672
1757
|
* The skill stays recoverable: the source is renamed into
|
|
1673
1758
|
* <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
|
|
1674
|
-
* SKILL.md path and moved as a whole directory.
|
|
1759
|
+
* SKILL.md (or SKILL.md.disabled) path and moved as a whole directory.
|
|
1675
1760
|
* @returns the trashed path plus the original path that was moved.
|
|
1676
1761
|
*/
|
|
1677
1762
|
async function trashSkill(sourcePath) {
|
|
1678
|
-
return moveIntoTrash(
|
|
1763
|
+
return moveIntoTrash(trashSourceOf(sourcePath));
|
|
1679
1764
|
}
|
|
1680
1765
|
/** Restore a trashed skill (directory or flat file) to its original location. */
|
|
1681
1766
|
async function restoreSkill(entry, home = dshHome()) {
|
|
@@ -2928,7 +3013,18 @@ function catalogRoutes(deps) {
|
|
|
2928
3013
|
writeError(res, 409, "disabled skill path is outside the hub writable roots");
|
|
2929
3014
|
return;
|
|
2930
3015
|
}
|
|
2931
|
-
|
|
3016
|
+
if (!await pathExists(disabled.path)) {
|
|
3017
|
+
await deps.store.removeDisabled(name);
|
|
3018
|
+
await deps.store.removeSkillFromTags(name);
|
|
3019
|
+
deps.invalidate?.();
|
|
3020
|
+
writeJson(res, 200, {
|
|
3021
|
+
ok: true,
|
|
3022
|
+
name,
|
|
3023
|
+
path: disabled.path
|
|
3024
|
+
});
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
trashResult = await trashSkill(disabled.path);
|
|
2932
3028
|
} else {
|
|
2933
3029
|
writeError(res, resolved.status, resolved.error);
|
|
2934
3030
|
return;
|
|
@@ -2979,11 +3075,23 @@ function catalogRoutes(deps) {
|
|
|
2979
3075
|
writeError(res, 404, "skill is not hub-disabled: " + name);
|
|
2980
3076
|
return;
|
|
2981
3077
|
}
|
|
3078
|
+
let disabledPath = record.path;
|
|
3079
|
+
if (!await pathExists(disabledPath)) {
|
|
3080
|
+
const root = rootOfPath(disabledPath, homeOf(deps));
|
|
3081
|
+
const occupancy = root === void 0 ? void 0 : await disabledOccupancyIn(rootPath(root, homeOf(deps)), name);
|
|
3082
|
+
if (occupancy === void 0) {
|
|
3083
|
+
await deps.store.removeDisabled(name);
|
|
3084
|
+
deps.invalidate?.();
|
|
3085
|
+
writeError(res, 409, "disabled record was stale (its file no longer exists); the record has been removed — retry now");
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3088
|
+
disabledPath = occupancy;
|
|
3089
|
+
}
|
|
2982
3090
|
try {
|
|
2983
|
-
await enableSkill(
|
|
3091
|
+
await enableSkill(disabledPath);
|
|
2984
3092
|
} catch (error) {
|
|
2985
3093
|
if (error.code === "ENOENT") {
|
|
2986
|
-
writeError(res, 409, "disabled skill file is missing on disk: " +
|
|
3094
|
+
writeError(res, 409, "disabled skill file is missing on disk: " + disabledPath);
|
|
2987
3095
|
return;
|
|
2988
3096
|
}
|
|
2989
3097
|
throw error;
|
|
@@ -3087,8 +3195,13 @@ function catalogRoutes(deps) {
|
|
|
3087
3195
|
writeError(res, 409, "skill name is disabled: re-enable it from the disabled list first");
|
|
3088
3196
|
return;
|
|
3089
3197
|
}
|
|
3090
|
-
|
|
3091
|
-
|
|
3198
|
+
const occupancy = await occupancyOf(rootPath(root, homeOf(deps)), name);
|
|
3199
|
+
if (occupancy.enabled.length > 0) {
|
|
3200
|
+
writeError(res, 409, "a skill file already occupies this name on disk: " + name + " (check the discovery diagnostics)");
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
if (occupancy.disabled.length > 0 || occupancy.bareDir !== void 0) {
|
|
3204
|
+
writeError(res, 409, "name is occupied by a disabled placeholder or an empty directory on disk: " + name + " (check the discovery diagnostics)");
|
|
3092
3205
|
return;
|
|
3093
3206
|
}
|
|
3094
3207
|
const path = await createSkill(root, name, readString(body, "description"), homeOf(deps));
|
|
@@ -3295,15 +3408,23 @@ function gcImportJobs() {
|
|
|
3295
3408
|
for (let i = 0; i < finished.length && importJobs.size > 100; i++) importJobs.delete(finished[i][0]);
|
|
3296
3409
|
}
|
|
3297
3410
|
}
|
|
3298
|
-
/**
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3411
|
+
/**
|
|
3412
|
+
* Replace whatever currently occupies a skill slot with a fresh download;
|
|
3413
|
+
* restores the previous state on failure.
|
|
3414
|
+
*
|
|
3415
|
+
* `target` is the entry to move aside — a bundle **directory** or a flat
|
|
3416
|
+
* `<name>.md` file, whichever the download is replacing (the rename/rm below
|
|
3417
|
+
* are form-neutral). The backup is dot-prefixed (discovery skips dot entries),
|
|
3418
|
+
* so even a failed cleanup can never register as a duplicate skill.
|
|
3419
|
+
*/
|
|
3420
|
+
async function replaceInPlace(target, download) {
|
|
3421
|
+
const backup = join(dirname(target), "." + basename(target) + ".sync-bak-" + Date.now());
|
|
3422
|
+
await rename(target, backup);
|
|
3302
3423
|
try {
|
|
3303
3424
|
await download();
|
|
3304
3425
|
} catch (error) {
|
|
3305
|
-
await rename(backup,
|
|
3306
|
-
console.warn("[dsh-skill-hub] restoring " +
|
|
3426
|
+
await rename(backup, target).catch((rollbackError) => {
|
|
3427
|
+
console.warn("[dsh-skill-hub] restoring " + target + " from " + backup + " failed:", errorText(rollbackError));
|
|
3307
3428
|
});
|
|
3308
3429
|
throw error;
|
|
3309
3430
|
}
|
|
@@ -3312,6 +3433,35 @@ async function replaceSkillDir(targetDir, download) {
|
|
|
3312
3433
|
force: true
|
|
3313
3434
|
});
|
|
3314
3435
|
}
|
|
3436
|
+
/**
|
|
3437
|
+
* 让位:把一个名字下的「非启用态占位」移入回收站并清掉它的 sidecar 禁用记录。
|
|
3438
|
+
* 只动这两类,**启用的技能一律不碰**:
|
|
3439
|
+
* - 禁用发现文件(`<name>/SKILL.md.disabled` 或 `<name>.md.disabled`,两种形态都认);
|
|
3440
|
+
* - 同名目录存在但既无 `SKILL.md` 也无 `SKILL.md.disabled`(导入中断/手工删除的空壳)。
|
|
3441
|
+
*
|
|
3442
|
+
* 回收站按名字索引,所以多形态只会记下最后一条的恢复指针;其余的那份会在
|
|
3443
|
+
* 「清空回收站」时被孤儿回收扫掉(见 sourceTrashClear)。
|
|
3444
|
+
*
|
|
3445
|
+
* @returns 被移入回收站的路径列表,没有可让位的东西时 undefined。
|
|
3446
|
+
*/
|
|
3447
|
+
async function retireNamePlaceholders(deps, base, name) {
|
|
3448
|
+
const occupancy = await occupancyOf(base, name);
|
|
3449
|
+
const sources = [.../* @__PURE__ */ new Set([...occupancy.disabled.map((path) => trashSourceOf(path)), ...occupancy.bareDir !== void 0 ? [occupancy.bareDir] : []])];
|
|
3450
|
+
if (sources.length === 0) return void 0;
|
|
3451
|
+
const trashed = [];
|
|
3452
|
+
for (const source of sources) {
|
|
3453
|
+
const moved = await moveIntoTrash(source);
|
|
3454
|
+
await deps.store.addTrash({
|
|
3455
|
+
name,
|
|
3456
|
+
path: moved.path,
|
|
3457
|
+
movedAt: Date.now(),
|
|
3458
|
+
sourcePath: moved.source
|
|
3459
|
+
});
|
|
3460
|
+
trashed.push(moved.path);
|
|
3461
|
+
}
|
|
3462
|
+
await deps.store.removeDisabled(name);
|
|
3463
|
+
return trashed;
|
|
3464
|
+
}
|
|
3315
3465
|
//#endregion
|
|
3316
3466
|
//#region src/routes/market.ts
|
|
3317
3467
|
/**
|
|
@@ -3581,29 +3731,17 @@ function marketRoutes(deps) {
|
|
|
3581
3731
|
* cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
|
|
3582
3732
|
*/
|
|
3583
3733
|
/**
|
|
3584
|
-
*
|
|
3585
|
-
*
|
|
3586
|
-
*
|
|
3734
|
+
* 导入让位(issue #16):磁盘上已有同名占位但**没有启用的技能**时,把占位移入
|
|
3735
|
+
* 回收站,把名字让给即将导入的真技能。占位有两种 —— 已禁用的发现文件(目录包或
|
|
3736
|
+
* 扁平,后者曾长期漏处理:`clearDisabledPlaceholder` 只认 `<name>` 这个目录,
|
|
3737
|
+
* 扁平占位占的是 `<name>.md.disabled`,判据为假,分支根本进不去)和没有 `SKILL.md`
|
|
3738
|
+
* 的空壳目录。判据统一走 `occupancyOf`,形态判断集中在 `retireNamePlaceholders`。
|
|
3587
3739
|
*
|
|
3588
|
-
*
|
|
3589
|
-
* 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
|
|
3740
|
+
* 返回 false = 磁盘上有启用的同名技能(真货),一律不动,交回原本的「跳过」。
|
|
3590
3741
|
*/
|
|
3591
3742
|
async function clearDisabledPlaceholder(deps, targetRoot, name) {
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
const dir = join(targetRoot, name);
|
|
3595
|
-
const sourceToTrash = record.path.endsWith("SKILL.md.disabled") ? dirname(record.path) : record.path;
|
|
3596
|
-
if (sourceToTrash !== dir) return false;
|
|
3597
|
-
if (await pathExists(join(dir, "SKILL.md"))) return false;
|
|
3598
|
-
const { path: target } = await moveIntoTrash(sourceToTrash);
|
|
3599
|
-
await deps.store.addTrash({
|
|
3600
|
-
name,
|
|
3601
|
-
path: target,
|
|
3602
|
-
movedAt: Date.now(),
|
|
3603
|
-
sourcePath: sourceToTrash
|
|
3604
|
-
});
|
|
3605
|
-
await deps.store.removeDisabled(name);
|
|
3606
|
-
return true;
|
|
3743
|
+
if ((await occupancyOf(targetRoot, name)).enabled.length > 0) return false;
|
|
3744
|
+
return await retireNamePlaceholders(deps, targetRoot, name) !== void 0;
|
|
3607
3745
|
}
|
|
3608
3746
|
/** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
3609
3747
|
function repoImportRoutes(deps) {
|
|
@@ -3733,7 +3871,8 @@ function repoImportRoutes(deps) {
|
|
|
3733
3871
|
job.done += 1;
|
|
3734
3872
|
continue;
|
|
3735
3873
|
}
|
|
3736
|
-
|
|
3874
|
+
const occupancy = await occupancyOf(targetRoot, entry.name);
|
|
3875
|
+
if (occupancy.enabled.length > 0 || occupancy.disabled.length > 0 || occupancy.bareDir !== void 0) {
|
|
3737
3876
|
let cleared = false;
|
|
3738
3877
|
try {
|
|
3739
3878
|
cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name);
|
|
@@ -4145,13 +4284,21 @@ function sourceRoutes(deps) {
|
|
|
4145
4284
|
continue;
|
|
4146
4285
|
}
|
|
4147
4286
|
const targetDir = join(targetRoot, name);
|
|
4148
|
-
const
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
if (
|
|
4154
|
-
else await
|
|
4287
|
+
const record = await deps.store.getDisabled(name);
|
|
4288
|
+
const occupancy = await occupancyOf(targetRoot, name);
|
|
4289
|
+
const download = () => downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot);
|
|
4290
|
+
await retireNamePlaceholders(deps, targetRoot, name);
|
|
4291
|
+
const enabled = occupancy.enabled[0];
|
|
4292
|
+
if (enabled !== void 0) await replaceInPlace(trashSourceOf(enabled), download);
|
|
4293
|
+
else await download();
|
|
4294
|
+
if (record !== void 0) {
|
|
4295
|
+
const disabledPath = join(targetDir, "SKILL.md.disabled");
|
|
4296
|
+
await rename(join(targetDir, "SKILL.md"), disabledPath);
|
|
4297
|
+
await deps.store.addDisabled({
|
|
4298
|
+
...record,
|
|
4299
|
+
path: disabledPath
|
|
4300
|
+
});
|
|
4301
|
+
} else await deps.store.removeDisabled(name);
|
|
4155
4302
|
await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir);
|
|
4156
4303
|
synced.push(name);
|
|
4157
4304
|
} catch (error) {
|
|
@@ -4204,29 +4351,30 @@ function sourceRoutes(deps) {
|
|
|
4204
4351
|
});
|
|
4205
4352
|
continue;
|
|
4206
4353
|
}
|
|
4207
|
-
const
|
|
4208
|
-
|
|
4354
|
+
const occupancy = await occupancyOf(rootPath("user-dsh", home), name);
|
|
4355
|
+
const existing = occupancy.enabled[0] ?? occupancy.disabled[0] ?? occupancy.bareDir;
|
|
4356
|
+
if (existing === void 0) {
|
|
4209
4357
|
failed.push({
|
|
4210
4358
|
name,
|
|
4211
|
-
error: "skill
|
|
4359
|
+
error: "skill not found on disk"
|
|
4212
4360
|
});
|
|
4213
4361
|
continue;
|
|
4214
4362
|
}
|
|
4215
|
-
if (
|
|
4363
|
+
if (rootOfPath(existing, home) === void 0) {
|
|
4216
4364
|
failed.push({
|
|
4217
4365
|
name,
|
|
4218
|
-
error: "skill
|
|
4366
|
+
error: "skill path is outside the hub writable roots"
|
|
4219
4367
|
});
|
|
4220
4368
|
continue;
|
|
4221
4369
|
}
|
|
4222
4370
|
try {
|
|
4223
4371
|
const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id);
|
|
4224
|
-
const { path } = await trashSkill(
|
|
4372
|
+
const { path, source: trashSource } = await trashSkill(existing);
|
|
4225
4373
|
await deps.store.addTrash({
|
|
4226
4374
|
name,
|
|
4227
4375
|
path,
|
|
4228
4376
|
movedAt: Date.now(),
|
|
4229
|
-
sourcePath,
|
|
4377
|
+
sourcePath: trashSource,
|
|
4230
4378
|
origin: {
|
|
4231
4379
|
repo: source.repo,
|
|
4232
4380
|
root: source.root,
|
|
@@ -4279,7 +4427,7 @@ function sourceRoutes(deps) {
|
|
|
4279
4427
|
const path = await restoreSkill(entry, home);
|
|
4280
4428
|
if (entry.origin !== void 0) await deps.store.addSourceSkill(entry.origin.repo, entry.origin.root, entry.origin.commitSha, entry.origin.ref, name);
|
|
4281
4429
|
for (const tagId of entry.tagIds ?? []) await deps.store.addSkillToTag(tagId, name);
|
|
4282
|
-
await deps.store.removeTrash(
|
|
4430
|
+
await deps.store.removeTrash(entry.path);
|
|
4283
4431
|
deps.invalidate?.();
|
|
4284
4432
|
writeJson(res, 200, {
|
|
4285
4433
|
ok: true,
|
|
@@ -4295,28 +4443,67 @@ function sourceRoutes(deps) {
|
|
|
4295
4443
|
const home = homeOf(deps);
|
|
4296
4444
|
const entries = await deps.store.listTrash();
|
|
4297
4445
|
const deleted = [];
|
|
4446
|
+
const removedPaths = [];
|
|
4298
4447
|
const failed = [];
|
|
4299
4448
|
for (const entry of entries) try {
|
|
4300
4449
|
await clearTrash(entry, home);
|
|
4301
4450
|
await deps.store.removeSkillFromTags(entry.name);
|
|
4302
4451
|
deleted.push(entry.name);
|
|
4452
|
+
removedPaths.push(entry.path);
|
|
4303
4453
|
} catch (error) {
|
|
4304
4454
|
failed.push({
|
|
4305
4455
|
name: entry.name,
|
|
4306
4456
|
error: errorText(error)
|
|
4307
4457
|
});
|
|
4308
4458
|
}
|
|
4309
|
-
for (const
|
|
4310
|
-
|
|
4459
|
+
for (const path of removedPaths) await deps.store.removeTrash(path);
|
|
4460
|
+
const orphans = await sweepOrphanTrash(deps, home, failed);
|
|
4461
|
+
if (deleted.length > 0 || orphans.length > 0) deps.invalidate?.();
|
|
4311
4462
|
writeJson(res, 200, {
|
|
4312
4463
|
ok: true,
|
|
4313
4464
|
deleted,
|
|
4314
|
-
failed
|
|
4465
|
+
failed,
|
|
4466
|
+
...orphans.length > 0 ? { orphans } : {}
|
|
4315
4467
|
});
|
|
4316
4468
|
}
|
|
4317
4469
|
}
|
|
4318
4470
|
];
|
|
4319
4471
|
}
|
|
4472
|
+
/**
|
|
4473
|
+
* 删除两个可写根 `.trash` 下不被任何回收站记录引用的残留目录。
|
|
4474
|
+
* 只扫 `.trash` 的直接子项、跳过点开头项,失败项并入 `failed`(保留可重试)。
|
|
4475
|
+
*/
|
|
4476
|
+
async function sweepOrphanTrash(deps, home, failed) {
|
|
4477
|
+
const referenced = new Set((await deps.store.listTrash()).map((entry) => entry.path));
|
|
4478
|
+
const orphans = [];
|
|
4479
|
+
for (const root of WRITABLE_ROOTS) {
|
|
4480
|
+
const trashDir = join(rootPath(root, home), ".trash");
|
|
4481
|
+
let names;
|
|
4482
|
+
try {
|
|
4483
|
+
names = await readdir(trashDir);
|
|
4484
|
+
} catch {
|
|
4485
|
+
continue;
|
|
4486
|
+
}
|
|
4487
|
+
for (const name of names) {
|
|
4488
|
+
if (name.startsWith(".")) continue;
|
|
4489
|
+
const full = join(trashDir, name);
|
|
4490
|
+
if (referenced.has(full)) continue;
|
|
4491
|
+
try {
|
|
4492
|
+
await rm(full, {
|
|
4493
|
+
recursive: true,
|
|
4494
|
+
force: true
|
|
4495
|
+
});
|
|
4496
|
+
orphans.push(full);
|
|
4497
|
+
} catch (error) {
|
|
4498
|
+
failed.push({
|
|
4499
|
+
name,
|
|
4500
|
+
error: errorText(error)
|
|
4501
|
+
});
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4504
|
+
}
|
|
4505
|
+
return orphans;
|
|
4506
|
+
}
|
|
4320
4507
|
//#endregion
|
|
4321
4508
|
//#region src/routes.ts
|
|
4322
4509
|
/**
|
|
@@ -4719,6 +4906,47 @@ async function reconcileDisabledSkills(store, home = dshHome()) {
|
|
|
4719
4906
|
}
|
|
4720
4907
|
return added;
|
|
4721
4908
|
}
|
|
4909
|
+
/**
|
|
4910
|
+
* 反向对账:`reconcileDisabledSkills` 只处理「磁盘有 `.disabled`、sidecar 没记录」。
|
|
4911
|
+
* 反方向(sidecar 有记录、磁盘上那个文件已经不在了)以前永不清理,于是留下一条
|
|
4912
|
+
* 干掉不掉的记录:`/toggle` 启用会 rename 失败,`/create` 被 409 挡住,`/skill/delete`
|
|
4913
|
+
* 也 rename 失败 —— 名字被永久锁死(issue #16 类,实测 G5)。
|
|
4914
|
+
*
|
|
4915
|
+
* 两种处理,都不动启用态技能:
|
|
4916
|
+
* - 名字在该 root 下仍有 `.disabled` 占位(目录包或扁平形态)→ 把记录指向真实文件;
|
|
4917
|
+
* - 一个都没有 → 删记录,把名字还给用户。
|
|
4918
|
+
*
|
|
4919
|
+
* 只处理合法可写 root 的记录;root 值异常(历史损坏数据)时原样保留,不擅自删。
|
|
4920
|
+
*/
|
|
4921
|
+
async function pruneStaleDisabledSkills(store, home = dshHome()) {
|
|
4922
|
+
const result = {
|
|
4923
|
+
repaired: [],
|
|
4924
|
+
removed: []
|
|
4925
|
+
};
|
|
4926
|
+
for (const record of await store.listDisabled()) {
|
|
4927
|
+
let exists = false;
|
|
4928
|
+
try {
|
|
4929
|
+
exists = (await stat(record.path)).isFile();
|
|
4930
|
+
} catch {
|
|
4931
|
+
exists = false;
|
|
4932
|
+
}
|
|
4933
|
+
if (exists) continue;
|
|
4934
|
+
if (!WRITABLE_ROOTS.includes(record.root)) continue;
|
|
4935
|
+
const occupancy = await disabledOccupancyIn(rootPath(record.root, home), record.name);
|
|
4936
|
+
if (occupancy === void 0) {
|
|
4937
|
+
await store.removeDisabled(record.name);
|
|
4938
|
+
result.removed.push(record.name);
|
|
4939
|
+
continue;
|
|
4940
|
+
}
|
|
4941
|
+
const repaired = {
|
|
4942
|
+
...record,
|
|
4943
|
+
path: occupancy
|
|
4944
|
+
};
|
|
4945
|
+
await store.addDisabled(repaired);
|
|
4946
|
+
result.repaired.push(repaired);
|
|
4947
|
+
}
|
|
4948
|
+
return result;
|
|
4949
|
+
}
|
|
4722
4950
|
//#endregion
|
|
4723
4951
|
//#region src/index.ts
|
|
4724
4952
|
/** Stable cordis plugin name (matches cordis.patch.yml insert id). */
|
|
@@ -4906,6 +5134,12 @@ function apply(ctx, config) {
|
|
|
4906
5134
|
} catch (error) {
|
|
4907
5135
|
ctx.logger.warn("[dsh-skill-hub] startup disabled-skill reconcile failed", error);
|
|
4908
5136
|
}
|
|
5137
|
+
try {
|
|
5138
|
+
const pruned = await pruneStaleDisabledSkills(store, home);
|
|
5139
|
+
if (pruned.repaired.length > 0 || pruned.removed.length > 0) ctx.logger.info(`[dsh-skill-hub] startup pruned disabled records: repaired ${pruned.repaired.length} (${pruned.repaired.map((entry) => entry.name).join(", ") || "-"}), removed ${pruned.removed.length} (${pruned.removed.join(", ") || "-"})`);
|
|
5140
|
+
} catch (error) {
|
|
5141
|
+
ctx.logger.warn("[dsh-skill-hub] startup disabled-record prune failed", error);
|
|
5142
|
+
}
|
|
4909
5143
|
})();
|
|
4910
5144
|
(async () => {
|
|
4911
5145
|
try {
|
|
@@ -134,6 +134,11 @@ export interface SourceTrashClearResponse {
|
|
|
134
134
|
name: string;
|
|
135
135
|
error: string;
|
|
136
136
|
}>;
|
|
137
|
+
/**
|
|
138
|
+
* `.trash` 里没有对应记录的残留目录(同名技能先后两次入站时,旧记录被覆盖,
|
|
139
|
+
* 目录却留了下来)。清空回收站时一并回收,这里上报它们的路径;没有则省略。
|
|
140
|
+
*/
|
|
141
|
+
orphans?: string[];
|
|
137
142
|
}
|
|
138
143
|
/** 项目级技能来源(它们有 workspace 归属,不属于「个人」组)。 */
|
|
139
144
|
export declare function isProjectSource(source: string): boolean;
|
package/lib/types/reconcile.d.ts
CHANGED
|
@@ -15,6 +15,10 @@ export interface DisabledReconcileStore {
|
|
|
15
15
|
listDisabled(): Promise<DisabledSkill[]>;
|
|
16
16
|
addDisabled(entry: DisabledSkill): Promise<void>;
|
|
17
17
|
}
|
|
18
|
+
/** Narrow store view used by the reverse reconcile (adds record removal). */
|
|
19
|
+
export interface DisabledPruneStore extends DisabledReconcileStore {
|
|
20
|
+
removeDisabled(name: string): Promise<void>;
|
|
21
|
+
}
|
|
18
22
|
/**
|
|
19
23
|
* Add sidecar disabled records for every `.disabled` discovery file that has
|
|
20
24
|
* none yet. Existing records (matched by path or by name) win, so this never
|
|
@@ -22,3 +26,23 @@ export interface DisabledReconcileStore {
|
|
|
22
26
|
* in the diagnostics scan instead). Returns the records added.
|
|
23
27
|
*/
|
|
24
28
|
export declare function reconcileDisabledSkills(store: DisabledReconcileStore, home?: string): Promise<DisabledSkill[]>;
|
|
29
|
+
/** 反向对账的结果:修好路径的记录 + 被清掉的死记录。 */
|
|
30
|
+
export interface DisabledPruneResult {
|
|
31
|
+
/** 记录还在、但 path 指向的文件已不在:已改指向现存的 `.disabled` 文件。 */
|
|
32
|
+
repaired: DisabledSkill[];
|
|
33
|
+
/** 文件与禁用占位都已不存在:记录已删除,名字被释放。 */
|
|
34
|
+
removed: string[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 反向对账:`reconcileDisabledSkills` 只处理「磁盘有 `.disabled`、sidecar 没记录」。
|
|
38
|
+
* 反方向(sidecar 有记录、磁盘上那个文件已经不在了)以前永不清理,于是留下一条
|
|
39
|
+
* 干掉不掉的记录:`/toggle` 启用会 rename 失败,`/create` 被 409 挡住,`/skill/delete`
|
|
40
|
+
* 也 rename 失败 —— 名字被永久锁死(issue #16 类,实测 G5)。
|
|
41
|
+
*
|
|
42
|
+
* 两种处理,都不动启用态技能:
|
|
43
|
+
* - 名字在该 root 下仍有 `.disabled` 占位(目录包或扁平形态)→ 把记录指向真实文件;
|
|
44
|
+
* - 一个都没有 → 删记录,把名字还给用户。
|
|
45
|
+
*
|
|
46
|
+
* 只处理合法可写 root 的记录;root 值异常(历史损坏数据)时原样保留,不擅自删。
|
|
47
|
+
*/
|
|
48
|
+
export declare function pruneStaleDisabledSkills(store: DisabledPruneStore, home?: string): Promise<DisabledPruneResult>;
|
|
@@ -54,5 +54,25 @@ export declare const importJobs: Map<string, ImportJob>;
|
|
|
54
54
|
export declare const IMPORT_JOB_TTL_MS: number;
|
|
55
55
|
export declare const IMPORT_JOB_MAX = 100;
|
|
56
56
|
export declare function gcImportJobs(): void;
|
|
57
|
-
/**
|
|
58
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Replace whatever currently occupies a skill slot with a fresh download;
|
|
59
|
+
* restores the previous state on failure.
|
|
60
|
+
*
|
|
61
|
+
* `target` is the entry to move aside — a bundle **directory** or a flat
|
|
62
|
+
* `<name>.md` file, whichever the download is replacing (the rename/rm below
|
|
63
|
+
* are form-neutral). The backup is dot-prefixed (discovery skips dot entries),
|
|
64
|
+
* so even a failed cleanup can never register as a duplicate skill.
|
|
65
|
+
*/
|
|
66
|
+
export declare function replaceInPlace(target: string, download: () => Promise<unknown>): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* 让位:把一个名字下的「非启用态占位」移入回收站并清掉它的 sidecar 禁用记录。
|
|
69
|
+
* 只动这两类,**启用的技能一律不碰**:
|
|
70
|
+
* - 禁用发现文件(`<name>/SKILL.md.disabled` 或 `<name>.md.disabled`,两种形态都认);
|
|
71
|
+
* - 同名目录存在但既无 `SKILL.md` 也无 `SKILL.md.disabled`(导入中断/手工删除的空壳)。
|
|
72
|
+
*
|
|
73
|
+
* 回收站按名字索引,所以多形态只会记下最后一条的恢复指针;其余的那份会在
|
|
74
|
+
* 「清空回收站」时被孤儿回收扫掉(见 sourceTrashClear)。
|
|
75
|
+
*
|
|
76
|
+
* @returns 被移入回收站的路径列表,没有可让位的东西时 undefined。
|
|
77
|
+
*/
|
|
78
|
+
export declare function retireNamePlaceholders(deps: SkillHubRouteDeps, base: string, name: string): Promise<string[] | undefined>;
|