fix(FN-1966): use public skills search fallback for catalog fetches
- Route unauthenticated catalog requests to skills.sh /api/search with wildcard query support and normalized response mapping - Keep authenticated v1 catalog fetch, but fall back to public search on 401/403 while preserving auth metadata - Expand skills route tests for unauthenticated payload shape, query passthrough, and no-query wildcard behavior - Stabilize onboarding reopen tests by awaiting Authentication section render before interaction - Document dashboard dist build prerequisite for CLI test troubleshooting
This commit is contained in:
@@ -2192,7 +2192,8 @@ describe("App onboarding reopen", () => {
|
||||
});
|
||||
|
||||
// Navigate to Authentication section (it should be default or click to ensure)
|
||||
const authSection = screen.getAllByText("Authentication")[0];
|
||||
const authSections = await screen.findAllByText("Authentication");
|
||||
const authSection = authSections[0];
|
||||
fireEvent.click(authSection);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -2254,7 +2255,11 @@ describe("App onboarding reopen", () => {
|
||||
expect(screen.getByText("Settings")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Authentication is the default settings section; wait for it to hydrate.
|
||||
// Navigate to Authentication section
|
||||
const authSections = await screen.findAllByText("Authentication");
|
||||
const authSection = authSections[0];
|
||||
fireEvent.click(authSection);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAuthStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -377,7 +377,56 @@ describe("Skills routes", () => {
|
||||
expect(res.body).toMatchObject({ error: "Skills adapter not configured", code: "adapter_not_configured" });
|
||||
});
|
||||
|
||||
it("passes limit and query parameters to catalog fetch", async () => {
|
||||
it("returns unauthenticated public-search style entries", async () => {
|
||||
const mockAdapter = createMockSkillsAdapter({
|
||||
fetchCatalog: vi.fn().mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
id: "vercel-labs/agent-skills/vercel-react-best-practices",
|
||||
slug: "vercel-labs/agent-skills/vercel-react-best-practices",
|
||||
name: "vercel-react-best-practices",
|
||||
repo: "vercel-labs/agent-skills",
|
||||
installs: 421,
|
||||
installation: {
|
||||
installed: false,
|
||||
matchingSkillIds: [],
|
||||
matchingPaths: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
auth: { mode: "unauthenticated", tokenPresent: false, fallbackUsed: false },
|
||||
}),
|
||||
});
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter });
|
||||
|
||||
const res = await request(app, "GET", "/api/skills/catalog");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
entries: [
|
||||
{
|
||||
id: "vercel-labs/agent-skills/vercel-react-best-practices",
|
||||
slug: "vercel-labs/agent-skills/vercel-react-best-practices",
|
||||
name: "vercel-react-best-practices",
|
||||
repo: "vercel-labs/agent-skills",
|
||||
installs: 421,
|
||||
installation: {
|
||||
installed: false,
|
||||
matchingSkillIds: [],
|
||||
matchingPaths: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
auth: {
|
||||
mode: "unauthenticated",
|
||||
tokenPresent: false,
|
||||
fallbackUsed: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes search query through to catalog adapter", async () => {
|
||||
const mockAdapter = createMockSkillsAdapter({
|
||||
fetchCatalog: vi.fn().mockResolvedValue({
|
||||
entries: [],
|
||||
@@ -387,10 +436,38 @@ describe("Skills routes", () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter });
|
||||
|
||||
const res = await request(app, "GET", "/api/skills/catalog?limit=50&q=search-term");
|
||||
const res = await request(app, "GET", "/api/skills/catalog?q=react");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAdapter.fetchCatalog).toHaveBeenCalledWith({ limit: 50, query: "search-term" });
|
||||
expect(mockAdapter.fetchCatalog).toHaveBeenCalledWith({ limit: 20, query: "react" });
|
||||
});
|
||||
|
||||
it("returns results when query is omitted (adapter handles wildcard fallback)", async () => {
|
||||
const mockAdapter = createMockSkillsAdapter({
|
||||
fetchCatalog: vi.fn().mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
id: "default-skill",
|
||||
slug: "default-skill",
|
||||
name: "Default Skill",
|
||||
installation: {
|
||||
installed: false,
|
||||
matchingSkillIds: [],
|
||||
matchingPaths: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
auth: { mode: "unauthenticated", tokenPresent: false, fallbackUsed: false },
|
||||
}),
|
||||
});
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter });
|
||||
|
||||
const res = await request(app, "GET", "/api/skills/catalog");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.entries).toHaveLength(1);
|
||||
expect(mockAdapter.fetchCatalog).toHaveBeenCalledWith({ limit: 20, query: undefined });
|
||||
});
|
||||
|
||||
it("bounds limit parameter to max 100", async () => {
|
||||
|
||||
@@ -395,92 +395,63 @@ export function createSkillsAdapter(options: {
|
||||
|
||||
// Get skills.sh token if available
|
||||
const token = process.env.SKILLS_SH_TOKEN;
|
||||
const catalogUrl = buildCatalogUrl(boundedLimit, query);
|
||||
const searchUrl = buildSearchUrl(boundedLimit, query);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(boundedLimit));
|
||||
if (query) {
|
||||
params.set("q", query);
|
||||
// No token available - use public search endpoint
|
||||
if (!token) {
|
||||
return fetchPublicCatalog(searchUrl, {
|
||||
mode: "unauthenticated",
|
||||
tokenPresent: false,
|
||||
fallbackUsed: false,
|
||||
});
|
||||
}
|
||||
|
||||
const upstreamUrl = `https://skills.sh/api/v1/skills?${params.toString()}`;
|
||||
// Try authenticated v1 catalog endpoint first
|
||||
try {
|
||||
const authResponse = await fetch(catalogUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
// Try authenticated first if token is available
|
||||
if (token) {
|
||||
try {
|
||||
const authResponse = await fetch(upstreamUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (authResponse.ok) {
|
||||
const data = await authResponse.json().catch(() => null);
|
||||
if (data) {
|
||||
return normalizeCatalogResponse(data, false);
|
||||
}
|
||||
if (authResponse.ok) {
|
||||
const data = await authResponse.json().catch(() => null);
|
||||
if (data) {
|
||||
return normalizeCatalogResponse(data, false);
|
||||
}
|
||||
|
||||
// 401/403 from authenticated request - fall back to unauthenticated
|
||||
if (authResponse.status === 401 || authResponse.status === 403) {
|
||||
const fallbackResponse = await fetch(upstreamUrl, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (fallbackResponse.ok) {
|
||||
const fallbackData = await fallbackResponse.json().catch(() => null);
|
||||
if (fallbackData) {
|
||||
return normalizeCatalogResponse(fallbackData, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upstream error
|
||||
return {
|
||||
error: `Upstream returned ${authResponse.status}: ${authResponse.statusText}`,
|
||||
code: "upstream_http_error",
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
if (error.name === "TimeoutError" || error.message?.includes("timeout")) {
|
||||
return { error: "Upstream request timed out", code: "upstream_timeout" };
|
||||
}
|
||||
return {
|
||||
error: error.message || "Upstream request failed",
|
||||
code: "upstream_http_error",
|
||||
error: "Invalid upstream response format",
|
||||
code: "upstream_invalid_payload",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// No token - unauthenticated request
|
||||
try {
|
||||
const response = await fetch(upstreamUrl, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
|
||||
// 401/403 from authenticated request - fall back to public search endpoint
|
||||
if (authResponse.status === 401 || authResponse.status === 403) {
|
||||
return fetchPublicCatalog(searchUrl, {
|
||||
mode: "fallback-unauthenticated",
|
||||
tokenPresent: true,
|
||||
fallbackUsed: true,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (data) {
|
||||
return normalizeCatalogResponse(data, false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: `Upstream returned ${response.status}: ${response.statusText}`,
|
||||
code: "upstream_http_error",
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
if (error.name === "TimeoutError" || error.message?.includes("timeout")) {
|
||||
return { error: "Upstream request timed out", code: "upstream_timeout" };
|
||||
}
|
||||
return {
|
||||
error: error.message || "Upstream request failed",
|
||||
code: "upstream_http_error",
|
||||
};
|
||||
}
|
||||
|
||||
// Upstream error
|
||||
return {
|
||||
error: `Upstream returned ${authResponse.status}: ${authResponse.statusText}`,
|
||||
code: "upstream_http_error",
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
if (isTimeoutError(error)) {
|
||||
return { error: "Upstream request timed out", code: "upstream_timeout" };
|
||||
}
|
||||
return {
|
||||
error: error.message || "Upstream request failed",
|
||||
code: "upstream_http_error",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -503,6 +474,142 @@ function extractSkillName(skillPath: string, source: string): string {
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the authenticated catalog URL.
|
||||
*/
|
||||
function buildCatalogUrl(limit: number, query?: string): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(limit));
|
||||
|
||||
const normalizedQuery = query?.trim();
|
||||
if (normalizedQuery) {
|
||||
params.set("q", normalizedQuery);
|
||||
}
|
||||
|
||||
return `https://skills.sh/api/v1/skills?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the public search URL.
|
||||
*
|
||||
* The search API requires a non-empty query. For initial catalog browsing,
|
||||
* use "*" as a wildcard query.
|
||||
*/
|
||||
function buildSearchUrl(limit: number, query?: string): string {
|
||||
const params = new URLSearchParams();
|
||||
const normalizedQuery = query?.trim();
|
||||
|
||||
params.set("q", normalizedQuery && normalizedQuery.length > 0 ? normalizedQuery : "*");
|
||||
params.set("limit", String(limit));
|
||||
|
||||
return `https://skills.sh/api/search?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and normalize catalog data from the public /api/search endpoint.
|
||||
*/
|
||||
async function fetchPublicCatalog(
|
||||
url: string,
|
||||
auth: CatalogFetchResult["auth"],
|
||||
): Promise<CatalogFetchResult | UpstreamError> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: `Upstream returned ${response.status}: ${response.statusText}`,
|
||||
code: "upstream_http_error",
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data) {
|
||||
return {
|
||||
error: "Invalid upstream response format",
|
||||
code: "upstream_invalid_payload",
|
||||
};
|
||||
}
|
||||
|
||||
return normalizeSearchResponse(data, auth);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
if (isTimeoutError(error)) {
|
||||
return { error: "Upstream request timed out", code: "upstream_timeout" };
|
||||
}
|
||||
return {
|
||||
error: error.message || "Upstream request failed",
|
||||
code: "upstream_http_error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize public search endpoint response shape to CatalogFetchResult.
|
||||
*/
|
||||
function normalizeSearchResponse(
|
||||
data: unknown,
|
||||
auth: CatalogFetchResult["auth"],
|
||||
): CatalogFetchResult | UpstreamError {
|
||||
if (!data || typeof data !== "object") {
|
||||
return {
|
||||
error: "Invalid upstream response format",
|
||||
code: "upstream_invalid_payload",
|
||||
};
|
||||
}
|
||||
|
||||
const record = data as Record<string, unknown>;
|
||||
if (!Array.isArray(record.skills)) {
|
||||
return {
|
||||
error: "Invalid upstream response format: expected { skills: [...] }",
|
||||
code: "upstream_invalid_payload",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
entries: record.skills.map(normalizeSearchEntry),
|
||||
auth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a single /api/search skill entry to CatalogEntry.
|
||||
*/
|
||||
function normalizeSearchEntry(entry: unknown): CatalogEntry {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return {
|
||||
id: "",
|
||||
slug: "",
|
||||
name: "Unknown",
|
||||
installation: { installed: false, matchingSkillIds: [], matchingPaths: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const record = entry as Record<string, unknown>;
|
||||
const id = String(record.id ?? record.skillId ?? "");
|
||||
const nameCandidate = String(record.name ?? record.skillId ?? id);
|
||||
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
name: nameCandidate || "Unknown",
|
||||
repo: record.source ? String(record.source) : undefined,
|
||||
installs: typeof record.installs === "number" ? record.installs : undefined,
|
||||
installation: {
|
||||
installed: false,
|
||||
matchingSkillIds: [],
|
||||
matchingPaths: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isTimeoutError(error: Error): boolean {
|
||||
const message = error.message?.toLowerCase() ?? "";
|
||||
return error.name === "TimeoutError" || message.includes("timeout");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize catalog response to handle both array and wrapped formats.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user