diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e18d0..0d7d75f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # lovstudio +## 0.5.1 + +### Patch Changes + +- Keep `skills add` working when the GitHub API rate limit is exhausted. The catalog is read from the GitHub API (using `GITHUB_TOKEN` / `GH_TOKEN` when set, sent only to api.github.com), then falls back to raw.githubusercontent.com and jsDelivr on any failure such as `HTTP 403`. A mirror that answers with something other than a catalog no longer passes as an empty one, and installs still stop when no source returns the catalog. + ## 0.5.0 ### Minor Changes diff --git a/README.md b/README.md index 559d301..0236b8a 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ automatically via their declared `install` command. Under the hood: - `account connect` → browser device confirmation on `lovstudio.ai`; the shared session is stored owner-only under `~/.lovstudio/` for local Agent tools - `skills add ` → resolves the product slug in `skills.yaml`, then passes its exact `runtime_name` to `npx -y skills@latest add lovstudio/skills --skill ` (vercel-labs/skills) +- `skills.yaml` is read from the GitHub API first (authenticated with `GITHUB_TOKEN` / `GH_TOKEN` when set; anonymous calls share 60 requests per hour per IP), then from raw.githubusercontent.com, then from jsDelivr. `LOVSTUDIO_SKILLS_CATALOG_URL` replaces the whole chain. If no source answers, nothing is installed - `skills add skills` → resolves the unified catalog and passes every free Skill's exact `runtime_name` - `license *` → `uvx lovstudio-skill-helper *` (pinned version) diff --git a/package-lock.json b/package-lock.json index ca54806..e30b399 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lovstudio", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lovstudio", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.6", diff --git a/package.json b/package.json index df91625..679f70a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lovstudio", - "version": "0.5.0", + "version": "0.5.1", "description": "Lovstudio CLI — install free Skills directly and redeem paid Skills with Credits.", "type": "module", "packageManager": "npm@11.17.0", diff --git a/src/commands/skills/index.mjs b/src/commands/skills/index.mjs index 332288e..9948442 100644 --- a/src/commands/skills/index.mjs +++ b/src/commands/skills/index.mjs @@ -15,8 +15,16 @@ const SKILLS_NPX_SPEC = "skills@latest"; const SKILL_PREFIX = "lov-"; const LEGACY_SKILL_PREFIX = "lovstudio-"; const ALL_SKILLS_NAMES = new Set(["*", "all", "skills", GALLERY]); -const CATALOG_URL = process.env.LOVSTUDIO_SKILLS_CATALOG_URL || - `https://api.github.com/repos/${GALLERY}/contents/skills.yaml?ref=main`; +const CATALOG_SOURCES = process.env.LOVSTUDIO_SKILLS_CATALOG_URL + ? [process.env.LOVSTUDIO_SKILLS_CATALOG_URL] + : [ + // The API reflects a push immediately but allows only 60 anonymous + // requests per hour per IP, which a shared NAT can exhaust. The CDN + // copies lag by minutes (raw) to hours (jsDelivr) and are not rate limited. + `https://api.github.com/repos/${GALLERY}/contents/skills.yaml?ref=main`, + `https://raw.githubusercontent.com/${GALLERY}/main/skills.yaml`, + `https://cdn.jsdelivr.net/gh/${GALLERY}@main/skills.yaml`, + ]; const WEB_URL = (process.env.LOVSTUDIO_WEB_URL || "https://lovstudio.ai").replace(/\/$/, ""); function isAllSkillsName(name) { @@ -58,27 +66,48 @@ async function requireAccountToken() { } } -async function loadCatalog() { - const response = await hfetch(CATALOG_URL, { - headers: { accept: "application/vnd.github+json, text/yaml, text/plain" }, +function catalogRequestHeaders(url) { + // The raw media type makes the API return the file itself instead of base64 + // JSON, which GitHub leaves empty once the file passes 1 MB. + const headers = { accept: "application/vnd.github.raw+json, text/yaml, text/plain" }; + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + // Only GitHub's API receives the token; mirrors and custom URLs never see it. + if (token && new URL(url).hostname === "api.github.com") { + headers.authorization = `Bearer ${token}`; + } + return headers; +} + +async function fetchCatalogSource(url) { + const response = await hfetch(url, { + headers: catalogRequestHeaders(url), signal: AbortSignal.timeout(15_000), }); - if (!response.ok) { - throw new Error(`catalog request failed: HTTP ${response.status}`); - } - const contentType = response.headers.get("content-type") || ""; - let text; - if (contentType.includes("application/json")) { - const payload = await response.json(); - if (typeof payload?.content !== "string") { - throw new Error("GitHub API catalog response has no encoded content"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = parseYaml(await response.text()); + // A blocked or hijacked mirror can answer 200 with an HTML page; that must + // not pass as an empty catalog. + if (!Array.isArray(data?.skills)) throw new Error("response is not a skills catalog"); + return data.skills.filter((skill) => !skill.test); +} + +// Every source serves the same public file, so the first one that answers +// wins. Callers still refuse to install when all of them fail. +export async function loadCatalog(sources = CATALOG_SOURCES) { + const failures = []; + for (const url of sources) { + try { + const skills = await fetchCatalogSource(url); + if (failures.length) { + console.error(`Lovstudio Skills 目录改用 ${new URL(url).host} 读取(${failures.join("; ")})。`); + } + return skills; + } catch (error) { + const cause = error?.cause?.code ? ` (${error.cause.code})` : ""; + failures.push(`${new URL(url).host} ${error instanceof Error ? error.message : String(error)}${cause}`); } - text = Buffer.from(payload.content, "base64").toString("utf8"); - } else { - text = await response.text(); } - const data = parseYaml(text) || {}; - return Array.isArray(data.skills) ? data.skills.filter((skill) => !skill.test) : []; + throw new Error(`catalog request failed: ${failures.join("; ")}`); } export function canonicalSkillName(name) { diff --git a/test/skills-command.test.mjs b/test/skills-command.test.mjs index 8a7efbb..52ac58d 100644 --- a/test/skills-command.test.mjs +++ b/test/skills-command.test.mjs @@ -10,10 +10,67 @@ import { catalogSkillSelector, canonicalSkillName, findCatalogSkill, + loadCatalog, locateExtractedSkill, skillSelector, } from "../src/commands/skills/index.mjs"; +const API_CATALOG = "https://api.github.com/repos/lovstudio/skills/contents/skills.yaml?ref=main"; +const RAW_CATALOG = "https://raw.githubusercontent.com/lovstudio/skills/main/skills.yaml"; +const CDN_CATALOG = "https://cdn.jsdelivr.net/gh/lovstudio/skills@main/skills.yaml"; + +function withEnv(t, name, value) { + const previous = process.env[name]; + process.env[name] = value; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); +} + +test("loadCatalog falls back to the raw mirror when the GitHub API answers 403", async (t) => { + withEnv(t, "GITHUB_TOKEN", "test-token"); + t.mock.method(console, "error", () => {}); + const requests = []; + t.mock.method(globalThis, "fetch", async (url, init) => { + requests.push({ url, authorization: init.headers.authorization }); + if (url === API_CATALOG) { + return new Response('{"message":"API rate limit exceeded"}', { + status: 403, + headers: { "content-type": "application/json" }, + }); + } + return new Response("version: 1\nskills:\n- name: wxmp-cli\n paid: false\n- name: smoke\n test: true\n", { + headers: { "content-type": "text/plain; charset=utf-8" }, + }); + }); + + const catalog = await loadCatalog([API_CATALOG, RAW_CATALOG, CDN_CATALOG]); + + assert.deepEqual(catalog.map((skill) => skill.name), ["wxmp-cli"]); + assert.deepEqual(requests, [ + { url: API_CATALOG, authorization: "Bearer test-token" }, + { url: RAW_CATALOG, authorization: undefined }, + ]); +}); + +test("loadCatalog fails instead of returning an empty catalog when every source fails", async (t) => { + t.mock.method(globalThis, "fetch", async (url) => { + if (url === API_CATALOG) return new Response("", { status: 429 }); + if (url === RAW_CATALOG) return new Response("blocked", { headers: { "content-type": "text/html" } }); + throw new TypeError("fetch failed", { cause: Object.assign(new Error("getaddrinfo"), { code: "ENOTFOUND" }) }); + }); + + await assert.rejects( + loadCatalog([API_CATALOG, RAW_CATALOG, CDN_CATALOG]), + { + message: "catalog request failed: api.github.com HTTP 429; " + + "raw.githubusercontent.com response is not a skills catalog; " + + "cdn.jsdelivr.net fetch failed (ENOTFOUND)", + }, + ); +}); + test("catalogSkillDependencyClosure installs transitive Skill dependencies first", () => { const catalog = [ { name: "branding-consistency", runtime_name: "lov-branding-consistency" },