@nanmicoder/dsh-skills-hub 0.0.1 → 0.0.2
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 +18 -0
- package/README.md +31 -82
- package/README_EN.md +31 -82
- package/README_ZH.md +2 -0
- package/compatibility.json +1 -1
- package/docs/COMPATIBILITY.md +34 -0
- package/docs/CONFIGURATION.md +38 -0
- package/docs/CONTRACT.md +25 -7
- package/docs/RELEASE.md +5 -5
- package/lib/client.js +1916 -1025
- 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/disk-cache.js +113 -0
- package/lib/market/install-service.js +8 -6
- package/lib/market/market-service.js +79 -29
- package/lib/market/provider-fetch.js +9 -2
- package/lib/market/types.js +2 -1
- package/lib/skills/management.js +182 -4
- package/lib/skills/root.js +1 -1
- package/lib/types/client/api.d.ts +33 -5
- package/lib/types/client/components/InstallConfirmDialog.d.ts +6 -0
- 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/SkillAvatar.d.ts +1 -1
- package/lib/types/client/components/SkillDetailShell.d.ts +63 -0
- package/lib/types/client/components/SkillDetailView.d.ts +4 -6
- 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 +30 -0
- package/lib/types/client/relative-time.d.ts +13 -0
- package/lib/types/client/skill-markdown.d.ts +39 -0
- package/lib/types/client/state.d.ts +13 -4
- package/lib/types/config.d.ts +2 -0
- package/lib/types/market/cache.d.ts +62 -14
- package/lib/types/market/disk-cache.d.ts +16 -0
- package/lib/types/market/market-service.d.ts +15 -3
- package/lib/types/market/provider-fetch.d.ts +2 -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 +2 -0
- package/lib/web-routes.js +42 -4
- package/package.json +1 -1
- package/release-notes/v0.0.1.md +1 -1
- package/release-notes/v0.0.2.md +12 -0
package/lib/config.js
CHANGED
|
@@ -27,6 +27,7 @@ export const SKILLS_HUB_DEFAULTS = {
|
|
|
27
27
|
requestRetries: 1,
|
|
28
28
|
allowUninstall: true,
|
|
29
29
|
pageSize: 24,
|
|
30
|
+
cacheTtlMinutes: 60,
|
|
30
31
|
};
|
|
31
32
|
/**
|
|
32
33
|
* `null` must stay a legal value for `skillsRoot` (it is the "use the DSH
|
|
@@ -48,4 +49,5 @@ export const Config = z.object({
|
|
|
48
49
|
requestRetries: z.natural().default(SKILLS_HUB_DEFAULTS.requestRetries),
|
|
49
50
|
allowUninstall: z.boolean().default(SKILLS_HUB_DEFAULTS.allowUninstall),
|
|
50
51
|
pageSize: z.natural().min(1).max(100).default(SKILLS_HUB_DEFAULTS.pageSize),
|
|
52
|
+
cacheTtlMinutes: z.natural().min(1).max(1440).default(SKILLS_HUB_DEFAULTS.cacheTtlMinutes),
|
|
51
53
|
});
|
package/lib/index.js
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
*
|
|
16
16
|
* @module dsh-skills-hub
|
|
17
17
|
*/
|
|
18
|
+
import * as path from 'node:path';
|
|
18
19
|
import { Config, SKILLS_HUB_DEFAULTS } from "./config.js";
|
|
20
|
+
import { configureMarketCache } from "./market/cache.js";
|
|
19
21
|
import { configureProviderFetch } from "./market/provider-fetch.js";
|
|
20
22
|
import { setInstalledLookup, resetInstalledLookup } from "./market/market-service.js";
|
|
21
23
|
import { installedLookupFrom, scanInstalledSkills } from "./skills/installed.js";
|
|
22
|
-
import { resolveSkillsRoot, resolveSkillsScanRoots } from "./skills/root.js";
|
|
24
|
+
import { resolveDshHome, resolveSkillsRoot, resolveSkillsScanRoots } from "./skills/root.js";
|
|
23
25
|
import { registerSkillsHubRoutes, setSkillsHubPageSize, } from "./web-routes.js";
|
|
24
26
|
export const name = 'skills-hub';
|
|
25
27
|
/**
|
|
@@ -52,6 +54,7 @@ function resolveConfig(config) {
|
|
|
52
54
|
requestRetries: config?.requestRetries ?? SKILLS_HUB_DEFAULTS.requestRetries,
|
|
53
55
|
allowUninstall: config?.allowUninstall ?? SKILLS_HUB_DEFAULTS.allowUninstall,
|
|
54
56
|
pageSize: config?.pageSize ?? SKILLS_HUB_DEFAULTS.pageSize,
|
|
57
|
+
cacheTtlMinutes: config?.cacheTtlMinutes ?? SKILLS_HUB_DEFAULTS.cacheTtlMinutes,
|
|
55
58
|
};
|
|
56
59
|
}
|
|
57
60
|
/**
|
|
@@ -101,6 +104,11 @@ export function apply(ctx, config) {
|
|
|
101
104
|
retries: resolved.requestRetries,
|
|
102
105
|
});
|
|
103
106
|
setSkillsHubPageSize(resolved.pageSize);
|
|
107
|
+
configureMarketCache({
|
|
108
|
+
directory: path.join(resolveDshHome(), 'cache', 'skills-hub', 'v1'),
|
|
109
|
+
ttlMs: resolved.cacheTtlMinutes * 60_000,
|
|
110
|
+
onError: (error) => ctx.logger.warn(`skills-hub: persistent cache unavailable: ${String(error)}`),
|
|
111
|
+
});
|
|
104
112
|
let disposed = false;
|
|
105
113
|
/** Rebuild the installed index and hand it to the market layer. */
|
|
106
114
|
const refreshInstalledIndex = async () => {
|
|
@@ -135,11 +143,12 @@ export function apply(ctx, config) {
|
|
|
135
143
|
// republishing it).
|
|
136
144
|
resetInstalledLookup();
|
|
137
145
|
}, 'skills-hub: activation state');
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
// Apply is synchronous, but the first HTTP read must wait for install
|
|
147
|
+
// annotations even when a persisted snapshot arrives before the scan does.
|
|
148
|
+
const initialScan = refreshInstalledIndex().catch((error) => {
|
|
149
|
+
ctx.logger.warn(`skills-hub: installed-skill index refresh failed: ${String(error)}`);
|
|
150
|
+
});
|
|
151
|
+
void initialScan.then(() => refreshSkillCatalog(ctx));
|
|
143
152
|
// The Web server may bind before or after this plugin (the Loader activates
|
|
144
153
|
// rows concurrently), and older compositions name the service `httpServer`.
|
|
145
154
|
// Bind lazily on the first service event that makes a server reachable.
|
|
@@ -159,6 +168,7 @@ export function apply(ctx, config) {
|
|
|
159
168
|
skillsRoot: () => resolveSkillsRoot(resolved.skillsRoot),
|
|
160
169
|
allowUninstall: () => resolved.allowUninstall,
|
|
161
170
|
rescan,
|
|
171
|
+
ready: () => initialScan,
|
|
162
172
|
};
|
|
163
173
|
// `ctx.effect` runs the factory immediately; `dsh-host-webserver` throws on a
|
|
164
174
|
// duplicate route path, so the flag may only be set once registration has
|
package/lib/market/cache.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Skills Market —
|
|
2
|
+
* Skills Market — memory + persistent TTL cache with stale-while-error support, plus
|
|
3
3
|
* per-source health tracking.
|
|
4
4
|
*
|
|
5
5
|
* Only *upstream* data is cached here (list pages, details, file bodies).
|
|
@@ -7,56 +7,128 @@
|
|
|
7
7
|
* and is deliberately never cached: a skill installed a second ago must show up
|
|
8
8
|
* as installed on the very next list call.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* The host configures disk storage at activation. Without a directory the
|
|
11
|
+
* cache stays memory-only, so provider tests never touch a user's Harness home.
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/** Per-payload TTLs: cheap-to-refetch list pages expire sooner than file bodies. */
|
|
13
|
+
import { DiskMarketCache, MAX_CACHE_ENTRIES, MAX_STALE_AGE_MS } from "./disk-cache.js";
|
|
14
|
+
/** Default snapshot lifetime; activation can override it with cacheTtlMinutes. */
|
|
16
15
|
export const MARKET_TTL = {
|
|
17
|
-
list:
|
|
18
|
-
search:
|
|
19
|
-
detail:
|
|
20
|
-
files:
|
|
21
|
-
fileContent:
|
|
16
|
+
list: 60 * 60_000,
|
|
17
|
+
search: 60 * 60_000,
|
|
18
|
+
detail: 60 * 60_000,
|
|
19
|
+
files: 60 * 60_000,
|
|
20
|
+
fileContent: 60 * 60_000,
|
|
22
21
|
};
|
|
23
|
-
|
|
22
|
+
const stats = {
|
|
23
|
+
hits: 0,
|
|
24
|
+
misses: 0,
|
|
25
|
+
staleServed: 0,
|
|
26
|
+
upstreamRequests: 0,
|
|
27
|
+
forcedRefreshes: 0,
|
|
28
|
+
};
|
|
29
|
+
/** Record one cache event. Kept tiny so it can sit on the request path. */
|
|
30
|
+
export function noteMarketStat(entry) {
|
|
31
|
+
stats[entry] += 1;
|
|
32
|
+
}
|
|
33
|
+
/** Snapshot of the counters; the caller cannot mutate the live object. */
|
|
34
|
+
export function getMarketStats() {
|
|
35
|
+
return { ...stats };
|
|
36
|
+
}
|
|
37
|
+
export class MarketCache {
|
|
38
|
+
options;
|
|
24
39
|
entries = new Map();
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
disk;
|
|
41
|
+
warned = false;
|
|
42
|
+
ttlMs;
|
|
43
|
+
constructor(options = {}) {
|
|
44
|
+
this.options = options;
|
|
45
|
+
this.ttlMs = options.ttlMs ?? MARKET_TTL.list;
|
|
46
|
+
if (options.directory)
|
|
47
|
+
this.disk = new DiskMarketCache(options.directory);
|
|
48
|
+
}
|
|
49
|
+
warn(error) {
|
|
50
|
+
if (this.warned)
|
|
51
|
+
return;
|
|
52
|
+
this.warned = true;
|
|
53
|
+
this.options.onError?.(error);
|
|
54
|
+
}
|
|
55
|
+
remember(key, entry) {
|
|
56
|
+
this.entries.delete(key);
|
|
57
|
+
this.entries.set(key, entry);
|
|
58
|
+
if (this.entries.size > MAX_CACHE_ENTRIES) {
|
|
59
|
+
const oldest = this.entries.keys().next().value;
|
|
60
|
+
if (oldest !== undefined)
|
|
61
|
+
this.entries.delete(oldest);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async read(key) {
|
|
65
|
+
const existing = this.entries.get(key);
|
|
66
|
+
if (existing)
|
|
67
|
+
return existing;
|
|
68
|
+
try {
|
|
69
|
+
const restored = await this.disk?.read(key);
|
|
70
|
+
// A concurrent upstream response may have replaced the snapshot while
|
|
71
|
+
// the disk read was pending. Never overwrite that newer memory entry.
|
|
72
|
+
const current = this.entries.get(key);
|
|
73
|
+
if (current)
|
|
74
|
+
return current;
|
|
75
|
+
if (restored)
|
|
76
|
+
this.remember(key, restored);
|
|
77
|
+
return restored;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
// A corrupt or unwritable cache must never break the market request.
|
|
81
|
+
this.warn(error);
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read a fresh entry.
|
|
87
|
+
*
|
|
88
|
+
* The record carries `storedAt` on purpose: a hit is a *snapshot*, and the
|
|
89
|
+
* caller must be able to say how old it is. Returning only the payload is how
|
|
90
|
+
* the panel ended up reporting "fetched just now" for data it had not touched
|
|
91
|
+
* in ten minutes.
|
|
92
|
+
*/
|
|
93
|
+
async getRecord(key) {
|
|
94
|
+
const entry = await this.read(key);
|
|
27
95
|
if (!entry)
|
|
28
96
|
return undefined;
|
|
29
|
-
if (Date.now()
|
|
97
|
+
if (Date.now() >= Math.min(entry.expiresAt, entry.storedAt + this.ttlMs))
|
|
30
98
|
return undefined;
|
|
31
99
|
// LRU touch: re-insert so the oldest key is the least recently used one.
|
|
32
100
|
this.entries.delete(key);
|
|
33
101
|
this.entries.set(key, entry);
|
|
34
|
-
return entry.value;
|
|
102
|
+
return { value: entry.value, storedAt: entry.storedAt };
|
|
35
103
|
}
|
|
36
104
|
/** Returns the entry even when expired — used for the stale-while-error fallback. */
|
|
37
|
-
getStale(key) {
|
|
38
|
-
const entry = this.
|
|
39
|
-
if (!entry)
|
|
105
|
+
async getStale(key) {
|
|
106
|
+
const entry = await this.read(key);
|
|
107
|
+
if (!entry || Date.now() - entry.storedAt > MAX_STALE_AGE_MS)
|
|
40
108
|
return undefined;
|
|
41
109
|
return { value: entry.value, storedAt: entry.storedAt };
|
|
42
110
|
}
|
|
43
|
-
set(key, value, ttlMs) {
|
|
44
|
-
if (this.entries.has(key))
|
|
45
|
-
this.entries.delete(key);
|
|
111
|
+
async set(key, value, ttlMs = this.ttlMs) {
|
|
46
112
|
const now = Date.now();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (oldest !== undefined)
|
|
52
|
-
this.entries.delete(oldest);
|
|
113
|
+
const entry = { value, expiresAt: now + ttlMs, storedAt: now };
|
|
114
|
+
this.remember(key, entry);
|
|
115
|
+
try {
|
|
116
|
+
await this.disk?.write(key, entry);
|
|
53
117
|
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
this.warn(error);
|
|
120
|
+
}
|
|
121
|
+
return now;
|
|
54
122
|
}
|
|
55
123
|
clear() {
|
|
56
124
|
this.entries.clear();
|
|
57
125
|
}
|
|
58
126
|
}
|
|
59
|
-
export
|
|
127
|
+
export let marketCache = new MarketCache();
|
|
128
|
+
/** Replace memory state when activating a profile; disk entries are read lazily. */
|
|
129
|
+
export function configureMarketCache(options) {
|
|
130
|
+
marketCache = new MarketCache(options);
|
|
131
|
+
}
|
|
60
132
|
function freshHealth() {
|
|
61
133
|
return {
|
|
62
134
|
clawhub: { status: 'ok' },
|
|
@@ -106,8 +178,10 @@ export function resetSourceHealth() {
|
|
|
106
178
|
sourceHealth[source] = fresh[source];
|
|
107
179
|
}
|
|
108
180
|
}
|
|
109
|
-
/** Test hook: drop
|
|
181
|
+
/** Test hook: drop memory payloads, reset source health and the counters. */
|
|
110
182
|
export function resetMarketCache() {
|
|
111
183
|
marketCache.clear();
|
|
112
184
|
resetSourceHealth();
|
|
185
|
+
for (const entry of Object.keys(stats))
|
|
186
|
+
stats[entry] = 0;
|
|
113
187
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Atomic, bounded storage for upstream snapshots; never stores install state. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
export const MAX_CACHE_ENTRIES = 500;
|
|
6
|
+
export const MAX_STALE_AGE_MS = 7 * 24 * 60 * 60_000;
|
|
7
|
+
const MAX_ENTRY_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
const MAX_DISK_BYTES = 64 * 1024 * 1024;
|
|
9
|
+
const CACHE_FILE = /^[a-f0-9]{64}\.json$/;
|
|
10
|
+
const TEMP_FILE = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\.tmp$/;
|
|
11
|
+
const MAX_TEMP_AGE_MS = 60 * 60_000;
|
|
12
|
+
function snapshotHash(key, entry) {
|
|
13
|
+
return createHash('sha256').update(JSON.stringify([2, key, entry.storedAt, entry.expiresAt, entry.value])).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
export class DiskMarketCache {
|
|
16
|
+
directory;
|
|
17
|
+
writes = Promise.resolve();
|
|
18
|
+
constructor(directory) {
|
|
19
|
+
this.directory = directory;
|
|
20
|
+
}
|
|
21
|
+
filename(key) {
|
|
22
|
+
return path.join(this.directory, `${createHash('sha256').update(key).digest('hex')}.json`);
|
|
23
|
+
}
|
|
24
|
+
async read(key) {
|
|
25
|
+
let handle;
|
|
26
|
+
try {
|
|
27
|
+
handle = await fs.open(this.filename(key), 'r');
|
|
28
|
+
if ((await handle.stat()).size > MAX_ENTRY_BYTES)
|
|
29
|
+
return undefined;
|
|
30
|
+
const record = JSON.parse(await handle.readFile('utf8'));
|
|
31
|
+
if (!record || record.version !== 2 || record.key !== key || !('value' in record))
|
|
32
|
+
return undefined;
|
|
33
|
+
const { storedAt, expiresAt, value } = record;
|
|
34
|
+
if (typeof storedAt !== 'number' || !Number.isFinite(storedAt) || storedAt < 0
|
|
35
|
+
|| typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || expiresAt < storedAt
|
|
36
|
+
|| storedAt > Date.now() || Date.now() - storedAt > MAX_STALE_AGE_MS)
|
|
37
|
+
return undefined;
|
|
38
|
+
const entry = { storedAt, expiresAt, value };
|
|
39
|
+
if (record.sha256 !== snapshotHash(key, entry))
|
|
40
|
+
return undefined;
|
|
41
|
+
return entry;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code === 'ENOENT')
|
|
45
|
+
return undefined;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await handle?.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async write(key, entry) {
|
|
53
|
+
const body = JSON.stringify({ version: 2, key, ...entry, sha256: snapshotHash(key, entry) });
|
|
54
|
+
const oversized = Buffer.byteLength(body) > MAX_ENTRY_BYTES;
|
|
55
|
+
// Serialize publication and pruning within this host. Each response awaits
|
|
56
|
+
// its write, so quitting after reading a page does not lose a delayed flush.
|
|
57
|
+
const pending = this.writes.then(async () => {
|
|
58
|
+
// An unpersistable refresh must not resurrect the previous version after
|
|
59
|
+
// restart. Serialize invalidation with ordinary writes of the same key.
|
|
60
|
+
if (oversized) {
|
|
61
|
+
await fs.rm(this.filename(key), { force: true });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
65
|
+
const temporary = path.join(this.directory, `${randomUUID()}.tmp`);
|
|
66
|
+
try {
|
|
67
|
+
await fs.writeFile(temporary, body, { mode: 0o600, flag: 'wx' });
|
|
68
|
+
await fs.rename(temporary, this.filename(key));
|
|
69
|
+
await this.prune();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await fs.rm(temporary, { force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
this.writes = pending.catch(() => { });
|
|
76
|
+
await pending;
|
|
77
|
+
}
|
|
78
|
+
async prune() {
|
|
79
|
+
const names = (await fs.readdir(this.directory)).filter((name) => CACHE_FILE.test(name) || TEMP_FILE.test(name));
|
|
80
|
+
const records = await Promise.all(names.map(async (name) => {
|
|
81
|
+
const filename = path.join(this.directory, name);
|
|
82
|
+
try {
|
|
83
|
+
const stat = await fs.stat(filename);
|
|
84
|
+
if (TEMP_FILE.test(name)) {
|
|
85
|
+
// A killed host can leave a partial write. Leave recent files alone:
|
|
86
|
+
// another host may still be publishing in this shared home.
|
|
87
|
+
if (Date.now() - stat.mtimeMs > MAX_TEMP_AGE_MS)
|
|
88
|
+
await fs.rm(filename, { force: true });
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
return { filename, size: stat.size, modifiedAt: stat.mtimeMs };
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error.code === 'ENOENT')
|
|
95
|
+
return undefined;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}));
|
|
99
|
+
const newest = records.filter((record) => record !== undefined).sort((a, b) => b.modifiedAt - a.modifiedAt);
|
|
100
|
+
let count = 0;
|
|
101
|
+
let bytes = 0;
|
|
102
|
+
for (const record of newest) {
|
|
103
|
+
if (Date.now() - record.modifiedAt > MAX_STALE_AGE_MS
|
|
104
|
+
|| record.size > MAX_ENTRY_BYTES || count >= MAX_CACHE_ENTRIES || bytes + record.size > MAX_DISK_BYTES) {
|
|
105
|
+
await fs.rm(record.filename, { force: true });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
count += 1;
|
|
109
|
+
bytes += record.size;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -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 } 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 = {
|
|
@@ -104,8 +105,7 @@ function hasErrorCode(error, code) {
|
|
|
104
105
|
}
|
|
105
106
|
/** The upstream detail for a skill, or the market's own "not installable" verdict. */
|
|
106
107
|
async function loadDetail(source, slug) {
|
|
107
|
-
|
|
108
|
-
return skill;
|
|
108
|
+
return resolveMarketSkill(source, slug);
|
|
109
109
|
}
|
|
110
110
|
/**
|
|
111
111
|
* Refuse to publish over anything we do not own.
|
|
@@ -155,7 +155,10 @@ async function readJsonIfExists(filePath) {
|
|
|
155
155
|
* @throws {import('./types.ts').MarketUpstreamError} for upstream failures,
|
|
156
156
|
* unwrapped so the route can classify them (404 vs 502).
|
|
157
157
|
*/
|
|
158
|
-
export
|
|
158
|
+
export function installMarketSkill(source, slug, options) {
|
|
159
|
+
return withProviderConfiguration(() => installMarketSkillWithConfiguration(source, slug, options));
|
|
160
|
+
}
|
|
161
|
+
async function installMarketSkillWithConfiguration(source, slug, options) {
|
|
159
162
|
const dirName = sanitizeDirName(slug);
|
|
160
163
|
if (dirName === null) {
|
|
161
164
|
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill slug cannot be used as a directory name: ${slug}`);
|
|
@@ -188,8 +191,7 @@ async function performInstall(source, slug, dirName, id, skillsRootInput) {
|
|
|
188
191
|
if (detail.installState === 'not-installable') {
|
|
189
192
|
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill is not installable (${detail.notInstallableReason ?? 'unknown'}): ${slug}`);
|
|
190
193
|
}
|
|
191
|
-
//
|
|
192
|
-
// file list is fetched fresh at install time (mirrors the reference).
|
|
194
|
+
// Re-read the file inventory for the freshly resolved manifest version.
|
|
193
195
|
const files = await providers[source].listFiles(slug, detail.version);
|
|
194
196
|
if (files.length === 0 || !files.some((file) => file.path === 'SKILL.md')) {
|
|
195
197
|
throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill has no installable SKILL.md: ${slug}`);
|
|
@@ -5,12 +5,13 @@
|
|
|
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,
|
|
13
|
+
import { getSourceHealth, markSourceHealth, marketCache, noteMarketStat } from "./cache.js";
|
|
14
|
+
import { getProviderBase, withProviderConfiguration } from "./provider-fetch.js";
|
|
14
15
|
import { clawhubProvider } from "./clawhub-provider.js";
|
|
15
16
|
import { skillhubProvider } from "./skillhub-provider.js";
|
|
16
17
|
import { MARKET_LIMITS, MARKET_SOURCES, detectMarketLanguage, sanitizeDirName, skillId, } from "./types.js";
|
|
@@ -154,23 +155,35 @@ export function dedupeSkills(items) {
|
|
|
154
155
|
* segment so every key is obviously source-scoped.
|
|
155
156
|
*/
|
|
156
157
|
function providerPageCacheKey(source, kind, params) {
|
|
157
|
-
return `${kind}:${source}:${JSON.stringify([params.q ?? null, params.cursor ?? null, params.limit])}`;
|
|
158
|
+
return `${kind}:${source}:${JSON.stringify([getProviderBase(source), params.q ?? null, params.cursor ?? null, params.limit])}`;
|
|
158
159
|
}
|
|
159
|
-
async function fetchProviderPage(source, params) {
|
|
160
|
+
async function fetchProviderPage(source, params, cache, options = {}) {
|
|
160
161
|
const isSearch = Boolean(params.q);
|
|
161
162
|
const cacheKey = providerPageCacheKey(source, isSearch ? 'search' : 'list', params);
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
const forced = options.force === true;
|
|
164
|
+
if (forced) {
|
|
165
|
+
// A reader asked for this page on purpose; the entry is still rewritten
|
|
166
|
+
// below, so the refresh costs one request and then serves everyone again.
|
|
167
|
+
noteMarketStat('forcedRefreshes');
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
const hit = await cache.getRecord(cacheKey);
|
|
171
|
+
if (hit) {
|
|
172
|
+
noteMarketStat('hits');
|
|
173
|
+
// The real fetch time travels with the payload: a hit is a snapshot, and
|
|
174
|
+
// the panel has to be able to say how old it is.
|
|
175
|
+
return { page: hit.value, status: { status: 'ok', fetchedAt: hit.storedAt, fromCache: true } };
|
|
176
|
+
}
|
|
166
177
|
}
|
|
178
|
+
noteMarketStat('misses');
|
|
167
179
|
try {
|
|
168
180
|
const q = params.q;
|
|
169
181
|
const page = isSearch && q !== undefined
|
|
170
182
|
? await providers[source].search({ q, cursor: params.cursor, limit: params.limit })
|
|
171
183
|
: await providers[source].list({ cursor: params.cursor, limit: params.limit });
|
|
172
|
-
|
|
173
|
-
|
|
184
|
+
noteMarketStat('upstreamRequests');
|
|
185
|
+
const storedAt = await cache.set(cacheKey, page);
|
|
186
|
+
return { page, status: { status: 'ok', fetchedAt: storedAt, fromCache: false } };
|
|
174
187
|
}
|
|
175
188
|
catch (error) {
|
|
176
189
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -181,8 +194,9 @@ async function fetchProviderPage(source, params) {
|
|
|
181
194
|
markSourceHealth(source, 'failed', message);
|
|
182
195
|
// Stale-while-error: a degraded source still serves its last good page, so a
|
|
183
196
|
// temporary upstream outage does not empty the panel.
|
|
184
|
-
const stale =
|
|
197
|
+
const stale = await cache.getStale(cacheKey);
|
|
185
198
|
if (stale) {
|
|
199
|
+
noteMarketStat('staleServed');
|
|
186
200
|
return {
|
|
187
201
|
page: stale.value,
|
|
188
202
|
status: { status: 'cached', fetchedAt: stale.storedAt, fromCache: true, error: message },
|
|
@@ -191,7 +205,11 @@ async function fetchProviderPage(source, params) {
|
|
|
191
205
|
return { page: null, status: { ...getSourceHealth(source), fromCache: false, error: message } };
|
|
192
206
|
}
|
|
193
207
|
}
|
|
194
|
-
export
|
|
208
|
+
export function listMarketSkills(params) {
|
|
209
|
+
const cache = marketCache;
|
|
210
|
+
return withProviderConfiguration(() => listMarketSkillsWithCache(params, cache));
|
|
211
|
+
}
|
|
212
|
+
async function listMarketSkillsWithCache(params, cache) {
|
|
195
213
|
const cursor = decodeCursor(params.cursor);
|
|
196
214
|
const isFirstPage = !params.cursor;
|
|
197
215
|
const activeSources = params.source === 'all' ? MARKET_SOURCES : [params.source];
|
|
@@ -207,7 +225,7 @@ export async function listMarketSkills(params) {
|
|
|
207
225
|
// ClawHub search has no pagination: ask it for one big page and let the
|
|
208
226
|
// aggregate cap apply, instead of pretending a second page exists.
|
|
209
227
|
const limit = q && source === 'clawhub' ? MARKET_LIMITS.searchResultCap : params.limit;
|
|
210
|
-
outcomes.set(source, await fetchProviderPage(source, { q, cursor: providerCursor, limit }));
|
|
228
|
+
outcomes.set(source, await fetchProviderPage(source, { q, cursor: providerCursor, limit }, cache, { force: params.refresh === true }));
|
|
211
229
|
}));
|
|
212
230
|
let merged = [];
|
|
213
231
|
const nextCursor = {};
|
|
@@ -239,20 +257,38 @@ export async function listMarketSkills(params) {
|
|
|
239
257
|
return { items: merged, nextCursor: encodeCursor(nextCursor), sources };
|
|
240
258
|
}
|
|
241
259
|
// ─── Detail / file content ───────────────────────────────────────────────────
|
|
242
|
-
export
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
260
|
+
export function getMarketSkillDetail(source, slug, options = {}) {
|
|
261
|
+
const cache = marketCache;
|
|
262
|
+
return withProviderConfiguration(() => getMarketSkillDetailWithCache(source, slug, options, cache));
|
|
263
|
+
}
|
|
264
|
+
async function getMarketSkillDetailWithCache(source, slug, options, cache) {
|
|
265
|
+
const cacheKey = `detail:${source}:${JSON.stringify([getProviderBase(source), slug])}`;
|
|
266
|
+
const forced = options.force === true;
|
|
267
|
+
if (forced)
|
|
268
|
+
noteMarketStat('forcedRefreshes');
|
|
269
|
+
const hit = forced ? undefined : await cache.getRecord(cacheKey);
|
|
270
|
+
let detail;
|
|
271
|
+
let sourceStatus;
|
|
272
|
+
if (hit) {
|
|
273
|
+
noteMarketStat('hits');
|
|
274
|
+
detail = hit.value;
|
|
275
|
+
sourceStatus = { status: 'ok', fetchedAt: hit.storedAt, fromCache: true };
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
noteMarketStat('misses');
|
|
247
279
|
try {
|
|
248
280
|
detail = await providers[source].detail(slug);
|
|
249
|
-
|
|
250
|
-
|
|
281
|
+
noteMarketStat('upstreamRequests');
|
|
282
|
+
const storedAt = await cache.set(cacheKey, detail);
|
|
283
|
+
sourceStatus = { status: 'ok', fetchedAt: storedAt, fromCache: false };
|
|
251
284
|
}
|
|
252
285
|
catch (error) {
|
|
253
|
-
|
|
286
|
+
if (options.allowStale === false)
|
|
287
|
+
throw error;
|
|
288
|
+
const stale = await cache.getStale(cacheKey);
|
|
254
289
|
if (!stale)
|
|
255
290
|
throw error;
|
|
291
|
+
noteMarketStat('staleServed');
|
|
256
292
|
detail = stale.value;
|
|
257
293
|
sourceStatus = {
|
|
258
294
|
status: 'cached',
|
|
@@ -274,13 +310,20 @@ export function isValidMarketFilePath(filePath) {
|
|
|
274
310
|
return false;
|
|
275
311
|
return true;
|
|
276
312
|
}
|
|
277
|
-
export
|
|
313
|
+
export function getMarketFileContent(source, slug, filePath) {
|
|
314
|
+
const cache = marketCache;
|
|
315
|
+
return withProviderConfiguration(() => getMarketFileContentWithCache(source, slug, filePath, cache));
|
|
316
|
+
}
|
|
317
|
+
async function getMarketFileContentWithCache(source, slug, filePath, cache) {
|
|
278
318
|
// JSON-encoded for the same reason as the list keys: slug and filePath are
|
|
279
319
|
// 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
|
-
|
|
320
|
+
const cacheKey = `file:${source}:${JSON.stringify([getProviderBase(source), slug, filePath])}`;
|
|
321
|
+
const cached = await cache.getRecord(cacheKey);
|
|
322
|
+
if (cached) {
|
|
323
|
+
noteMarketStat('hits');
|
|
324
|
+
return cached.value;
|
|
325
|
+
}
|
|
326
|
+
noteMarketStat('misses');
|
|
284
327
|
const fetched = await providers[source].fetchFile(slug, filePath);
|
|
285
328
|
let content = fetched.content;
|
|
286
329
|
let truncated = false;
|
|
@@ -295,7 +338,7 @@ export async function getMarketFileContent(source, slug, filePath) {
|
|
|
295
338
|
size: fetched.size,
|
|
296
339
|
truncated,
|
|
297
340
|
};
|
|
298
|
-
|
|
341
|
+
await cache.set(cacheKey, result);
|
|
299
342
|
return result;
|
|
300
343
|
}
|
|
301
344
|
// ─── Status ──────────────────────────────────────────────────────────────────
|
|
@@ -305,8 +348,15 @@ export function getMarketStatus() {
|
|
|
305
348
|
skillhub: getSourceHealth('skillhub'),
|
|
306
349
|
};
|
|
307
350
|
}
|
|
308
|
-
/**
|
|
351
|
+
/**
|
|
352
|
+
* Look up a single skill for an install (the detail path, bypassing the list).
|
|
353
|
+
*
|
|
354
|
+
* Always forced: a cached manifest is a snapshot of the *catalogue*, and
|
|
355
|
+
* installing is the one action that has to be authorized by what upstream says
|
|
356
|
+
* right now — the file bytes are fetched fresh and hash-verified against the
|
|
357
|
+
* list, so the list must come from the same generation as those bytes.
|
|
358
|
+
*/
|
|
309
359
|
export async function resolveMarketSkill(source, slug) {
|
|
310
|
-
const { skill } = await getMarketSkillDetail(source, slug);
|
|
360
|
+
const { skill } = await getMarketSkillDetail(source, slug, { force: true, allowStale: false });
|
|
311
361
|
return skill;
|
|
312
362
|
}
|
|
@@ -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++) {
|
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',
|