@nanmicoder/dsh-skills-hub 0.0.1
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/AGENTS.md +9 -0
- package/CONTRIBUTING.md +11 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/README_EN.md +120 -0
- package/README_ZH.md +5 -0
- package/assets/readme/hero-en.png +0 -0
- package/assets/readme/hero-zh.png +0 -0
- package/assets/readme/marketplace.png +0 -0
- package/compatibility.json +10 -0
- package/cordis.patch.yml +5 -0
- package/docs/COMPATIBILITY.md +31 -0
- package/docs/CONTRACT.md +125 -0
- package/docs/RELEASE.md +24 -0
- package/lib/client.js +25115 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +51 -0
- package/lib/index.js +176 -0
- package/lib/market/cache.js +113 -0
- package/lib/market/clawhub-provider.js +598 -0
- package/lib/market/install-service.js +394 -0
- package/lib/market/market-service.js +312 -0
- package/lib/market/provider-fetch.js +216 -0
- package/lib/market/skillhub-provider.js +277 -0
- package/lib/market/types.js +82 -0
- package/lib/skills/installed.js +426 -0
- package/lib/skills/management.js +36 -0
- package/lib/skills/read-text.js +23 -0
- package/lib/skills/root.js +112 -0
- package/lib/types/client/api.d.ts +106 -0
- package/lib/types/client/components/FilterBar.d.ts +20 -0
- package/lib/types/client/components/FrontmatterPanel.d.ts +15 -0
- package/lib/types/client/components/InstallConfirmDialog.d.ts +31 -0
- package/lib/types/client/components/InstallStateBadge.d.ts +11 -0
- package/lib/types/client/components/InstalledSkills.d.ts +3 -0
- package/lib/types/client/components/MarkdownView.d.ts +4 -0
- package/lib/types/client/components/MarketDisclaimer.d.ts +10 -0
- package/lib/types/client/components/MarketHome.d.ts +12 -0
- package/lib/types/client/components/SecurityBadge.d.ts +16 -0
- package/lib/types/client/components/SkillAvatar.d.ts +14 -0
- package/lib/types/client/components/SkillCard.d.ts +17 -0
- package/lib/types/client/components/SkillDetailView.d.ts +21 -0
- package/lib/types/client/components/SourceStatusBar.d.ts +13 -0
- package/lib/types/client/icons.d.ts +71 -0
- package/lib/types/client/index.d.ts +56 -0
- package/lib/types/client/locale-context.d.ts +31 -0
- package/lib/types/client/locales.d.ts +117 -0
- package/lib/types/client/page/MarketPage.d.ts +21 -0
- package/lib/types/client/skill-markdown.d.ts +2 -0
- package/lib/types/client/state.d.ts +119 -0
- package/lib/types/config.d.ts +51 -0
- package/lib/types/index.d.ts +42 -0
- package/lib/types/market/cache.d.ts +49 -0
- package/lib/types/market/clawhub-provider.d.ts +28 -0
- package/lib/types/market/install-service.d.ts +75 -0
- package/lib/types/market/market-service.d.ts +74 -0
- package/lib/types/market/provider-fetch.d.ts +61 -0
- package/lib/types/market/skillhub-provider.d.ts +13 -0
- package/lib/types/market/types.d.ts +163 -0
- package/lib/types/skills/installed.d.ts +117 -0
- package/lib/types/skills/management.d.ts +7 -0
- package/lib/types/skills/read-text.d.ts +5 -0
- package/lib/types/skills/root.d.ts +48 -0
- package/lib/types/web-routes.d.ts +74 -0
- package/lib/web-routes.js +427 -0
- package/package.json +160 -0
- package/release-notes/v0.0.1.md +11 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills Market — install / uninstall service.
|
|
3
|
+
*
|
|
4
|
+
* Install: fetch the whole file list, enforce `MARKET_LIMITS`, verify every
|
|
5
|
+
* provider-reported SHA-256, sanitize the slug into a directory name, stage the
|
|
6
|
+
* complete skill in a hidden sibling directory and publish it with a single
|
|
7
|
+
* `rename` — the skills root never contains a partially written skill, and a
|
|
8
|
+
* failure leaves nothing behind but the (removed) staging directory.
|
|
9
|
+
*
|
|
10
|
+
* Uninstall: only ever removes a directory that carries our provenance sidecar
|
|
11
|
+
* with an id matching the requested skill, and only inside the configured
|
|
12
|
+
* skills root. Every path is resolved and containment-checked before a delete,
|
|
13
|
+
* because "the plugin deleted something outside its own tree" is the one bug a
|
|
14
|
+
* skills market must not have.
|
|
15
|
+
*
|
|
16
|
+
* Ported from the reference `installService.ts` (Claude Code desktop), with the
|
|
17
|
+
* filesystem layout, the sidecar name and the concurrency key all frozen by the
|
|
18
|
+
* interface contract.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
21
|
+
import * as fs from 'node:fs/promises';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import { clawhubProvider } from "./clawhub-provider.js";
|
|
24
|
+
import { skillhubProvider } from "./skillhub-provider.js";
|
|
25
|
+
import { getMarketSkillDetail } from "./market-service.js";
|
|
26
|
+
import { INSTALL_META_FILE, parseInstalledMeta, readSkillHeadline, } from "../skills/installed.js";
|
|
27
|
+
import { MARKET_ERROR_CODES, MARKET_LIMITS, sanitizeDirName, skillId, } from "./types.js";
|
|
28
|
+
const providers = {
|
|
29
|
+
clawhub: clawhubProvider,
|
|
30
|
+
skillhub: skillhubProvider,
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* One in-flight install per *resolved directory*.
|
|
34
|
+
*
|
|
35
|
+
* A second request for the same skill while the first is still downloading is a
|
|
36
|
+
* conflict, not a queue: the caller (the panel) shows one spinner per skill and
|
|
37
|
+
* the reference implementation behaved the same way. The key is the sanitized
|
|
38
|
+
* directory name (`${source}:${dirName}`, which is lowercased) rather than the
|
|
39
|
+
* raw id, because `clawhub:Demo` and `clawhub:demo` publish to the same
|
|
40
|
+
* directory: keying on the raw id let both pass the conflict checks and the
|
|
41
|
+
* loser failed with a raw ENOTEMPTY from the publish rename. `clawhub:demo` and
|
|
42
|
+
* `skillhub:demo` still stay independent.
|
|
43
|
+
*/
|
|
44
|
+
const inFlight = new Map();
|
|
45
|
+
/** File mode of the provenance sidecar; the skills tree is ordinary user data. */
|
|
46
|
+
const META_FILE_MODE = 0o644;
|
|
47
|
+
/** Naming prefix of the hidden staging directory inside the skills root. */
|
|
48
|
+
const STAGING_PREFIX = '.skills-hub-install-';
|
|
49
|
+
/** Naming prefix of the hidden directory a removal is renamed into first. */
|
|
50
|
+
const TRASH_PREFIX = '.skills-hub-trash-';
|
|
51
|
+
/**
|
|
52
|
+
* One rejected install/uninstall carrying both the market error code and the
|
|
53
|
+
* HTTP status the route layer must answer with.
|
|
54
|
+
*
|
|
55
|
+
* The reference used its server-wide `ApiError`; this plugin owns no server
|
|
56
|
+
* middleware, so the code/status pair travels with the error instead of being
|
|
57
|
+
* re-derived from a message string at the edge. Upstream failures are *not*
|
|
58
|
+
* wrapped: W1's `MarketUpstreamError` already carries the code the route maps
|
|
59
|
+
* (`upstreamBadResponse` → 404, everything else → 502).
|
|
60
|
+
*/
|
|
61
|
+
export class MarketInstallError extends Error {
|
|
62
|
+
code;
|
|
63
|
+
status;
|
|
64
|
+
constructor(status, code, message) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = 'MarketInstallError';
|
|
67
|
+
this.code = code;
|
|
68
|
+
this.status = status;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Reject an empty/oversized/traversing provider file path (POSIX separators only). */
|
|
72
|
+
function isSafeRelativeFilePath(filePath) {
|
|
73
|
+
if (filePath === '' || filePath.length > 512)
|
|
74
|
+
return false;
|
|
75
|
+
if (filePath.includes('\0') || filePath.includes('\\'))
|
|
76
|
+
return false;
|
|
77
|
+
if (path.posix.isAbsolute(filePath))
|
|
78
|
+
return false;
|
|
79
|
+
const normalized = path.posix.normalize(filePath);
|
|
80
|
+
if (normalized.startsWith('..') || normalized.startsWith('/'))
|
|
81
|
+
return false;
|
|
82
|
+
return normalized.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
83
|
+
}
|
|
84
|
+
/** `true` only when `child` is a strict descendant of `parent` (both absolute). */
|
|
85
|
+
function isInside(parent, child) {
|
|
86
|
+
const relative = path.relative(parent, child);
|
|
87
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
88
|
+
}
|
|
89
|
+
/** `child` resolved and proved to live strictly below `parent`. */
|
|
90
|
+
function resolveInside(parent, ...segments) {
|
|
91
|
+
const target = path.resolve(parent, ...segments);
|
|
92
|
+
if (!isInside(parent, target)) {
|
|
93
|
+
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Unsafe path outside the skills root: ${target}`);
|
|
94
|
+
}
|
|
95
|
+
return target;
|
|
96
|
+
}
|
|
97
|
+
/** SHA-256 of one fetched file body, hex encoded (matches the provider field). */
|
|
98
|
+
function sha256Hex(content) {
|
|
99
|
+
return createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
100
|
+
}
|
|
101
|
+
/** `true` when `error` is a Node filesystem error with the given code. */
|
|
102
|
+
function hasErrorCode(error, code) {
|
|
103
|
+
return typeof error === 'object' && error !== null && error.code === code;
|
|
104
|
+
}
|
|
105
|
+
/** The upstream detail for a skill, or the market's own "not installable" verdict. */
|
|
106
|
+
async function loadDetail(source, slug) {
|
|
107
|
+
const { skill } = await getMarketSkillDetail(source, slug);
|
|
108
|
+
return skill;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Refuse to publish over anything we do not own.
|
|
112
|
+
*
|
|
113
|
+
* Called once before downloading (fail fast, no wasted upstream traffic) and
|
|
114
|
+
* again immediately before the publish rename, because a manual install can land
|
|
115
|
+
* in between. A symlink is never a valid target: following one would publish
|
|
116
|
+
* into a directory outside the skills root.
|
|
117
|
+
*/
|
|
118
|
+
async function assertInstallTargetFree(target, dirName, id) {
|
|
119
|
+
let stats;
|
|
120
|
+
try {
|
|
121
|
+
stats = await fs.lstat(target);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (hasErrorCode(error, 'ENOENT'))
|
|
125
|
+
return;
|
|
126
|
+
throw new MarketInstallError(500, MARKET_ERROR_CODES.diskError, `Cannot inspect the skill directory "${dirName}"`);
|
|
127
|
+
}
|
|
128
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
129
|
+
throw new MarketInstallError(409, MARKET_ERROR_CODES.notInstallable, `A file or link already occupies the skill directory: ${dirName}`);
|
|
130
|
+
}
|
|
131
|
+
const meta = parseInstalledMeta(await readJsonIfExists(path.join(target, INSTALL_META_FILE)));
|
|
132
|
+
if (meta !== null && meta.id === id) {
|
|
133
|
+
throw new MarketInstallError(409, MARKET_ERROR_CODES.alreadyInstalled, `Skill already installed: ${id}`);
|
|
134
|
+
}
|
|
135
|
+
throw new MarketInstallError(409, MARKET_ERROR_CODES.notInstallable, `Skill directory "${dirName}" was not installed by Skills Hub (name-conflict); remove it manually to continue`);
|
|
136
|
+
}
|
|
137
|
+
/** Read and parse a JSON file; `undefined` for absent or unparsable content. */
|
|
138
|
+
async function readJsonIfExists(filePath) {
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Install one market skill into `options.skillsRoot`.
|
|
148
|
+
*
|
|
149
|
+
* @param source - market the skill comes from.
|
|
150
|
+
* @param slug - provider slug; also the directory name after sanitization.
|
|
151
|
+
* @param options - resolved skills root and the (unused here) uninstall switch,
|
|
152
|
+
* kept in the signature so both verbs take one shape.
|
|
153
|
+
* @returns the absolute installed path and the skill re-annotated as `installed`.
|
|
154
|
+
* @throws {MarketInstallError} for anything this plugin refuses itself.
|
|
155
|
+
* @throws {import('./types.ts').MarketUpstreamError} for upstream failures,
|
|
156
|
+
* unwrapped so the route can classify them (404 vs 502).
|
|
157
|
+
*/
|
|
158
|
+
export async function installMarketSkill(source, slug, options) {
|
|
159
|
+
const dirName = sanitizeDirName(slug);
|
|
160
|
+
if (dirName === null) {
|
|
161
|
+
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill slug cannot be used as a directory name: ${slug}`);
|
|
162
|
+
}
|
|
163
|
+
const id = skillId(source, slug);
|
|
164
|
+
// The lock is keyed by the resolved directory name, not the raw id: the
|
|
165
|
+
// directory name is lowercased, so `clawhub:Demo` and `clawhub:demo` are the
|
|
166
|
+
// same publish target and must serialise.
|
|
167
|
+
const lockKey = skillId(source, dirName);
|
|
168
|
+
if (inFlight.has(lockKey)) {
|
|
169
|
+
throw new MarketInstallError(409, MARKET_ERROR_CODES.installInProgress, `Install already in progress: ${id}`);
|
|
170
|
+
}
|
|
171
|
+
const task = performInstall(source, slug, dirName, id, options.skillsRoot);
|
|
172
|
+
// Store a settled-safe copy: the lock exists to reject *concurrent* work, and
|
|
173
|
+
// an unhandled rejection here would crash the host on every failed install.
|
|
174
|
+
inFlight.set(lockKey, task.catch(() => undefined));
|
|
175
|
+
try {
|
|
176
|
+
return await task;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
inFlight.delete(lockKey);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function performInstall(source, slug, dirName, id, skillsRootInput) {
|
|
183
|
+
const skillsRoot = path.resolve(skillsRootInput);
|
|
184
|
+
const detail = await loadDetail(source, slug);
|
|
185
|
+
if (detail.installState === 'installed') {
|
|
186
|
+
throw new MarketInstallError(409, MARKET_ERROR_CODES.alreadyInstalled, `Skill already installed: ${id}`);
|
|
187
|
+
}
|
|
188
|
+
if (detail.installState === 'not-installable') {
|
|
189
|
+
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill is not installable (${detail.notInstallableReason ?? 'unknown'}): ${slug}`);
|
|
190
|
+
}
|
|
191
|
+
// The cached detail may be older than the provider's version index, so the
|
|
192
|
+
// file list is fetched fresh at install time (mirrors the reference).
|
|
193
|
+
const files = await providers[source].listFiles(slug, detail.version);
|
|
194
|
+
if (files.length === 0 || !files.some((file) => file.path === 'SKILL.md')) {
|
|
195
|
+
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill has no installable SKILL.md: ${slug}`);
|
|
196
|
+
}
|
|
197
|
+
if (files.length > MARKET_LIMITS.maxFileCount) {
|
|
198
|
+
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill has too many files (${files.length} > ${MARKET_LIMITS.maxFileCount}): ${slug}`);
|
|
199
|
+
}
|
|
200
|
+
const advertisedTotal = files.reduce((total, file) => total + (file.size || 0), 0);
|
|
201
|
+
if (files.some((file) => file.size > MARKET_LIMITS.maxFileSize) || advertisedTotal > MARKET_LIMITS.maxTotalSize) {
|
|
202
|
+
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill files exceed the size limit: ${slug}`);
|
|
203
|
+
}
|
|
204
|
+
for (const file of files) {
|
|
205
|
+
if (!isSafeRelativeFilePath(file.path)) {
|
|
206
|
+
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Unsafe file path in skill: ${file.path}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const target = resolveInside(skillsRoot, dirName);
|
|
210
|
+
await assertInstallTargetFree(target, dirName, id);
|
|
211
|
+
await fs.mkdir(skillsRoot, { recursive: true });
|
|
212
|
+
// Staging in the target's own parent makes the publishing rename
|
|
213
|
+
// same-filesystem by construction: there is no cross-device copy path to get
|
|
214
|
+
// wrong, and a crash leaves only a hidden directory that no scanner reads.
|
|
215
|
+
let stagingDir;
|
|
216
|
+
try {
|
|
217
|
+
stagingDir = await fs.mkdtemp(path.join(skillsRoot, STAGING_PREFIX));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
throw new MarketInstallError(500, MARKET_ERROR_CODES.diskError, 'Cannot create a staging directory for the install');
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const ledger = [];
|
|
224
|
+
let actualTotal = 0;
|
|
225
|
+
for (const file of files) {
|
|
226
|
+
const fetched = await providers[source].fetchFile(slug, file.path);
|
|
227
|
+
const bytes = Buffer.byteLength(fetched.content, 'utf-8');
|
|
228
|
+
if (bytes > MARKET_LIMITS.maxFileSize) {
|
|
229
|
+
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill file exceeds the actual size limit: ${file.path}`);
|
|
230
|
+
}
|
|
231
|
+
actualTotal += bytes;
|
|
232
|
+
if (actualTotal > MARKET_LIMITS.maxTotalSize) {
|
|
233
|
+
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill files exceed the actual total size limit: ${slug}`);
|
|
234
|
+
}
|
|
235
|
+
const digest = sha256Hex(fetched.content);
|
|
236
|
+
if (file.sha256 !== undefined && digest !== file.sha256.toLowerCase()) {
|
|
237
|
+
throw new MarketInstallError(502, MARKET_ERROR_CODES.checksumMismatch, `Checksum mismatch for ${file.path} — aborting install`);
|
|
238
|
+
}
|
|
239
|
+
const segments = path.posix.normalize(file.path).split('/');
|
|
240
|
+
const destination = resolveInside(stagingDir, ...segments);
|
|
241
|
+
try {
|
|
242
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
243
|
+
await fs.writeFile(destination, fetched.content, 'utf-8');
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Raw Node errors carry absolute staging paths ("EEXIST: … mkdir
|
|
247
|
+
// '/var/folders/…'"), which must never reach the HTTP envelope.
|
|
248
|
+
throw new MarketInstallError(500, MARKET_ERROR_CODES.diskError, 'Failed to write a skill file into the staging directory');
|
|
249
|
+
}
|
|
250
|
+
ledger.push({ path: segments.join('/'), sha256: digest });
|
|
251
|
+
}
|
|
252
|
+
const installedAt = new Date().toISOString();
|
|
253
|
+
const meta = {
|
|
254
|
+
id,
|
|
255
|
+
source,
|
|
256
|
+
slug,
|
|
257
|
+
...(detail.version === undefined ? {} : { version: detail.version }),
|
|
258
|
+
installedAt,
|
|
259
|
+
files: ledger,
|
|
260
|
+
};
|
|
261
|
+
try {
|
|
262
|
+
// Written with a plain write on purpose: this file lives in the private
|
|
263
|
+
// staging directory and only becomes visible when the whole directory is
|
|
264
|
+
// renamed into place below, so per-file atomicity would add nothing — and
|
|
265
|
+
// the harness' atomic-write package is an *optional* peer, which must not
|
|
266
|
+
// sit on this plugin's module-load path.
|
|
267
|
+
await fs.writeFile(path.join(stagingDir, INSTALL_META_FILE), `${JSON.stringify(meta, null, 2)}\n`, { mode: META_FILE_MODE });
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// Same rule as the file writes above: a raw EISDIR message carries the
|
|
271
|
+
// absolute staging path, which must not reach the HTTP envelope.
|
|
272
|
+
throw new MarketInstallError(500, MARKET_ERROR_CODES.diskError, `Failed to write ${INSTALL_META_FILE} into the staging directory`);
|
|
273
|
+
}
|
|
274
|
+
await assertInstallTargetFree(target, dirName, id);
|
|
275
|
+
try {
|
|
276
|
+
await fs.rename(stagingDir, target);
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
// A directory/file that appeared between the pre-assert and the rename is a
|
|
280
|
+
// name conflict (the exact race `assertInstallTargetFree` cannot close), not
|
|
281
|
+
// a disk failure — and the raw ENOTEMPTY/EEXIST message would leak two
|
|
282
|
+
// absolute paths into the error envelope.
|
|
283
|
+
if (hasErrorCode(error, 'ENOTEMPTY') || hasErrorCode(error, 'EEXIST') || hasErrorCode(error, 'ENOTDIR')) {
|
|
284
|
+
throw new MarketInstallError(409, MARKET_ERROR_CODES.notInstallable, `Skill directory "${dirName}" is occupied by something else (name-conflict); remove it manually to continue`);
|
|
285
|
+
}
|
|
286
|
+
throw new MarketInstallError(500, MARKET_ERROR_CODES.diskError, 'Failed to publish the skill to disk');
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
installedPath: target,
|
|
290
|
+
// The pre-install detail is already the freshest upstream read we are
|
|
291
|
+
// going to make; re-fetching after a successful publish could only add a
|
|
292
|
+
// failure mode where files exist on disk but the caller sees an error.
|
|
293
|
+
skill: {
|
|
294
|
+
...detail,
|
|
295
|
+
installState: 'installed',
|
|
296
|
+
notInstallableReason: undefined,
|
|
297
|
+
installedInfo: {
|
|
298
|
+
dirName,
|
|
299
|
+
...(detail.version === undefined ? {} : { version: detail.version }),
|
|
300
|
+
installedAt,
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
// A no-op once the rename succeeded (the path no longer exists).
|
|
307
|
+
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Remove one skill this plugin installed.
|
|
312
|
+
*
|
|
313
|
+
* @param source - market the skill was installed from.
|
|
314
|
+
* @param slug - provider slug; the directory name after sanitization.
|
|
315
|
+
* @param options - resolved skills root and the `allowUninstall` switch.
|
|
316
|
+
* @returns the removed path and a best-effort refreshed skill record.
|
|
317
|
+
* @throws {MarketInstallError} when the switch is off, the skill is missing, or
|
|
318
|
+
* the directory is not provably ours.
|
|
319
|
+
*/
|
|
320
|
+
export async function uninstallMarketSkill(source, slug, options) {
|
|
321
|
+
if (!options.allowUninstall) {
|
|
322
|
+
throw new MarketInstallError(405, 'METHOD_NOT_ALLOWED', 'Uninstalling skills is disabled by the Skills Hub configuration');
|
|
323
|
+
}
|
|
324
|
+
const dirName = sanitizeDirName(slug);
|
|
325
|
+
const id = skillId(source, slug);
|
|
326
|
+
if (dirName === null) {
|
|
327
|
+
throw new MarketInstallError(404, MARKET_ERROR_CODES.notInstalled, `Skill is not installed: ${slug}`);
|
|
328
|
+
}
|
|
329
|
+
const skillsRoot = path.resolve(options.skillsRoot);
|
|
330
|
+
const target = resolveInside(skillsRoot, dirName);
|
|
331
|
+
let stats;
|
|
332
|
+
try {
|
|
333
|
+
stats = await fs.lstat(target);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
throw new MarketInstallError(404, MARKET_ERROR_CODES.notInstalled, `Skill is not installed: ${slug}`);
|
|
337
|
+
}
|
|
338
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
339
|
+
throw new MarketInstallError(409, MARKET_ERROR_CODES.notManaged, `Skill directory is not a managed directory: ${dirName}`);
|
|
340
|
+
}
|
|
341
|
+
const meta = parseInstalledMeta(await readJsonIfExists(path.join(target, INSTALL_META_FILE)));
|
|
342
|
+
if (meta === null || meta.id !== id) {
|
|
343
|
+
// Never delete a directory the market did not create.
|
|
344
|
+
throw new MarketInstallError(409, MARKET_ERROR_CODES.notManaged, `Skill directory was not installed by Skills Hub: ${dirName}`);
|
|
345
|
+
}
|
|
346
|
+
// Label the response from the directory itself: after the removal the
|
|
347
|
+
// upstream may be unreachable, and the panel still has to name what it removed.
|
|
348
|
+
const headline = await readSkillHeadline(path.join(target, 'SKILL.md'));
|
|
349
|
+
// Rename out of the skills root's namespace first, then delete: the rename is
|
|
350
|
+
// the atomic step that unpublishes the skill, so an interruption can only
|
|
351
|
+
// leave an inert, dot-prefixed directory behind — never a half-deleted skill.
|
|
352
|
+
const trash = resolveInside(skillsRoot, `${TRASH_PREFIX}${dirName}-${randomUUID()}`);
|
|
353
|
+
try {
|
|
354
|
+
await fs.rename(target, trash);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
throw new MarketInstallError(500, MARKET_ERROR_CODES.diskError, 'Failed to remove the skill directory');
|
|
358
|
+
}
|
|
359
|
+
await fs.rm(trash, { recursive: true, force: true }).catch(() => undefined);
|
|
360
|
+
return { removedPath: target, skill: await refreshRemovedSkill(source, slug, id, dirName, meta, headline) };
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Describe a just-removed skill for the response.
|
|
364
|
+
*
|
|
365
|
+
* Best effort: the upstream read is what makes the panel's patch exact, but the
|
|
366
|
+
* removal already happened, so an unreachable provider degrades to a record
|
|
367
|
+
* built from the sidecar and the skill's own frontmatter instead of turning a
|
|
368
|
+
* successful uninstall into an error.
|
|
369
|
+
*/
|
|
370
|
+
async function refreshRemovedSkill(source, slug, id, dirName, meta, headline) {
|
|
371
|
+
try {
|
|
372
|
+
const { skill } = await getMarketSkillDetail(source, slug);
|
|
373
|
+
return { ...skill, installState: 'installable', notInstallableReason: undefined, installedInfo: undefined };
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return {
|
|
377
|
+
id,
|
|
378
|
+
source,
|
|
379
|
+
slug,
|
|
380
|
+
name: headline?.name ?? dirName,
|
|
381
|
+
summary: headline?.summary ?? '',
|
|
382
|
+
author: { handle: '' },
|
|
383
|
+
stats: { downloads: 0 },
|
|
384
|
+
tags: [],
|
|
385
|
+
...(meta.version === undefined ? {} : { version: meta.version }),
|
|
386
|
+
securityStatus: 'unknown',
|
|
387
|
+
installState: 'installable',
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** Drop every install lock. Test hook, mirroring the reference implementation. */
|
|
392
|
+
export function resetInstallLocksForTests() {
|
|
393
|
+
inFlight.clear();
|
|
394
|
+
}
|