dsh-skill-hub 0.3.24 → 0.3.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +31 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +17 -2
- package/lib/types/client/api.d.ts +22 -0
- package/lib/types/store/store.d.ts +11 -0
- package/package.json +1 -1
- package/src/client/api.test.ts +51 -0
- package/src/client/api.ts +32 -1
- package/src/store/store.ts +16 -1
- package/src/store.test.ts +25 -0
package/lib/index.js
CHANGED
|
@@ -937,6 +937,17 @@ var SkillHubStore = class {
|
|
|
937
937
|
* Upsert one skill into a source record. When the repo has no record yet a
|
|
938
938
|
* new one is created (root + commit snapshot from the caller).
|
|
939
939
|
*/
|
|
940
|
+
/**
|
|
941
|
+
* Record one imported skill in its repo-level source row.
|
|
942
|
+
*
|
|
943
|
+
* A repo owns exactly one source row, hence one pinned ref, so a later import
|
|
944
|
+
* must re-pin it: keeping the first import's `ref` / `commitSha` makes
|
|
945
|
+
* `sources/check` compare against a stale sha and `sources/sync` re-download
|
|
946
|
+
* the old ref over content that was imported from a tag — the "pinning a tag
|
|
947
|
+
* means nothing" failure of issue #15, reached through the bookkeeping
|
|
948
|
+
* instead of the download. An absent `ref` means the import ran on the
|
|
949
|
+
* default branch, which clears the pin rather than silently keeping it.
|
|
950
|
+
*/
|
|
940
951
|
async addSourceSkill(repo, root, commitSha, ref, skillName) {
|
|
941
952
|
await this.ensureLoaded();
|
|
942
953
|
const existing = this.sourcesByRepo.get(repo);
|
|
@@ -949,10 +960,14 @@ var SkillHubStore = class {
|
|
|
949
960
|
});
|
|
950
961
|
else {
|
|
951
962
|
const skills = existing.skills.includes(skillName) ? existing.skills : [...existing.skills, skillName].sort((a, b) => a.localeCompare(b));
|
|
952
|
-
|
|
963
|
+
const next = {
|
|
953
964
|
...existing,
|
|
954
965
|
skills
|
|
955
|
-
}
|
|
966
|
+
};
|
|
967
|
+
if (ref !== void 0 && ref !== "") next.ref = ref;
|
|
968
|
+
else delete next.ref;
|
|
969
|
+
if (commitSha !== "") next.commitSha = commitSha;
|
|
970
|
+
this.sourcesByRepo.set(repo, next);
|
|
956
971
|
}
|
|
957
972
|
await this.persist();
|
|
958
973
|
}
|
|
@@ -7,6 +7,28 @@ import { type CatalogResponse, type ConfigResponse, type CreateRequest, type Cre
|
|
|
7
7
|
export declare class SkillHubApiError extends Error {
|
|
8
8
|
constructor(message: string);
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolve one route path against the page's base.
|
|
12
|
+
*
|
|
13
|
+
* Every route in this family is registered on the server as a root-absolute
|
|
14
|
+
* path (`/api/skill-hub/...`), which is right there but wrong in a browser
|
|
15
|
+
* behind a reverse proxy that owns a path prefix: the page loads from
|
|
16
|
+
* `https://host/app/dsh/` while a root-absolute URL drops the prefix entirely,
|
|
17
|
+
* so the gateway answers 404 HTML and `readJson` reports "invalid JSON
|
|
18
|
+
* response" (issue #18). Resolving against `document.baseURI` is the host's own
|
|
19
|
+
* convention — `dsh-client-connection` opens its browser carrier with
|
|
20
|
+
* `new URL(path.slice(1), document.baseURI)` — so a root deployment resolves to
|
|
21
|
+
* exactly the same absolute path while a prefixed one keeps its prefix, and the
|
|
22
|
+
* gateway strips it before forwarding: the server still receives
|
|
23
|
+
* `/api/skill-hub/*`.
|
|
24
|
+
*
|
|
25
|
+
* `SKILL_HUB_API_ROOT` must stay absolute (the same constant registers the
|
|
26
|
+
* routes on the server), which is why the prefix is applied here at the single
|
|
27
|
+
* outbound boundary rather than in the constants.
|
|
28
|
+
* @param path - one route path, root-absolute unless it is already a full URL.
|
|
29
|
+
* @returns the URL to fetch.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveApiUrl(path: string): string;
|
|
10
32
|
/** The browser half's only data entry point. */
|
|
11
33
|
export declare class SkillHubApi {
|
|
12
34
|
/** One GET round trip (query already encoded by the caller). */
|
|
@@ -83,6 +83,17 @@ export declare class SkillHubStore {
|
|
|
83
83
|
* Upsert one skill into a source record. When the repo has no record yet a
|
|
84
84
|
* new one is created (root + commit snapshot from the caller).
|
|
85
85
|
*/
|
|
86
|
+
/**
|
|
87
|
+
* Record one imported skill in its repo-level source row.
|
|
88
|
+
*
|
|
89
|
+
* A repo owns exactly one source row, hence one pinned ref, so a later import
|
|
90
|
+
* must re-pin it: keeping the first import's `ref` / `commitSha` makes
|
|
91
|
+
* `sources/check` compare against a stale sha and `sources/sync` re-download
|
|
92
|
+
* the old ref over content that was imported from a tag — the "pinning a tag
|
|
93
|
+
* means nothing" failure of issue #15, reached through the bookkeeping
|
|
94
|
+
* instead of the download. An absent `ref` means the import ran on the
|
|
95
|
+
* default branch, which clears the pin rather than silently keeping it.
|
|
96
|
+
*/
|
|
86
97
|
addSourceSkill(repo: string, root: string, commitSha: string, ref: string | undefined, skillName: string): Promise<void>;
|
|
87
98
|
/** Replace a source's skill list (used after sync/confirm-delete). */
|
|
88
99
|
setSourceSkills(repo: string, skills: readonly string[]): Promise<SourceRecord | undefined>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-hub",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.26",
|
|
4
4
|
"description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics, and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync. The full manager beyond the read-only dsh-skill-manager browser.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root-absolute route paths must resolve against the page's base, not the site
|
|
3
|
+
* root: behind a reverse proxy that owns a path prefix (`https://host/app/dsh/`)
|
|
4
|
+
* a root-absolute URL loses the prefix and the gateway answers 404 HTML — the
|
|
5
|
+
* `HTTP 404: invalid JSON response` banner in issue #18.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import { SkillHubApi, resolveApiUrl } from './api.ts'
|
|
10
|
+
|
|
11
|
+
describe('resolveApiUrl (issue #18)', () => {
|
|
12
|
+
afterEach(() => { vi.unstubAllGlobals() })
|
|
13
|
+
|
|
14
|
+
it('resolves to the site root for a plain deployment', () => {
|
|
15
|
+
vi.stubGlobal('document', { baseURI: 'http://127.0.0.1:3080/' })
|
|
16
|
+
expect(resolveApiUrl('/api/skill-hub/catalog')).toBe('http://127.0.0.1:3080/api/skill-hub/catalog')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('keeps a reverse proxy path prefix', () => {
|
|
20
|
+
vi.stubGlobal('document', { baseURI: 'https://host.example.com/app/dsh/' })
|
|
21
|
+
expect(resolveApiUrl('/api/skill-hub/catalog')).toBe('https://host.example.com/app/dsh/api/skill-hub/catalog')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('passes full URLs through and tolerates a missing document (SSR/tests)', () => {
|
|
25
|
+
expect(resolveApiUrl('https://cdn.example.com/x.json')).toBe('https://cdn.example.com/x.json')
|
|
26
|
+
expect(resolveApiUrl('/api/skill-hub/catalog')).toBe('/api/skill-hub/catalog')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('SkillHubApi outbound boundary (issue #18)', () => {
|
|
31
|
+
afterEach(() => { vi.unstubAllGlobals() })
|
|
32
|
+
|
|
33
|
+
it('sends every request through the resolved URL, prefix included', async () => {
|
|
34
|
+
vi.stubGlobal('document', { baseURI: 'https://host.example.com/app/dsh/' })
|
|
35
|
+
const seen: string[] = []
|
|
36
|
+
vi.stubGlobal('fetch', async (url: string) => {
|
|
37
|
+
seen.push(String(url))
|
|
38
|
+
return new Response(JSON.stringify({ ok: true, skills: [], disabled: [], diagnostics: [] }), {
|
|
39
|
+
status: 200,
|
|
40
|
+
headers: { 'content-type': 'application/json' },
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const api = new SkillHubApi()
|
|
45
|
+
await api.catalog()
|
|
46
|
+
|
|
47
|
+
// One outbound path for the whole family: the prefix is applied once, here.
|
|
48
|
+
expect(seen).toHaveLength(1)
|
|
49
|
+
expect(seen[0]).toBe('https://host.example.com/app/dsh/api/skill-hub/catalog')
|
|
50
|
+
})
|
|
51
|
+
})
|
package/src/client/api.ts
CHANGED
|
@@ -83,6 +83,37 @@ async function readJson<T>(response: Response): Promise<T> {
|
|
|
83
83
|
return body as T
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Resolve one route path against the page's base.
|
|
88
|
+
*
|
|
89
|
+
* Every route in this family is registered on the server as a root-absolute
|
|
90
|
+
* path (`/api/skill-hub/...`), which is right there but wrong in a browser
|
|
91
|
+
* behind a reverse proxy that owns a path prefix: the page loads from
|
|
92
|
+
* `https://host/app/dsh/` while a root-absolute URL drops the prefix entirely,
|
|
93
|
+
* so the gateway answers 404 HTML and `readJson` reports "invalid JSON
|
|
94
|
+
* response" (issue #18). Resolving against `document.baseURI` is the host's own
|
|
95
|
+
* convention — `dsh-client-connection` opens its browser carrier with
|
|
96
|
+
* `new URL(path.slice(1), document.baseURI)` — so a root deployment resolves to
|
|
97
|
+
* exactly the same absolute path while a prefixed one keeps its prefix, and the
|
|
98
|
+
* gateway strips it before forwarding: the server still receives
|
|
99
|
+
* `/api/skill-hub/*`.
|
|
100
|
+
*
|
|
101
|
+
* `SKILL_HUB_API_ROOT` must stay absolute (the same constant registers the
|
|
102
|
+
* routes on the server), which is why the prefix is applied here at the single
|
|
103
|
+
* outbound boundary rather than in the constants.
|
|
104
|
+
* @param path - one route path, root-absolute unless it is already a full URL.
|
|
105
|
+
* @returns the URL to fetch.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveApiUrl(path: string): string {
|
|
108
|
+
if (!path.startsWith('/')) return path
|
|
109
|
+
if (typeof document === 'undefined') return path
|
|
110
|
+
try {
|
|
111
|
+
return new URL(path.slice(1), document.baseURI).href
|
|
112
|
+
} catch {
|
|
113
|
+
return path
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
86
117
|
/**
|
|
87
118
|
* Fetch with a hard timeout: a hung connection (proxy, stalled keep-alive,
|
|
88
119
|
* host wedged) must never leave the panel stuck on "loading" forever — it
|
|
@@ -92,7 +123,7 @@ async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit, ms
|
|
|
92
123
|
const controller = new AbortController()
|
|
93
124
|
const timer = setTimeout(() => controller.abort(), ms)
|
|
94
125
|
try {
|
|
95
|
-
return await fetch(input, { ...init, signal: controller.signal })
|
|
126
|
+
return await fetch(typeof input === 'string' ? resolveApiUrl(input) : input, { ...init, signal: controller.signal })
|
|
96
127
|
} finally {
|
|
97
128
|
clearTimeout(timer)
|
|
98
129
|
}
|
package/src/store/store.ts
CHANGED
|
@@ -282,6 +282,17 @@ export class SkillHubStore {
|
|
|
282
282
|
* Upsert one skill into a source record. When the repo has no record yet a
|
|
283
283
|
* new one is created (root + commit snapshot from the caller).
|
|
284
284
|
*/
|
|
285
|
+
/**
|
|
286
|
+
* Record one imported skill in its repo-level source row.
|
|
287
|
+
*
|
|
288
|
+
* A repo owns exactly one source row, hence one pinned ref, so a later import
|
|
289
|
+
* must re-pin it: keeping the first import's `ref` / `commitSha` makes
|
|
290
|
+
* `sources/check` compare against a stale sha and `sources/sync` re-download
|
|
291
|
+
* the old ref over content that was imported from a tag — the "pinning a tag
|
|
292
|
+
* means nothing" failure of issue #15, reached through the bookkeeping
|
|
293
|
+
* instead of the download. An absent `ref` means the import ran on the
|
|
294
|
+
* default branch, which clears the pin rather than silently keeping it.
|
|
295
|
+
*/
|
|
285
296
|
async addSourceSkill(repo: string, root: string, commitSha: string, ref: string | undefined, skillName: string): Promise<void> {
|
|
286
297
|
await this.ensureLoaded()
|
|
287
298
|
const existing = this.sourcesByRepo.get(repo)
|
|
@@ -295,7 +306,11 @@ export class SkillHubStore {
|
|
|
295
306
|
})
|
|
296
307
|
} else {
|
|
297
308
|
const skills = existing.skills.includes(skillName) ? existing.skills : [...existing.skills, skillName].sort((a, b) => a.localeCompare(b))
|
|
298
|
-
|
|
309
|
+
const next: SourceRecord = { ...existing, skills }
|
|
310
|
+
if (ref !== undefined && ref !== '') next.ref = ref
|
|
311
|
+
else delete next.ref
|
|
312
|
+
if (commitSha !== '') next.commitSha = commitSha
|
|
313
|
+
this.sourcesByRepo.set(repo, next)
|
|
299
314
|
}
|
|
300
315
|
await this.persist()
|
|
301
316
|
}
|
package/src/store.test.ts
CHANGED
|
@@ -125,6 +125,31 @@ describe('SkillHubStore', () => {
|
|
|
125
125
|
expect(again[0].manifest).toEqual({ 'skills/docx/SKILL.md': 120 })
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
+
// issue #15 follow-up: one repo owns one source row, hence one pinned ref, so
|
|
129
|
+
// a later import must re-pin it. Keeping the first import's ref/sha made
|
|
130
|
+
// `check` compare a stale sha and `sync` re-download the old ref over
|
|
131
|
+
// tag-pinned content — the same "pinning a tag means nothing" failure.
|
|
132
|
+
it('re-pins the source to the ref and sha of the latest import', async () => {
|
|
133
|
+
await store.addSourceSkill('a/b', 'skills', 'sha-main', 'main', 'one')
|
|
134
|
+
expect(await store.getSource('a/b')).toMatchObject({ ref: 'main', commitSha: 'sha-main' })
|
|
135
|
+
|
|
136
|
+
await store.addSourceSkill('a/b', 'skills', 'sha-tag', 'v1.2.0', 'two')
|
|
137
|
+
expect(await store.getSource('a/b')).toMatchObject({ ref: 'v1.2.0', commitSha: 'sha-tag', skills: ['one', 'two'] })
|
|
138
|
+
|
|
139
|
+
// An import from the default branch clears the pin instead of keeping it.
|
|
140
|
+
await store.addSourceSkill('a/b', 'skills', 'sha-default', undefined, 'three')
|
|
141
|
+
const unpinned = await store.getSource('a/b')
|
|
142
|
+
expect(unpinned?.ref).toBeUndefined()
|
|
143
|
+
expect(unpinned?.commitSha).toBe('sha-default')
|
|
144
|
+
expect(unpinned?.skills).toEqual(['one', 'three', 'two'])
|
|
145
|
+
|
|
146
|
+
// The cleared pin survives the sidecar round trip.
|
|
147
|
+
const reloaded = new SkillHubStore(file)
|
|
148
|
+
const persisted = await reloaded.getSource('a/b')
|
|
149
|
+
expect(persisted?.ref).toBeUndefined()
|
|
150
|
+
expect(persisted?.commitSha).toBe('sha-default')
|
|
151
|
+
})
|
|
152
|
+
|
|
128
153
|
it('drops only the given skill directory from the baseline', async () => {
|
|
129
154
|
await store.addSourceSkill('a/b', 'skills', 'sha-1', undefined, 'docx')
|
|
130
155
|
await store.addSourceSkill('a/b', 'skills', 'sha-1', undefined, 'pdf')
|