dsh-skill-hub 0.3.20 → 0.3.22
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 +2 -2
- package/README.md +5 -4
- package/README.zh.md +5 -4
- package/lib/client.js +2 -2
- package/lib/client.js.map +1 -1
- package/lib/index.js +362 -74
- package/lib/types/client/locales/settings.d.ts +1 -1
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/protocol/config.d.ts +1 -1
- package/lib/types/protocol/sources.d.ts +5 -0
- package/lib/types/reconcile.d.ts +24 -0
- package/lib/types/routes/deps.d.ts +8 -1
- package/lib/types/routes/helpers.d.ts +11 -6
- package/lib/types/routes/http.d.ts +27 -2
- package/lib/types/routes/route-state.d.ts +22 -2
- package/lib/types/routes.d.ts +29 -4
- 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/client/locales/settings.ts +2 -2
- package/src/index.ts +26 -4
- package/src/protocol/config.ts +1 -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/deps.ts +8 -2
- package/src/routes/helpers.ts +18 -7
- package/src/routes/http.ts +31 -2
- 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 +302 -4
- package/src/routes.ts +49 -5
- 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()) {
|
|
@@ -2306,6 +2391,15 @@ function isLoopbackRequest(request) {
|
|
|
2306
2391
|
return false;
|
|
2307
2392
|
}
|
|
2308
2393
|
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Fallback fence for a host that mounts no Connection service (unit tests, and
|
|
2396
|
+
* any composition serving the webserver alone): the historical literal-loopback
|
|
2397
|
+
* check, so a missing service degrades to the previous behaviour instead of an
|
|
2398
|
+
* open or a dead route family.
|
|
2399
|
+
*/
|
|
2400
|
+
function loopbackOnlyFence(request) {
|
|
2401
|
+
return isLoopbackRequest(request) ? void 0 : 403;
|
|
2402
|
+
}
|
|
2309
2403
|
/** One JSON response. */
|
|
2310
2404
|
function writeJson(res, status, body) {
|
|
2311
2405
|
const payload = JSON.stringify(body);
|
|
@@ -2778,17 +2872,24 @@ function toDetail(skill) {
|
|
|
2778
2872
|
//#region src/routes/helpers.ts
|
|
2779
2873
|
/**
|
|
2780
2874
|
* Wrap one handler in the shared request fences. Order matches the
|
|
2781
|
-
* original per-handler prologue:
|
|
2875
|
+
* original per-handler prologue: trust fence → HTTP method → master
|
|
2782
2876
|
* switch → JSON body. The outer catch maps errors onto the JSON error
|
|
2783
2877
|
* body (preserving RepoFetchError status codes).
|
|
2878
|
+
*
|
|
2879
|
+
* The trust fence is the host's `/api` model (`deps.fence`) whenever the
|
|
2880
|
+
* composition mounts Connection, so a reverse-proxied deployment is judged by
|
|
2881
|
+
* the same authorities dsh already trusts; without that service the historical
|
|
2882
|
+
* literal-loopback check stands in.
|
|
2784
2883
|
*/
|
|
2785
2884
|
function createRoute(deps, spec) {
|
|
2885
|
+
const fence = deps.fence ?? loopbackOnlyFence;
|
|
2786
2886
|
return {
|
|
2787
2887
|
kind: spec.kind ?? "exact",
|
|
2788
2888
|
path: spec.path,
|
|
2789
2889
|
handler: async (req, res) => {
|
|
2790
|
-
|
|
2791
|
-
|
|
2890
|
+
const rejection = fence(req);
|
|
2891
|
+
if (rejection !== void 0) {
|
|
2892
|
+
writeError(res, rejection, rejection === 401 ? "unauthorized: browser session required" : "forbidden: untrusted authority");
|
|
2792
2893
|
return;
|
|
2793
2894
|
}
|
|
2794
2895
|
if (!spec.methods.includes(req.method)) {
|
|
@@ -2928,7 +3029,18 @@ function catalogRoutes(deps) {
|
|
|
2928
3029
|
writeError(res, 409, "disabled skill path is outside the hub writable roots");
|
|
2929
3030
|
return;
|
|
2930
3031
|
}
|
|
2931
|
-
|
|
3032
|
+
if (!await pathExists(disabled.path)) {
|
|
3033
|
+
await deps.store.removeDisabled(name);
|
|
3034
|
+
await deps.store.removeSkillFromTags(name);
|
|
3035
|
+
deps.invalidate?.();
|
|
3036
|
+
writeJson(res, 200, {
|
|
3037
|
+
ok: true,
|
|
3038
|
+
name,
|
|
3039
|
+
path: disabled.path
|
|
3040
|
+
});
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
trashResult = await trashSkill(disabled.path);
|
|
2932
3044
|
} else {
|
|
2933
3045
|
writeError(res, resolved.status, resolved.error);
|
|
2934
3046
|
return;
|
|
@@ -2979,11 +3091,23 @@ function catalogRoutes(deps) {
|
|
|
2979
3091
|
writeError(res, 404, "skill is not hub-disabled: " + name);
|
|
2980
3092
|
return;
|
|
2981
3093
|
}
|
|
3094
|
+
let disabledPath = record.path;
|
|
3095
|
+
if (!await pathExists(disabledPath)) {
|
|
3096
|
+
const root = rootOfPath(disabledPath, homeOf(deps));
|
|
3097
|
+
const occupancy = root === void 0 ? void 0 : await disabledOccupancyIn(rootPath(root, homeOf(deps)), name);
|
|
3098
|
+
if (occupancy === void 0) {
|
|
3099
|
+
await deps.store.removeDisabled(name);
|
|
3100
|
+
deps.invalidate?.();
|
|
3101
|
+
writeError(res, 409, "disabled record was stale (its file no longer exists); the record has been removed — retry now");
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
disabledPath = occupancy;
|
|
3105
|
+
}
|
|
2982
3106
|
try {
|
|
2983
|
-
await enableSkill(
|
|
3107
|
+
await enableSkill(disabledPath);
|
|
2984
3108
|
} catch (error) {
|
|
2985
3109
|
if (error.code === "ENOENT") {
|
|
2986
|
-
writeError(res, 409, "disabled skill file is missing on disk: " +
|
|
3110
|
+
writeError(res, 409, "disabled skill file is missing on disk: " + disabledPath);
|
|
2987
3111
|
return;
|
|
2988
3112
|
}
|
|
2989
3113
|
throw error;
|
|
@@ -3087,8 +3211,13 @@ function catalogRoutes(deps) {
|
|
|
3087
3211
|
writeError(res, 409, "skill name is disabled: re-enable it from the disabled list first");
|
|
3088
3212
|
return;
|
|
3089
3213
|
}
|
|
3090
|
-
|
|
3091
|
-
|
|
3214
|
+
const occupancy = await occupancyOf(rootPath(root, homeOf(deps)), name);
|
|
3215
|
+
if (occupancy.enabled.length > 0) {
|
|
3216
|
+
writeError(res, 409, "a skill file already occupies this name on disk: " + name + " (check the discovery diagnostics)");
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
3219
|
+
if (occupancy.disabled.length > 0 || occupancy.bareDir !== void 0) {
|
|
3220
|
+
writeError(res, 409, "name is occupied by a disabled placeholder or an empty directory on disk: " + name + " (check the discovery diagnostics)");
|
|
3092
3221
|
return;
|
|
3093
3222
|
}
|
|
3094
3223
|
const path = await createSkill(root, name, readString(body, "description"), homeOf(deps));
|
|
@@ -3295,15 +3424,23 @@ function gcImportJobs() {
|
|
|
3295
3424
|
for (let i = 0; i < finished.length && importJobs.size > 100; i++) importJobs.delete(finished[i][0]);
|
|
3296
3425
|
}
|
|
3297
3426
|
}
|
|
3298
|
-
/**
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3427
|
+
/**
|
|
3428
|
+
* Replace whatever currently occupies a skill slot with a fresh download;
|
|
3429
|
+
* restores the previous state on failure.
|
|
3430
|
+
*
|
|
3431
|
+
* `target` is the entry to move aside — a bundle **directory** or a flat
|
|
3432
|
+
* `<name>.md` file, whichever the download is replacing (the rename/rm below
|
|
3433
|
+
* are form-neutral). The backup is dot-prefixed (discovery skips dot entries),
|
|
3434
|
+
* so even a failed cleanup can never register as a duplicate skill.
|
|
3435
|
+
*/
|
|
3436
|
+
async function replaceInPlace(target, download) {
|
|
3437
|
+
const backup = join(dirname(target), "." + basename(target) + ".sync-bak-" + Date.now());
|
|
3438
|
+
await rename(target, backup);
|
|
3302
3439
|
try {
|
|
3303
3440
|
await download();
|
|
3304
3441
|
} catch (error) {
|
|
3305
|
-
await rename(backup,
|
|
3306
|
-
console.warn("[dsh-skill-hub] restoring " +
|
|
3442
|
+
await rename(backup, target).catch((rollbackError) => {
|
|
3443
|
+
console.warn("[dsh-skill-hub] restoring " + target + " from " + backup + " failed:", errorText(rollbackError));
|
|
3307
3444
|
});
|
|
3308
3445
|
throw error;
|
|
3309
3446
|
}
|
|
@@ -3312,6 +3449,35 @@ async function replaceSkillDir(targetDir, download) {
|
|
|
3312
3449
|
force: true
|
|
3313
3450
|
});
|
|
3314
3451
|
}
|
|
3452
|
+
/**
|
|
3453
|
+
* 让位:把一个名字下的「非启用态占位」移入回收站并清掉它的 sidecar 禁用记录。
|
|
3454
|
+
* 只动这两类,**启用的技能一律不碰**:
|
|
3455
|
+
* - 禁用发现文件(`<name>/SKILL.md.disabled` 或 `<name>.md.disabled`,两种形态都认);
|
|
3456
|
+
* - 同名目录存在但既无 `SKILL.md` 也无 `SKILL.md.disabled`(导入中断/手工删除的空壳)。
|
|
3457
|
+
*
|
|
3458
|
+
* 回收站按名字索引,所以多形态只会记下最后一条的恢复指针;其余的那份会在
|
|
3459
|
+
* 「清空回收站」时被孤儿回收扫掉(见 sourceTrashClear)。
|
|
3460
|
+
*
|
|
3461
|
+
* @returns 被移入回收站的路径列表,没有可让位的东西时 undefined。
|
|
3462
|
+
*/
|
|
3463
|
+
async function retireNamePlaceholders(deps, base, name) {
|
|
3464
|
+
const occupancy = await occupancyOf(base, name);
|
|
3465
|
+
const sources = [.../* @__PURE__ */ new Set([...occupancy.disabled.map((path) => trashSourceOf(path)), ...occupancy.bareDir !== void 0 ? [occupancy.bareDir] : []])];
|
|
3466
|
+
if (sources.length === 0) return void 0;
|
|
3467
|
+
const trashed = [];
|
|
3468
|
+
for (const source of sources) {
|
|
3469
|
+
const moved = await moveIntoTrash(source);
|
|
3470
|
+
await deps.store.addTrash({
|
|
3471
|
+
name,
|
|
3472
|
+
path: moved.path,
|
|
3473
|
+
movedAt: Date.now(),
|
|
3474
|
+
sourcePath: moved.source
|
|
3475
|
+
});
|
|
3476
|
+
trashed.push(moved.path);
|
|
3477
|
+
}
|
|
3478
|
+
await deps.store.removeDisabled(name);
|
|
3479
|
+
return trashed;
|
|
3480
|
+
}
|
|
3315
3481
|
//#endregion
|
|
3316
3482
|
//#region src/routes/market.ts
|
|
3317
3483
|
/**
|
|
@@ -3581,29 +3747,17 @@ function marketRoutes(deps) {
|
|
|
3581
3747
|
* cancel 取消。从 routes.ts 原样搬出,handler 逻辑不变。
|
|
3582
3748
|
*/
|
|
3583
3749
|
/**
|
|
3584
|
-
*
|
|
3585
|
-
*
|
|
3586
|
-
*
|
|
3750
|
+
* 导入让位(issue #16):磁盘上已有同名占位但**没有启用的技能**时,把占位移入
|
|
3751
|
+
* 回收站,把名字让给即将导入的真技能。占位有两种 —— 已禁用的发现文件(目录包或
|
|
3752
|
+
* 扁平,后者曾长期漏处理:`clearDisabledPlaceholder` 只认 `<name>` 这个目录,
|
|
3753
|
+
* 扁平占位占的是 `<name>.md.disabled`,判据为假,分支根本进不去)和没有 `SKILL.md`
|
|
3754
|
+
* 的空壳目录。判据统一走 `occupancyOf`,形态判断集中在 `retireNamePlaceholders`。
|
|
3587
3755
|
*
|
|
3588
|
-
*
|
|
3589
|
-
* 必须已经没有 SKILL.md;其余不确定情形一律返回 false,交回原本的「跳过」。
|
|
3756
|
+
* 返回 false = 磁盘上有启用的同名技能(真货),一律不动,交回原本的「跳过」。
|
|
3590
3757
|
*/
|
|
3591
3758
|
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;
|
|
3759
|
+
if ((await occupancyOf(targetRoot, name)).enabled.length > 0) return false;
|
|
3760
|
+
return await retireNamePlaceholders(deps, targetRoot, name) !== void 0;
|
|
3607
3761
|
}
|
|
3608
3762
|
/** 仓库导入域全部路由 spec(由 routes.ts 经 createRoute 包上统一围栏)。 */
|
|
3609
3763
|
function repoImportRoutes(deps) {
|
|
@@ -3733,7 +3887,8 @@ function repoImportRoutes(deps) {
|
|
|
3733
3887
|
job.done += 1;
|
|
3734
3888
|
continue;
|
|
3735
3889
|
}
|
|
3736
|
-
|
|
3890
|
+
const occupancy = await occupancyOf(targetRoot, entry.name);
|
|
3891
|
+
if (occupancy.enabled.length > 0 || occupancy.disabled.length > 0 || occupancy.bareDir !== void 0) {
|
|
3737
3892
|
let cleared = false;
|
|
3738
3893
|
try {
|
|
3739
3894
|
cleared = await clearDisabledPlaceholder(deps, targetRoot, entry.name);
|
|
@@ -4145,13 +4300,21 @@ function sourceRoutes(deps) {
|
|
|
4145
4300
|
continue;
|
|
4146
4301
|
}
|
|
4147
4302
|
const targetDir = join(targetRoot, name);
|
|
4148
|
-
const
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
if (
|
|
4154
|
-
else await
|
|
4303
|
+
const record = await deps.store.getDisabled(name);
|
|
4304
|
+
const occupancy = await occupancyOf(targetRoot, name);
|
|
4305
|
+
const download = () => downloadRepoSkill(repo, latest.commitSha, entry, files, targetRoot);
|
|
4306
|
+
await retireNamePlaceholders(deps, targetRoot, name);
|
|
4307
|
+
const enabled = occupancy.enabled[0];
|
|
4308
|
+
if (enabled !== void 0) await replaceInPlace(trashSourceOf(enabled), download);
|
|
4309
|
+
else await download();
|
|
4310
|
+
if (record !== void 0) {
|
|
4311
|
+
const disabledPath = join(targetDir, "SKILL.md.disabled");
|
|
4312
|
+
await rename(join(targetDir, "SKILL.md"), disabledPath);
|
|
4313
|
+
await deps.store.addDisabled({
|
|
4314
|
+
...record,
|
|
4315
|
+
path: disabledPath
|
|
4316
|
+
});
|
|
4317
|
+
} else await deps.store.removeDisabled(name);
|
|
4155
4318
|
await deps.store.mergeSourceManifest(repo, skillManifest(tree, entry.dir), entry.dir);
|
|
4156
4319
|
synced.push(name);
|
|
4157
4320
|
} catch (error) {
|
|
@@ -4204,29 +4367,30 @@ function sourceRoutes(deps) {
|
|
|
4204
4367
|
});
|
|
4205
4368
|
continue;
|
|
4206
4369
|
}
|
|
4207
|
-
const
|
|
4208
|
-
|
|
4370
|
+
const occupancy = await occupancyOf(rootPath("user-dsh", home), name);
|
|
4371
|
+
const existing = occupancy.enabled[0] ?? occupancy.disabled[0] ?? occupancy.bareDir;
|
|
4372
|
+
if (existing === void 0) {
|
|
4209
4373
|
failed.push({
|
|
4210
4374
|
name,
|
|
4211
|
-
error: "skill
|
|
4375
|
+
error: "skill not found on disk"
|
|
4212
4376
|
});
|
|
4213
4377
|
continue;
|
|
4214
4378
|
}
|
|
4215
|
-
if (
|
|
4379
|
+
if (rootOfPath(existing, home) === void 0) {
|
|
4216
4380
|
failed.push({
|
|
4217
4381
|
name,
|
|
4218
|
-
error: "skill
|
|
4382
|
+
error: "skill path is outside the hub writable roots"
|
|
4219
4383
|
});
|
|
4220
4384
|
continue;
|
|
4221
4385
|
}
|
|
4222
4386
|
try {
|
|
4223
4387
|
const tagIds = (await deps.store.listTags()).filter((tag) => tag.skillNames.includes(name)).map((tag) => tag.id);
|
|
4224
|
-
const { path } = await trashSkill(
|
|
4388
|
+
const { path, source: trashSource } = await trashSkill(existing);
|
|
4225
4389
|
await deps.store.addTrash({
|
|
4226
4390
|
name,
|
|
4227
4391
|
path,
|
|
4228
4392
|
movedAt: Date.now(),
|
|
4229
|
-
sourcePath,
|
|
4393
|
+
sourcePath: trashSource,
|
|
4230
4394
|
origin: {
|
|
4231
4395
|
repo: source.repo,
|
|
4232
4396
|
root: source.root,
|
|
@@ -4279,7 +4443,7 @@ function sourceRoutes(deps) {
|
|
|
4279
4443
|
const path = await restoreSkill(entry, home);
|
|
4280
4444
|
if (entry.origin !== void 0) await deps.store.addSourceSkill(entry.origin.repo, entry.origin.root, entry.origin.commitSha, entry.origin.ref, name);
|
|
4281
4445
|
for (const tagId of entry.tagIds ?? []) await deps.store.addSkillToTag(tagId, name);
|
|
4282
|
-
await deps.store.removeTrash(
|
|
4446
|
+
await deps.store.removeTrash(entry.path);
|
|
4283
4447
|
deps.invalidate?.();
|
|
4284
4448
|
writeJson(res, 200, {
|
|
4285
4449
|
ok: true,
|
|
@@ -4295,33 +4459,109 @@ function sourceRoutes(deps) {
|
|
|
4295
4459
|
const home = homeOf(deps);
|
|
4296
4460
|
const entries = await deps.store.listTrash();
|
|
4297
4461
|
const deleted = [];
|
|
4462
|
+
const removedPaths = [];
|
|
4298
4463
|
const failed = [];
|
|
4299
4464
|
for (const entry of entries) try {
|
|
4300
4465
|
await clearTrash(entry, home);
|
|
4301
4466
|
await deps.store.removeSkillFromTags(entry.name);
|
|
4302
4467
|
deleted.push(entry.name);
|
|
4468
|
+
removedPaths.push(entry.path);
|
|
4303
4469
|
} catch (error) {
|
|
4304
4470
|
failed.push({
|
|
4305
4471
|
name: entry.name,
|
|
4306
4472
|
error: errorText(error)
|
|
4307
4473
|
});
|
|
4308
4474
|
}
|
|
4309
|
-
for (const
|
|
4310
|
-
|
|
4475
|
+
for (const path of removedPaths) await deps.store.removeTrash(path);
|
|
4476
|
+
const orphans = await sweepOrphanTrash(deps, home, failed);
|
|
4477
|
+
if (deleted.length > 0 || orphans.length > 0) deps.invalidate?.();
|
|
4311
4478
|
writeJson(res, 200, {
|
|
4312
4479
|
ok: true,
|
|
4313
4480
|
deleted,
|
|
4314
|
-
failed
|
|
4481
|
+
failed,
|
|
4482
|
+
...orphans.length > 0 ? { orphans } : {}
|
|
4315
4483
|
});
|
|
4316
4484
|
}
|
|
4317
4485
|
}
|
|
4318
4486
|
];
|
|
4319
4487
|
}
|
|
4488
|
+
/**
|
|
4489
|
+
* 删除两个可写根 `.trash` 下不被任何回收站记录引用的残留目录。
|
|
4490
|
+
* 只扫 `.trash` 的直接子项、跳过点开头项,失败项并入 `failed`(保留可重试)。
|
|
4491
|
+
*/
|
|
4492
|
+
async function sweepOrphanTrash(deps, home, failed) {
|
|
4493
|
+
const referenced = new Set((await deps.store.listTrash()).map((entry) => entry.path));
|
|
4494
|
+
const orphans = [];
|
|
4495
|
+
for (const root of WRITABLE_ROOTS) {
|
|
4496
|
+
const trashDir = join(rootPath(root, home), ".trash");
|
|
4497
|
+
let names;
|
|
4498
|
+
try {
|
|
4499
|
+
names = await readdir(trashDir);
|
|
4500
|
+
} catch {
|
|
4501
|
+
continue;
|
|
4502
|
+
}
|
|
4503
|
+
for (const name of names) {
|
|
4504
|
+
if (name.startsWith(".")) continue;
|
|
4505
|
+
const full = join(trashDir, name);
|
|
4506
|
+
if (referenced.has(full)) continue;
|
|
4507
|
+
try {
|
|
4508
|
+
await rm(full, {
|
|
4509
|
+
recursive: true,
|
|
4510
|
+
force: true
|
|
4511
|
+
});
|
|
4512
|
+
orphans.push(full);
|
|
4513
|
+
} catch (error) {
|
|
4514
|
+
failed.push({
|
|
4515
|
+
name,
|
|
4516
|
+
error: errorText(error)
|
|
4517
|
+
});
|
|
4518
|
+
}
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4521
|
+
return orphans;
|
|
4522
|
+
}
|
|
4320
4523
|
//#endregion
|
|
4321
4524
|
//#region src/routes.ts
|
|
4322
4525
|
/**
|
|
4526
|
+
* Build the family's trust fence from the host's `/api` model.
|
|
4527
|
+
*
|
|
4528
|
+
* `ctx.connection.requestRejection` is the documented hook for fencing another
|
|
4529
|
+
* Web route; with no Connection service (a webserver-only composition, or a
|
|
4530
|
+
* unit test) the literal-loopback check stands in, so the fallback is the old
|
|
4531
|
+
* behaviour rather than an open route family.
|
|
4532
|
+
*
|
|
4533
|
+
* The service is read **per request**, not once at mount: plugins start in
|
|
4534
|
+
* composition order, so a one-shot read at `apply()` time would latch the
|
|
4535
|
+
* fallback whenever this plugin happens to come up before Connection. A
|
|
4536
|
+
* persistent absence is almost certainly an upstream composition change rather
|
|
4537
|
+
* than a transient startup order, so the first miss fires `onMissing` once —
|
|
4538
|
+
* without that signal the fallback would silently reproduce issue #17 after
|
|
4539
|
+
* any future upstream rename of the service.
|
|
4540
|
+
* @param read - resolves `ctx.connection`, or `undefined` when absent.
|
|
4541
|
+
* @param onMissing - called once, the first time the service reads absent.
|
|
4542
|
+
* @returns the fence every route in this family runs first.
|
|
4543
|
+
*/
|
|
4544
|
+
function hostTrustFence(read, onMissing) {
|
|
4545
|
+
let missingReported = false;
|
|
4546
|
+
return (request) => {
|
|
4547
|
+
const connection = read();
|
|
4548
|
+
if (connection === void 0) {
|
|
4549
|
+
if (!missingReported) {
|
|
4550
|
+
missingReported = true;
|
|
4551
|
+
onMissing?.();
|
|
4552
|
+
}
|
|
4553
|
+
return loopbackOnlyFence(request);
|
|
4554
|
+
}
|
|
4555
|
+
try {
|
|
4556
|
+
return connection.requestRejection(request);
|
|
4557
|
+
} catch {
|
|
4558
|
+
return 403;
|
|
4559
|
+
}
|
|
4560
|
+
};
|
|
4561
|
+
}
|
|
4562
|
+
/**
|
|
4323
4563
|
* Build every /api/skill-hub route.
|
|
4324
|
-
* @param deps - skill registry view + sidecar store.
|
|
4564
|
+
* @param deps - skill registry view + sidecar store + trust fence.
|
|
4325
4565
|
* @returns the exact-path routes plus the family's 404 catch-all.
|
|
4326
4566
|
*/
|
|
4327
4567
|
function makeRoutes(deps) {
|
|
@@ -4719,6 +4959,47 @@ async function reconcileDisabledSkills(store, home = dshHome()) {
|
|
|
4719
4959
|
}
|
|
4720
4960
|
return added;
|
|
4721
4961
|
}
|
|
4962
|
+
/**
|
|
4963
|
+
* 反向对账:`reconcileDisabledSkills` 只处理「磁盘有 `.disabled`、sidecar 没记录」。
|
|
4964
|
+
* 反方向(sidecar 有记录、磁盘上那个文件已经不在了)以前永不清理,于是留下一条
|
|
4965
|
+
* 干掉不掉的记录:`/toggle` 启用会 rename 失败,`/create` 被 409 挡住,`/skill/delete`
|
|
4966
|
+
* 也 rename 失败 —— 名字被永久锁死(issue #16 类,实测 G5)。
|
|
4967
|
+
*
|
|
4968
|
+
* 两种处理,都不动启用态技能:
|
|
4969
|
+
* - 名字在该 root 下仍有 `.disabled` 占位(目录包或扁平形态)→ 把记录指向真实文件;
|
|
4970
|
+
* - 一个都没有 → 删记录,把名字还给用户。
|
|
4971
|
+
*
|
|
4972
|
+
* 只处理合法可写 root 的记录;root 值异常(历史损坏数据)时原样保留,不擅自删。
|
|
4973
|
+
*/
|
|
4974
|
+
async function pruneStaleDisabledSkills(store, home = dshHome()) {
|
|
4975
|
+
const result = {
|
|
4976
|
+
repaired: [],
|
|
4977
|
+
removed: []
|
|
4978
|
+
};
|
|
4979
|
+
for (const record of await store.listDisabled()) {
|
|
4980
|
+
let exists = false;
|
|
4981
|
+
try {
|
|
4982
|
+
exists = (await stat(record.path)).isFile();
|
|
4983
|
+
} catch {
|
|
4984
|
+
exists = false;
|
|
4985
|
+
}
|
|
4986
|
+
if (exists) continue;
|
|
4987
|
+
if (!WRITABLE_ROOTS.includes(record.root)) continue;
|
|
4988
|
+
const occupancy = await disabledOccupancyIn(rootPath(record.root, home), record.name);
|
|
4989
|
+
if (occupancy === void 0) {
|
|
4990
|
+
await store.removeDisabled(record.name);
|
|
4991
|
+
result.removed.push(record.name);
|
|
4992
|
+
continue;
|
|
4993
|
+
}
|
|
4994
|
+
const repaired = {
|
|
4995
|
+
...record,
|
|
4996
|
+
path: occupancy
|
|
4997
|
+
};
|
|
4998
|
+
await store.addDisabled(repaired);
|
|
4999
|
+
result.repaired.push(repaired);
|
|
5000
|
+
}
|
|
5001
|
+
return result;
|
|
5002
|
+
}
|
|
4722
5003
|
//#endregion
|
|
4723
5004
|
//#region src/index.ts
|
|
4724
5005
|
/** Stable cordis plugin name (matches cordis.patch.yml insert id). */
|
|
@@ -4786,7 +5067,7 @@ const CONFIG_FIELDS = [
|
|
|
4786
5067
|
/** Order of the announcement section within the tool-guidance band. */
|
|
4787
5068
|
const SECTION_ORDER = 152;
|
|
4788
5069
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
4789
|
-
const SKILL_HUB_GUIDANCE = ["本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;本插件的配置页(启用/公告开关、圆点颜色、GitHub token、统计窗口)在插件管理页——侧边栏「插件」→ 本插件,是本插件自己注册的配置卡片(默认折叠,点标题展开)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit 快照,可检查更新、选择同步、上游删除时跟进删除(移入回收站可恢复,恢复后保留来源与场景归属);个人技能(无来源记录)不跟踪;调用次数与最近使用时间统计;查看技能正文;发现诊断;新建技能向导(写入 ~/.dsh/skills 或 ~/.agents/skills)。限制:仅用户级技能(user-dsh/user-agents
|
|
5070
|
+
const SKILL_HUB_GUIDANCE = ["本机已安装 dsh-skill-hub 插件(DSH Web GUI 技能中枢):设置 →「技能」分区为管理主页;本插件的配置页(启用/公告开关、圆点颜色、GitHub token、统计窗口)在插件管理页——侧边栏「插件」→ 本插件,是本插件自己注册的配置卡片(默认折叠,点标题展开)。能力:完整本地技能目录(项目/自定义/用户/内置全部来源,走官方 ctx.skills 注册表,含第三方 provider);按来源与自定义分组浏览,分组/来源头部的滑动开关可一键启用/禁用整组(跨组冲突时询问);市场:内置市场目录(精选仓库一键添加)加自定义仓库源,扫描后勾选安装,每个市场源行显示已装/可更新/上游已删数量,支持「检查全部」与「全部更新」;来源跟踪:从 GitHub 仓库(市场源或直接地址)导入的技能记录上游 repo/commit 快照,可检查更新、选择同步、上游删除时跟进删除(移入回收站可恢复,恢复后保留来源与场景归属);个人技能(无来源记录)不跟踪;调用次数与最近使用时间统计;查看技能正文;发现诊断;新建技能向导(写入 ~/.dsh/skills 或 ~/.agents/skills)。限制:仅用户级技能(user-dsh/user-agents 根目录)可写,项目/内置/运行时技能只读展示;路由受 dsh 自身的 /api 信任栅栏保护(回环、监听器绑定地址、--trusted-host 声明的 authority,外加浏览器会话认证),因此反代部署下与 GUI 同一套放行规则。用户提到「技能管理 / 技能列表 / 技能开关 / 技能同步 / 技能市场 / 更新技能 / 新建技能」时即指本插件,请据此协作。", "The dsh-skill-hub plugin is installed (the DSH Web GUI skill hub): Settings → \"Skills\" is the management page; the plugin's configuration page (enable / announcement toggles, dot colors, GitHub token, stats window) is registered by the plugin itself on its own page in the Plugins manager (sidebar → 插件 → the plugin; collapsed until you expand the title). Capabilities: full local skill catalog (project / custom / user / bundled roots via the official ctx.skills registry, including third-party providers); browsing by source and custom groups, each group header carrying a sliding switch to enable/disable the whole group in one click (cross-group conflicts prompt the user); market: a built-in catalog of curated repos (one-click add) plus custom repo sources, scan-and-install import, per-source installed / updatable / deleted-upstream badges with \"check all\" and \"update all\" actions; upstream source tracking: skills imported from GitHub repos (market sources or direct URLs) record the repo/commit snapshot, support update checks, selective sync, and follow-up deletion when the upstream removes a skill (moves it into a restorable trash; restoring keeps the source and scene membership); personal skills (no source record) are never tracked; invocation counts and last-used times; skill body inspection; discovery diagnostics; new-skill wizard (writes to ~/.dsh/skills or ~/.agents/skills). Limits: only user-level skills (user-dsh/user-agents roots) are writable; project/bundled/runtime skills are read-only; the routes sit behind dsh's own /api trust fence (loopback, the listener's bind address, any --trusted-host authority, plus browser-session authentication), so a reverse-proxied deployment is judged exactly like the GUI around it. When the user mentions \"skill management / skill list / skill toggle / skill sync / skill market / update skills / new skill\", this plugin is what they mean — collaborate accordingly."].join("\n\n");
|
|
4790
5071
|
/**
|
|
4791
5072
|
* Mount the skill hub routes and announcement.
|
|
4792
5073
|
* @param ctx - host plugin context carrying webServer/skills/systemPrompt/settings.
|
|
@@ -4877,6 +5158,7 @@ function apply(ctx, config) {
|
|
|
4877
5158
|
const disposers = makeRoutes({
|
|
4878
5159
|
skills: ctx.skills,
|
|
4879
5160
|
store,
|
|
5161
|
+
fence: hostTrustFence(() => ctx.get("connection"), () => ctx.logger.warn("[dsh-skill-hub] Connection service absent: routes fall back to literal-loopback; reverse-proxy deployments will 403 until it mounts")),
|
|
4880
5162
|
invalidate: () => {
|
|
4881
5163
|
providerControl?.invalidate();
|
|
4882
5164
|
},
|
|
@@ -4906,6 +5188,12 @@ function apply(ctx, config) {
|
|
|
4906
5188
|
} catch (error) {
|
|
4907
5189
|
ctx.logger.warn("[dsh-skill-hub] startup disabled-skill reconcile failed", error);
|
|
4908
5190
|
}
|
|
5191
|
+
try {
|
|
5192
|
+
const pruned = await pruneStaleDisabledSkills(store, home);
|
|
5193
|
+
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(", ") || "-"})`);
|
|
5194
|
+
} catch (error) {
|
|
5195
|
+
ctx.logger.warn("[dsh-skill-hub] startup disabled-record prune failed", error);
|
|
5196
|
+
}
|
|
4909
5197
|
})();
|
|
4910
5198
|
(async () => {
|
|
4911
5199
|
try {
|