@nanmicoder/dsh-skills-hub 0.0.1 → 0.0.3
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 +22 -0
- package/README.md +32 -82
- package/README_EN.md +32 -82
- package/README_ZH.md +2 -0
- package/assets/readme/marketplace.png +0 -0
- package/compatibility.json +1 -1
- package/docs/CATALOG.md +39 -0
- package/docs/COMPATIBILITY.md +46 -0
- package/docs/CONFIGURATION.md +38 -0
- package/docs/CONTRACT.md +35 -12
- package/docs/RELEASE.md +5 -5
- package/lib/catalog/catalog.js +89 -0
- package/lib/catalog/skills.json +8673 -0
- package/lib/client.js +3349 -1728
- package/lib/client.js.map +1 -1
- package/lib/config.js +2 -0
- package/lib/index.js +16 -6
- package/lib/market/cache.js +104 -30
- package/lib/market/clawhub-provider.js +139 -22
- package/lib/market/disk-cache.js +113 -0
- package/lib/market/install-service.js +24 -11
- package/lib/market/market-service.js +161 -32
- package/lib/market/provider-fetch.js +9 -2
- package/lib/market/skillhub-provider.js +6 -2
- package/lib/market/types.js +2 -1
- package/lib/skills/installed.js +5 -0
- package/lib/skills/management.js +182 -4
- package/lib/skills/root.js +1 -1
- package/lib/types/catalog/catalog.d.ts +73 -0
- package/lib/types/client/api.d.ts +45 -8
- package/lib/types/client/components/CategoryBar.d.ts +13 -0
- package/lib/types/client/components/FilterBar.d.ts +4 -0
- package/lib/types/client/components/InstallConfirmDialog.d.ts +20 -11
- package/lib/types/client/components/InstalledSkillDetail.d.ts +24 -0
- package/lib/types/client/components/InstalledSkills.d.ts +18 -1
- package/lib/types/client/components/MarkdownView.d.ts +7 -1
- package/lib/types/client/components/MarketSkillDetail.d.ts +34 -0
- package/lib/types/client/components/SkillAvatar.d.ts +1 -1
- package/lib/types/client/components/SkillDetailShell.d.ts +86 -0
- package/lib/types/client/components/SkillFiles.d.ts +32 -0
- package/lib/types/client/components/SourceStatusBar.d.ts +4 -0
- package/lib/types/client/icons.d.ts +4 -0
- package/lib/types/client/locales.d.ts +94 -0
- package/lib/types/client/relative-time.d.ts +13 -0
- package/lib/types/client/skill-insights.d.ts +30 -0
- package/lib/types/client/skill-markdown.d.ts +39 -0
- package/lib/types/client/state.d.ts +36 -7
- package/lib/types/config.d.ts +2 -0
- package/lib/types/market/cache.d.ts +62 -14
- package/lib/types/market/clawhub-provider.d.ts +15 -0
- package/lib/types/market/disk-cache.d.ts +16 -0
- package/lib/types/market/install-service.d.ts +1 -0
- package/lib/types/market/market-service.d.ts +40 -6
- package/lib/types/market/provider-fetch.d.ts +2 -0
- package/lib/types/market/types.d.ts +29 -0
- package/lib/types/skills/installed.d.ts +4 -0
- package/lib/types/skills/management.d.ts +63 -2
- package/lib/types/skills/root.d.ts +8 -0
- package/lib/types/web-routes.d.ts +3 -1
- package/lib/web-routes.js +85 -8
- package/package.json +1 -1
- package/release-notes/v0.0.1.md +1 -1
- package/release-notes/v0.0.2.md +12 -0
- package/release-notes/v0.0.3.md +12 -0
- package/lib/types/client/components/InstallStateBadge.d.ts +0 -11
- package/lib/types/client/components/SecurityBadge.d.ts +0 -16
- package/lib/types/client/components/SkillDetailView.d.ts +0 -21
|
@@ -22,7 +22,8 @@ import * as fs from 'node:fs/promises';
|
|
|
22
22
|
import * as path from 'node:path';
|
|
23
23
|
import { clawhubProvider } from "./clawhub-provider.js";
|
|
24
24
|
import { skillhubProvider } from "./skillhub-provider.js";
|
|
25
|
-
import { getMarketSkillDetail } from "./market-service.js";
|
|
25
|
+
import { getMarketSkillDetail, resolveMarketSkill, withMarketOwner } from "./market-service.js";
|
|
26
|
+
import { withProviderConfiguration } from "./provider-fetch.js";
|
|
26
27
|
import { INSTALL_META_FILE, parseInstalledMeta, readSkillHeadline, } from "../skills/installed.js";
|
|
27
28
|
import { MARKET_ERROR_CODES, MARKET_LIMITS, sanitizeDirName, skillId, } from "./types.js";
|
|
28
29
|
const providers = {
|
|
@@ -103,9 +104,14 @@ function hasErrorCode(error, code) {
|
|
|
103
104
|
return typeof error === 'object' && error !== null && error.code === code;
|
|
104
105
|
}
|
|
105
106
|
/** The upstream detail for a skill, or the market's own "not installable" verdict. */
|
|
106
|
-
async function loadDetail(source, slug) {
|
|
107
|
-
const
|
|
108
|
-
|
|
107
|
+
async function loadDetail(source, slug, owner) {
|
|
108
|
+
const detail = await resolveMarketSkill(source, slug, owner);
|
|
109
|
+
// The bytes must belong to the author the reader chose: a registry that
|
|
110
|
+
// answers for another owner is refused rather than installed.
|
|
111
|
+
if (owner && detail.author.handle && detail.author.handle !== owner) {
|
|
112
|
+
throw new MarketInstallError(409, MARKET_ERROR_CODES.notInstallable, `Skill ${slug} resolved to ${detail.author.handle}, not the requested ${owner}`);
|
|
113
|
+
}
|
|
114
|
+
return detail;
|
|
109
115
|
}
|
|
110
116
|
/**
|
|
111
117
|
* Refuse to publish over anything we do not own.
|
|
@@ -155,7 +161,11 @@ async function readJsonIfExists(filePath) {
|
|
|
155
161
|
* @throws {import('./types.ts').MarketUpstreamError} for upstream failures,
|
|
156
162
|
* unwrapped so the route can classify them (404 vs 502).
|
|
157
163
|
*/
|
|
158
|
-
export
|
|
164
|
+
export function installMarketSkill(source, slug, options) {
|
|
165
|
+
// One owner for the manifest, the file list and every file read.
|
|
166
|
+
return withProviderConfiguration(() => withMarketOwner(source, slug, options.owner, () => installMarketSkillWithConfiguration(source, slug, options)));
|
|
167
|
+
}
|
|
168
|
+
async function installMarketSkillWithConfiguration(source, slug, options) {
|
|
159
169
|
const dirName = sanitizeDirName(slug);
|
|
160
170
|
if (dirName === null) {
|
|
161
171
|
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill slug cannot be used as a directory name: ${slug}`);
|
|
@@ -168,7 +178,7 @@ export async function installMarketSkill(source, slug, options) {
|
|
|
168
178
|
if (inFlight.has(lockKey)) {
|
|
169
179
|
throw new MarketInstallError(409, MARKET_ERROR_CODES.installInProgress, `Install already in progress: ${id}`);
|
|
170
180
|
}
|
|
171
|
-
const task = performInstall(source, slug, dirName, id, options.skillsRoot);
|
|
181
|
+
const task = performInstall(source, slug, dirName, id, options.skillsRoot, options.owner);
|
|
172
182
|
// Store a settled-safe copy: the lock exists to reject *concurrent* work, and
|
|
173
183
|
// an unhandled rejection here would crash the host on every failed install.
|
|
174
184
|
inFlight.set(lockKey, task.catch(() => undefined));
|
|
@@ -179,17 +189,16 @@ export async function installMarketSkill(source, slug, options) {
|
|
|
179
189
|
inFlight.delete(lockKey);
|
|
180
190
|
}
|
|
181
191
|
}
|
|
182
|
-
async function performInstall(source, slug, dirName, id, skillsRootInput) {
|
|
192
|
+
async function performInstall(source, slug, dirName, id, skillsRootInput, requestedOwner) {
|
|
183
193
|
const skillsRoot = path.resolve(skillsRootInput);
|
|
184
|
-
const detail = await loadDetail(source, slug);
|
|
194
|
+
const detail = await loadDetail(source, slug, requestedOwner);
|
|
185
195
|
if (detail.installState === 'installed') {
|
|
186
196
|
throw new MarketInstallError(409, MARKET_ERROR_CODES.alreadyInstalled, `Skill already installed: ${id}`);
|
|
187
197
|
}
|
|
188
198
|
if (detail.installState === 'not-installable') {
|
|
189
199
|
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill is not installable (${detail.notInstallableReason ?? 'unknown'}): ${slug}`);
|
|
190
200
|
}
|
|
191
|
-
//
|
|
192
|
-
// file list is fetched fresh at install time (mirrors the reference).
|
|
201
|
+
// Re-read the file inventory for the freshly resolved manifest version.
|
|
193
202
|
const files = await providers[source].listFiles(slug, detail.version);
|
|
194
203
|
if (files.length === 0 || !files.some((file) => file.path === 'SKILL.md')) {
|
|
195
204
|
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill has no installable SKILL.md: ${slug}`);
|
|
@@ -250,10 +259,14 @@ async function performInstall(source, slug, dirName, id, skillsRootInput) {
|
|
|
250
259
|
ledger.push({ path: segments.join('/'), sha256: digest });
|
|
251
260
|
}
|
|
252
261
|
const installedAt = new Date().toISOString();
|
|
262
|
+
// The author is recorded so "view in marketplace" and later reads find this
|
|
263
|
+
// exact skill again, not another one sharing its slug.
|
|
264
|
+
const owner = detail.author.handle || requestedOwner;
|
|
253
265
|
const meta = {
|
|
254
266
|
id,
|
|
255
267
|
source,
|
|
256
268
|
slug,
|
|
269
|
+
...(owner ? { owner } : {}),
|
|
257
270
|
...(detail.version === undefined ? {} : { version: detail.version }),
|
|
258
271
|
installedAt,
|
|
259
272
|
files: ledger,
|
|
@@ -369,7 +382,7 @@ export async function uninstallMarketSkill(source, slug, options) {
|
|
|
369
382
|
*/
|
|
370
383
|
async function refreshRemovedSkill(source, slug, id, dirName, meta, headline) {
|
|
371
384
|
try {
|
|
372
|
-
const { skill } = await getMarketSkillDetail(source, slug);
|
|
385
|
+
const { skill } = await getMarketSkillDetail(source, slug, { owner: meta.owner });
|
|
373
386
|
return { ...skill, installState: 'installable', notInstallableReason: undefined, installedInfo: undefined };
|
|
374
387
|
}
|
|
375
388
|
catch {
|
|
@@ -5,19 +5,24 @@
|
|
|
5
5
|
* cross-source dedupe, per-source health/degradation reporting, TTL caching
|
|
6
6
|
* (stale-while-error) and locally-computed install state.
|
|
7
7
|
*
|
|
8
|
-
* The module is deliberately free of DSH services
|
|
8
|
+
* The module is deliberately free of DSH services: the
|
|
9
9
|
* "what is installed locally" question is answered by an injected
|
|
10
10
|
* `InstalledLookup` (see `setInstalledLookup`), so this file can be unit-tested
|
|
11
11
|
* by pointing the providers at a stub server.
|
|
12
12
|
*/
|
|
13
|
-
import { getSourceHealth, markSourceHealth,
|
|
14
|
-
import {
|
|
13
|
+
import { getSourceHealth, markSourceHealth, marketCache, noteMarketStat } from "./cache.js";
|
|
14
|
+
import { getProviderBase, withProviderConfiguration } from "./provider-fetch.js";
|
|
15
|
+
import { clawhubOwnerFor, clawhubProvider, setClawhubOwnerHints, withClawhubOwner } from "./clawhub-provider.js";
|
|
16
|
+
import { catalogCategories, catalogClawhubOwners, catalogEntryFor, catalogEntryToSkill, filterCatalog, getCatalog, } from "../catalog/catalog.js";
|
|
15
17
|
import { skillhubProvider } from "./skillhub-provider.js";
|
|
16
18
|
import { MARKET_LIMITS, MARKET_SOURCES, detectMarketLanguage, sanitizeDirName, skillId, } from "./types.js";
|
|
17
19
|
const providers = {
|
|
18
20
|
clawhub: clawhubProvider,
|
|
19
21
|
skillhub: skillhubProvider,
|
|
20
22
|
};
|
|
23
|
+
// Catalogue entries name an exact ClawHub owner; detail, files and install must
|
|
24
|
+
// resolve the same skill the card showed.
|
|
25
|
+
setClawhubOwnerHints(catalogClawhubOwners());
|
|
21
26
|
/** Nothing is installed until the host installs its real lookup. */
|
|
22
27
|
const EMPTY_INSTALLED_LOOKUP = {
|
|
23
28
|
has: () => false,
|
|
@@ -128,7 +133,9 @@ export function dedupeSkills(items) {
|
|
|
128
133
|
for (const item of copies) {
|
|
129
134
|
if (item.source === 'skillhub' && item.upstream?.slug) {
|
|
130
135
|
const original = byClawhubSlug.get(item.upstream.slug);
|
|
131
|
-
|
|
136
|
+
// Same slug, different author: the mirror copies another skill.
|
|
137
|
+
const sameAuthor = !item.upstream.owner || !original?.author.handle || item.upstream.owner === original.author.handle;
|
|
138
|
+
if (original && sameAuthor) {
|
|
132
139
|
original.mirrors = [...(original.mirrors ?? []), item.id];
|
|
133
140
|
// Enrich the original with SkillHub-only data.
|
|
134
141
|
if (!original.iconUrl && item.iconUrl)
|
|
@@ -154,23 +161,35 @@ export function dedupeSkills(items) {
|
|
|
154
161
|
* segment so every key is obviously source-scoped.
|
|
155
162
|
*/
|
|
156
163
|
function providerPageCacheKey(source, kind, params) {
|
|
157
|
-
return `${kind}:${source}:${JSON.stringify([params.q ?? null, params.cursor ?? null, params.limit])}`;
|
|
164
|
+
return `${kind}:${source}:${JSON.stringify([getProviderBase(source), params.q ?? null, params.cursor ?? null, params.limit])}`;
|
|
158
165
|
}
|
|
159
|
-
async function fetchProviderPage(source, params) {
|
|
166
|
+
async function fetchProviderPage(source, params, cache, options = {}) {
|
|
160
167
|
const isSearch = Boolean(params.q);
|
|
161
168
|
const cacheKey = providerPageCacheKey(source, isSearch ? 'search' : 'list', params);
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
169
|
+
const forced = options.force === true;
|
|
170
|
+
if (forced) {
|
|
171
|
+
// A reader asked for this page on purpose; the entry is still rewritten
|
|
172
|
+
// below, so the refresh costs one request and then serves everyone again.
|
|
173
|
+
noteMarketStat('forcedRefreshes');
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
const hit = await cache.getRecord(cacheKey);
|
|
177
|
+
if (hit) {
|
|
178
|
+
noteMarketStat('hits');
|
|
179
|
+
// The real fetch time travels with the payload: a hit is a snapshot, and
|
|
180
|
+
// the panel has to be able to say how old it is.
|
|
181
|
+
return { page: hit.value, status: { status: 'ok', fetchedAt: hit.storedAt, fromCache: true } };
|
|
182
|
+
}
|
|
166
183
|
}
|
|
184
|
+
noteMarketStat('misses');
|
|
167
185
|
try {
|
|
168
186
|
const q = params.q;
|
|
169
187
|
const page = isSearch && q !== undefined
|
|
170
188
|
? await providers[source].search({ q, cursor: params.cursor, limit: params.limit })
|
|
171
189
|
: await providers[source].list({ cursor: params.cursor, limit: params.limit });
|
|
172
|
-
|
|
173
|
-
|
|
190
|
+
noteMarketStat('upstreamRequests');
|
|
191
|
+
const storedAt = await cache.set(cacheKey, page);
|
|
192
|
+
return { page, status: { status: 'ok', fetchedAt: storedAt, fromCache: false } };
|
|
174
193
|
}
|
|
175
194
|
catch (error) {
|
|
176
195
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -181,8 +200,9 @@ async function fetchProviderPage(source, params) {
|
|
|
181
200
|
markSourceHealth(source, 'failed', message);
|
|
182
201
|
// Stale-while-error: a degraded source still serves its last good page, so a
|
|
183
202
|
// temporary upstream outage does not empty the panel.
|
|
184
|
-
const stale =
|
|
203
|
+
const stale = await cache.getStale(cacheKey);
|
|
185
204
|
if (stale) {
|
|
205
|
+
noteMarketStat('staleServed');
|
|
186
206
|
return {
|
|
187
207
|
page: stale.value,
|
|
188
208
|
status: { status: 'cached', fetchedAt: stale.storedAt, fromCache: true, error: message },
|
|
@@ -191,7 +211,13 @@ async function fetchProviderPage(source, params) {
|
|
|
191
211
|
return { page: null, status: { ...getSourceHealth(source), fromCache: false, error: message } };
|
|
192
212
|
}
|
|
193
213
|
}
|
|
194
|
-
export
|
|
214
|
+
export function listMarketSkills(params) {
|
|
215
|
+
if (params.scope !== 'market')
|
|
216
|
+
return Promise.resolve(listCatalogSkills(params));
|
|
217
|
+
const cache = marketCache;
|
|
218
|
+
return withProviderConfiguration(() => listMarketSkillsWithCache(params, cache));
|
|
219
|
+
}
|
|
220
|
+
async function listMarketSkillsWithCache(params, cache) {
|
|
195
221
|
const cursor = decodeCursor(params.cursor);
|
|
196
222
|
const isFirstPage = !params.cursor;
|
|
197
223
|
const activeSources = params.source === 'all' ? MARKET_SOURCES : [params.source];
|
|
@@ -207,7 +233,7 @@ export async function listMarketSkills(params) {
|
|
|
207
233
|
// ClawHub search has no pagination: ask it for one big page and let the
|
|
208
234
|
// aggregate cap apply, instead of pretending a second page exists.
|
|
209
235
|
const limit = q && source === 'clawhub' ? MARKET_LIMITS.searchResultCap : params.limit;
|
|
210
|
-
outcomes.set(source, await fetchProviderPage(source, { q, cursor: providerCursor, limit }));
|
|
236
|
+
outcomes.set(source, await fetchProviderPage(source, { q, cursor: providerCursor, limit }, cache, { force: params.refresh === true }));
|
|
211
237
|
}));
|
|
212
238
|
let merged = [];
|
|
213
239
|
const nextCursor = {};
|
|
@@ -238,21 +264,94 @@ export async function listMarketSkills(params) {
|
|
|
238
264
|
}
|
|
239
265
|
return { items: merged, nextCursor: encodeCursor(nextCursor), sources };
|
|
240
266
|
}
|
|
267
|
+
// ─── Curated catalogue ───────────────────────────────────────────────────────
|
|
268
|
+
const CATALOG_CURSOR_PREFIX = 'catalog:';
|
|
269
|
+
/** Provenance of every catalogue answer: one snapshot, read when it was generated. */
|
|
270
|
+
function catalogStatus() {
|
|
271
|
+
return { status: 'ok', fetchedAt: getCatalog().generatedAt, fromCache: true };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* One page of the curated catalogue.
|
|
275
|
+
*
|
|
276
|
+
* Everything is local, so every filter — security and installed included — runs
|
|
277
|
+
* before pagination: a page is always full until the list is exhausted, which
|
|
278
|
+
* is what keeps infinite scroll from stalling on a filtered-out page.
|
|
279
|
+
*/
|
|
280
|
+
export function listCatalogSkills(params) {
|
|
281
|
+
let items = filterCatalog({ q: params.q, category: params.category, source: params.source })
|
|
282
|
+
.map(catalogEntryToSkill)
|
|
283
|
+
.map((item) => annotateInstallState(item));
|
|
284
|
+
if (params.security !== 'all')
|
|
285
|
+
items = items.filter((item) => item.securityStatus === params.security);
|
|
286
|
+
if (params.installed !== 'all') {
|
|
287
|
+
items = items.filter((item) => params.installed === 'installed' ? item.installState === 'installed' : item.installState !== 'installed');
|
|
288
|
+
}
|
|
289
|
+
const raw = params.cursor?.startsWith(CATALOG_CURSOR_PREFIX) ? params.cursor.slice(CATALOG_CURSOR_PREFIX.length) : '';
|
|
290
|
+
const offset = Math.max(0, Number.parseInt(raw, 10) || 0);
|
|
291
|
+
const end = offset + params.limit;
|
|
292
|
+
const status = catalogStatus();
|
|
293
|
+
return {
|
|
294
|
+
items: items.slice(offset, end),
|
|
295
|
+
nextCursor: end < items.length ? `${CATALOG_CURSOR_PREFIX}${end}` : null,
|
|
296
|
+
total: items.length,
|
|
297
|
+
sources: { clawhub: status, skillhub: status },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
export function listMarketCategories() {
|
|
301
|
+
return { items: catalogCategories(), status: catalogStatus() };
|
|
302
|
+
}
|
|
241
303
|
// ─── Detail / file content ───────────────────────────────────────────────────
|
|
242
|
-
export
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
304
|
+
export function getMarketSkillDetail(source, slug, options = {}) {
|
|
305
|
+
const cache = marketCache;
|
|
306
|
+
return withProviderConfiguration(() => withMarketOwner(source, slug, options.owner, () => getMarketSkillDetailWithCache(source, slug, options, cache))).then((result) => ({ ...result, skill: withCatalogMetadata(result.skill) }));
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Overlay the catalogue's editorial fields on a live detail.
|
|
310
|
+
*
|
|
311
|
+
* Upstream stays authoritative for everything it owns (version, files,
|
|
312
|
+
* security, stats); the catalogue only adds what upstream does not have — our
|
|
313
|
+
* category, the editor's pick and the reader-facing Chinese summary.
|
|
314
|
+
*/
|
|
315
|
+
function withCatalogMetadata(detail) {
|
|
316
|
+
const entry = catalogEntryFor(detail.source, detail.slug);
|
|
317
|
+
if (!entry)
|
|
318
|
+
return detail;
|
|
319
|
+
return {
|
|
320
|
+
...detail,
|
|
321
|
+
category: entry.category,
|
|
322
|
+
featured: entry.featured === true ? true : undefined,
|
|
323
|
+
summary: entry.summary || detail.summary,
|
|
324
|
+
tags: detail.tags.length > 0 ? detail.tags : [...entry.tags],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
async function getMarketSkillDetailWithCache(source, slug, options, cache) {
|
|
328
|
+
const cacheKey = `detail:${source}:${JSON.stringify([getProviderBase(source), slug, ...ownerKeyPart(source, slug)])}`;
|
|
329
|
+
const forced = options.force === true;
|
|
330
|
+
if (forced)
|
|
331
|
+
noteMarketStat('forcedRefreshes');
|
|
332
|
+
const hit = forced ? undefined : await cache.getRecord(cacheKey);
|
|
333
|
+
let detail;
|
|
334
|
+
let sourceStatus;
|
|
335
|
+
if (hit) {
|
|
336
|
+
noteMarketStat('hits');
|
|
337
|
+
detail = hit.value;
|
|
338
|
+
sourceStatus = { status: 'ok', fetchedAt: hit.storedAt, fromCache: true };
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
noteMarketStat('misses');
|
|
247
342
|
try {
|
|
248
343
|
detail = await providers[source].detail(slug);
|
|
249
|
-
|
|
250
|
-
|
|
344
|
+
noteMarketStat('upstreamRequests');
|
|
345
|
+
const storedAt = await cache.set(cacheKey, detail);
|
|
346
|
+
sourceStatus = { status: 'ok', fetchedAt: storedAt, fromCache: false };
|
|
251
347
|
}
|
|
252
348
|
catch (error) {
|
|
253
|
-
|
|
349
|
+
if (options.allowStale === false)
|
|
350
|
+
throw error;
|
|
351
|
+
const stale = await cache.getStale(cacheKey);
|
|
254
352
|
if (!stale)
|
|
255
353
|
throw error;
|
|
354
|
+
noteMarketStat('staleServed');
|
|
256
355
|
detail = stale.value;
|
|
257
356
|
sourceStatus = {
|
|
258
357
|
status: 'cached',
|
|
@@ -274,13 +373,36 @@ export function isValidMarketFilePath(filePath) {
|
|
|
274
373
|
return false;
|
|
275
374
|
return true;
|
|
276
375
|
}
|
|
277
|
-
export
|
|
376
|
+
export function getMarketFileContent(source, slug, filePath, owner) {
|
|
377
|
+
const cache = marketCache;
|
|
378
|
+
return withProviderConfiguration(() => withMarketOwner(source, slug, owner, () => getMarketFileContentWithCache(source, slug, filePath, cache)));
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Pin a ClawHub read to the owner the reader asked for. ClawHub slugs are
|
|
382
|
+
* shared across authors, so `clawhub:<slug>` alone does not name one skill.
|
|
383
|
+
*/
|
|
384
|
+
export function withMarketOwner(source, slug, owner, operation) {
|
|
385
|
+
return source === 'clawhub' ? withClawhubOwner(slug, owner, operation) : operation();
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Cache keys of owner-dependent reads carry the owner the read will use, so a
|
|
389
|
+
* snapshot of one author's skill is never served for another's. Appended only
|
|
390
|
+
* when known, keeping unambiguous slugs' keys unchanged.
|
|
391
|
+
*/
|
|
392
|
+
function ownerKeyPart(source, slug) {
|
|
393
|
+
const owner = source === 'clawhub' ? clawhubOwnerFor(slug) : undefined;
|
|
394
|
+
return owner ? [`owner=${owner}`] : [];
|
|
395
|
+
}
|
|
396
|
+
async function getMarketFileContentWithCache(source, slug, filePath, cache) {
|
|
278
397
|
// JSON-encoded for the same reason as the list keys: slug and filePath are
|
|
279
398
|
// both caller-controlled, so a `:`-joined key is ambiguous.
|
|
280
|
-
const cacheKey = `file:${source}:${JSON.stringify([slug, filePath])}`;
|
|
281
|
-
const cached =
|
|
282
|
-
if (cached)
|
|
283
|
-
|
|
399
|
+
const cacheKey = `file:${source}:${JSON.stringify([getProviderBase(source), slug, filePath, ...ownerKeyPart(source, slug)])}`;
|
|
400
|
+
const cached = await cache.getRecord(cacheKey);
|
|
401
|
+
if (cached) {
|
|
402
|
+
noteMarketStat('hits');
|
|
403
|
+
return cached.value;
|
|
404
|
+
}
|
|
405
|
+
noteMarketStat('misses');
|
|
284
406
|
const fetched = await providers[source].fetchFile(slug, filePath);
|
|
285
407
|
let content = fetched.content;
|
|
286
408
|
let truncated = false;
|
|
@@ -295,7 +417,7 @@ export async function getMarketFileContent(source, slug, filePath) {
|
|
|
295
417
|
size: fetched.size,
|
|
296
418
|
truncated,
|
|
297
419
|
};
|
|
298
|
-
|
|
420
|
+
await cache.set(cacheKey, result);
|
|
299
421
|
return result;
|
|
300
422
|
}
|
|
301
423
|
// ─── Status ──────────────────────────────────────────────────────────────────
|
|
@@ -305,8 +427,15 @@ export function getMarketStatus() {
|
|
|
305
427
|
skillhub: getSourceHealth('skillhub'),
|
|
306
428
|
};
|
|
307
429
|
}
|
|
308
|
-
/**
|
|
309
|
-
|
|
310
|
-
|
|
430
|
+
/**
|
|
431
|
+
* Look up a single skill for an install (the detail path, bypassing the list).
|
|
432
|
+
*
|
|
433
|
+
* Always forced: a cached manifest is a snapshot of the *catalogue*, and
|
|
434
|
+
* installing is the one action that has to be authorized by what upstream says
|
|
435
|
+
* right now — the file bytes are fetched fresh and hash-verified against the
|
|
436
|
+
* list, so the list must come from the same generation as those bytes.
|
|
437
|
+
*/
|
|
438
|
+
export async function resolveMarketSkill(source, slug, owner) {
|
|
439
|
+
const { skill } = await getMarketSkillDetail(source, slug, { force: true, allowStale: false, owner });
|
|
311
440
|
return skill;
|
|
312
441
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* `process.env` at module scope, so the module (and every provider built on it)
|
|
11
11
|
* is directly unit-testable by pointing the bases at a local stub.
|
|
12
12
|
*/
|
|
13
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
13
14
|
import { markSourceHealth } from "./cache.js";
|
|
14
15
|
import { MARKET_ERROR_CODES, MARKET_LIMITS, MarketUpstreamError } from "./types.js";
|
|
15
16
|
const DEFAULT_CONFIG = {
|
|
@@ -20,6 +21,11 @@ const DEFAULT_CONFIG = {
|
|
|
20
21
|
userAgent: 'dsh-skills-hub',
|
|
21
22
|
};
|
|
22
23
|
let currentConfig = { ...DEFAULT_CONFIG };
|
|
24
|
+
const requestConfig = new AsyncLocalStorage();
|
|
25
|
+
/** Keep cache keys and all chained upstream reads on one configuration snapshot. */
|
|
26
|
+
export function withProviderConfiguration(operation) {
|
|
27
|
+
return requestConfig.run(requestConfig.getStore() ?? currentConfig, operation);
|
|
28
|
+
}
|
|
23
29
|
/** Strip trailing slashes so `new URL('/api/...', base)` never doubles a separator. */
|
|
24
30
|
function normalizeBaseUrl(value) {
|
|
25
31
|
return value.replace(/\/+$/, '');
|
|
@@ -51,7 +57,8 @@ export function configureProviderFetch(config) {
|
|
|
51
57
|
}
|
|
52
58
|
/** Base URL of one upstream, as resolved from the active configuration. */
|
|
53
59
|
export function getProviderBase(source) {
|
|
54
|
-
|
|
60
|
+
const config = requestConfig.getStore() ?? currentConfig;
|
|
61
|
+
return source === 'clawhub' ? config.clawhubBaseUrl : config.skillhubBaseUrl;
|
|
55
62
|
}
|
|
56
63
|
/**
|
|
57
64
|
* An upstream answered with a non-2xx status (or a retryable 429/5xx).
|
|
@@ -93,7 +100,7 @@ function requestHeaders(init, userAgent) {
|
|
|
93
100
|
* provider can map it to a meaningful error.
|
|
94
101
|
*/
|
|
95
102
|
export async function providerFetch(source, url, init) {
|
|
96
|
-
const config = currentConfig;
|
|
103
|
+
const config = requestConfig.getStore() ?? currentConfig;
|
|
97
104
|
const attempts = Math.max(1, config.retries + 1);
|
|
98
105
|
let lastError;
|
|
99
106
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
@@ -13,7 +13,7 @@ import { getProviderBase, providerFetch, providerFetchJson, readResponseTextWith
|
|
|
13
13
|
import { detectMarketLanguage, MARKET_ERROR_CODES, MARKET_LIMITS, MarketUpstreamError, skillId, } from "./types.js";
|
|
14
14
|
// The frontmatter parser is shared with the ClawHub provider: both sources hand
|
|
15
15
|
// us a raw SKILL.md and the detail contract promises a frontmatter-free body.
|
|
16
|
-
import { parseFrontmatter } from "./clawhub-provider.js";
|
|
16
|
+
import { meaningfulChangelog, parseFrontmatter } from "./clawhub-provider.js";
|
|
17
17
|
import { markSourceHealth } from "./cache.js";
|
|
18
18
|
/**
|
|
19
19
|
* Vendor statuses that mean "no findings". Anything else (suspicious,
|
|
@@ -88,8 +88,9 @@ function parseUpstream(item) {
|
|
|
88
88
|
try {
|
|
89
89
|
const segments = new URL(upstreamUrl).pathname.split('/').filter(Boolean);
|
|
90
90
|
const slug = segments[segments.length - 1];
|
|
91
|
+
const owner = segments.length > 1 ? segments[segments.length - 2] : undefined;
|
|
91
92
|
if (slug)
|
|
92
|
-
return { source: 'clawhub', slug };
|
|
93
|
+
return { source: 'clawhub', slug, ...(owner ? { owner } : {}) };
|
|
93
94
|
}
|
|
94
95
|
catch {
|
|
95
96
|
// Malformed upstream URL — treat as a native entry.
|
|
@@ -200,6 +201,7 @@ export const skillhubProvider = {
|
|
|
200
201
|
}
|
|
201
202
|
const security = mapSecurity(data.securityReports, data.skill.verified === undefined ? undefined : Boolean(data.skill.verified));
|
|
202
203
|
const version = asString(data.latestVersion?.version) || item.version;
|
|
204
|
+
const changelog = meaningfulChangelog(data.latestVersion?.changelog);
|
|
203
205
|
let files = [];
|
|
204
206
|
try {
|
|
205
207
|
files = await skillhubProvider.listFiles(slug);
|
|
@@ -242,6 +244,8 @@ export const skillhubProvider = {
|
|
|
242
244
|
},
|
|
243
245
|
securityStatus: security.status,
|
|
244
246
|
securityReports: security.reports.length ? security.reports : undefined,
|
|
247
|
+
...(changelog ? { changelog: { version, text: changelog, publishedAt: asNumber(data.latestVersion?.createdAt) } } : {}),
|
|
248
|
+
pageUrl: `https://skillhub.cn/skills/${encodeURIComponent(slug)}`,
|
|
245
249
|
description,
|
|
246
250
|
...(descriptionFrontmatter ? { descriptionFrontmatter } : {}),
|
|
247
251
|
files: files.map((file) => ({
|
package/lib/market/types.js
CHANGED
|
@@ -70,7 +70,8 @@ export function sanitizeDirName(slug) {
|
|
|
70
70
|
return name;
|
|
71
71
|
}
|
|
72
72
|
const LANG_MAP = {
|
|
73
|
-
md: 'markdown',
|
|
73
|
+
md: 'markdown', markdown: 'markdown', mdx: 'markdown',
|
|
74
|
+
ts: 'typescript', tsx: 'typescript',
|
|
74
75
|
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
|
75
76
|
json: 'json', yaml: 'yaml', yml: 'yaml', sh: 'bash', bash: 'bash', zsh: 'bash',
|
|
76
77
|
py: 'python', toml: 'toml', css: 'css', html: 'html',
|
package/lib/skills/installed.js
CHANGED
|
@@ -57,6 +57,9 @@ export function parseInstalledMeta(value) {
|
|
|
57
57
|
const version = typeof candidate['version'] === 'string' && candidate['version'] !== ''
|
|
58
58
|
? candidate['version']
|
|
59
59
|
: undefined;
|
|
60
|
+
const owner = typeof candidate['owner'] === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(candidate['owner'])
|
|
61
|
+
? candidate['owner']
|
|
62
|
+
: undefined;
|
|
60
63
|
const files = [];
|
|
61
64
|
const rawFiles = candidate['files'];
|
|
62
65
|
if (Array.isArray(rawFiles)) {
|
|
@@ -75,6 +78,7 @@ export function parseInstalledMeta(value) {
|
|
|
75
78
|
id,
|
|
76
79
|
source: source,
|
|
77
80
|
slug,
|
|
81
|
+
...(owner === undefined ? {} : { owner }),
|
|
78
82
|
...(version === undefined ? {} : { version }),
|
|
79
83
|
installedAt,
|
|
80
84
|
files,
|
|
@@ -289,6 +293,7 @@ async function readDirectorySkill(rootPath, dirName) {
|
|
|
289
293
|
id: meta.id,
|
|
290
294
|
source: meta.source,
|
|
291
295
|
slug: meta.slug,
|
|
296
|
+
...(meta.owner === undefined ? {} : { owner: meta.owner }),
|
|
292
297
|
...(version === undefined ? {} : { version }),
|
|
293
298
|
installedAt: meta.installedAt,
|
|
294
299
|
...shared,
|