From ee82821a160ca388ea31b73d33406ba8f7e43f38 Mon Sep 17 00:00:00 2001 From: markshawn2020 Date: Thu, 29 Oct 2026 16:45:29 +0800 Subject: [PATCH] fix(skills): read the site's compact catalog and fetch only the needed Skills (0.5.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On links slower than about 50 KB/s, common between mainland China and GitHub, `skills add` stopped before the entitlement check with "读取 Lovstudio Skills 目录失败:The operation was aborted due to timeout": the only catalog source was the GitHub API copy of skills.yaml, about 740 KB of base64 JSON, under a 15 s timeout. Reproduced with 0.5.1 through a 30 KB/s proxy. The catalog is now read from lovstudio.ai's /api/skills/catalog first (about 20 KB, only name, runtime_name, paid and depends_on). If the site fails, the CLI tries the GitHub API with the raw media type (sending GITHUB_TOKEN / GH_TOKEN only to api.github.com), then raw.githubusercontent.com, then jsDelivr. These fallbacks are carried over from the unmerged #7. A response without a skills list counts as a failure, and nothing installs when every source fails. Once past the catalog, free Skills (including a paid Skill's free dependencies, such as branding-consistency for koubo-media-creator) were installed by cloning the whole lovstudio/skills repository: 117.7 MB in 3,826 files at HEAD, which slow links cannot finish within the installer's 300 s clone limit. The CLI now makes a partial (--filter=blob:none) sparse clone of only the selected skills/ directories and installs from that local copy, as it already does for paid Skills. Blobs arrive over separate connections at checkout, so each git step is retried up to three times; a clone whose checkout failed is removed first. `npx skills update` skips local installs, so these Skills are updated by running `skills add` again. `skills add skills` (every free Skill) keeps the full clone. Through a 30 KB/s proxy, `skills add writing-style` (two free dependencies) now finishes in 19 s, moving about 24 KB from the site and 490 KB from GitHub. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++ README.md | 3 +- package-lock.json | 4 +- package.json | 2 +- src/commands/skills/index.mjs | 117 +++++++++++++++++---- test/skills-command.test.mjs | 154 +++++++++++++++++++++++++++- test/skills-vip-membership.test.mjs | 48 ++++++++- 7 files changed, 307 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18cc6ab..94fbd64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # lovstudio +## 0.5.2 + +### Patch Changes + +- Fix `skills add` stopping at "读取 Lovstudio Skills 目录失败 … aborted due to timeout" on slow cross-border links. The catalog is now read from lovstudio.ai's `/api/skills/catalog` (about 20 KB with only the install fields) instead of the GitHub API copy of `skills.yaml` (about 740 KB). If the site cannot answer, the CLI falls back to the GitHub API (using `GITHUB_TOKEN` / `GH_TOKEN` when set, sent only to api.github.com), then raw.githubusercontent.com, then jsDelivr. A source that answers with something other than a catalog no longer passes as an empty one, and nothing is installed when no source returns the catalog. +- Free Skills, including a paid Skill's free dependencies, are fetched with a partial sparse clone of only their `skills/` directories instead of a full clone of the catalog repository (over 100 MB, which slow links could not finish within the installer's 300 s clone limit), then installed from that local copy. Each git step is retried up to three times, since flaky links drop some of the connections. Like paid Skills, these installs are updated by running `skills add` again rather than by `npx skills update`. + ## 0.5.1 ### Patch Changes diff --git a/README.md b/README.md index e8637bb..0887168 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ 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 add ` → resolves the product slug in the catalog, fetches free Skills' `skills/` directories with a partial sparse clone of `lovstudio/skills` (paid Skills download from lovstudio.ai), then passes their exact `runtime_name` to `npx -y skills@latest add --skill ` (vercel-labs/skills). Reinstall with the same command to update +- The catalog is read from `https://lovstudio.ai/api/skills/catalog` first, then from the GitHub API (authenticated with `GITHUB_TOKEN` / `GH_TOKEN` when set; anonymous calls share 60 requests per hour per IP), raw.githubusercontent.com and 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 e30b399..c0bfadb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lovstudio", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lovstudio", - "version": "0.5.1", + "version": "0.5.2", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.6", diff --git a/package.json b/package.json index c6b976f..fbe5ae8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lovstudio", - "version": "0.5.1", + "version": "0.5.2", "description": "Lovstudio CLI \u2014 install free Skills directly and access paid Skills with VIP Membership.", "type": "module", "packageManager": "npm@11.17.0", diff --git a/src/commands/skills/index.mjs b/src/commands/skills/index.mjs index c6e7404..5a23232 100644 --- a/src/commands/skills/index.mjs +++ b/src/commands/skills/index.mjs @@ -1,5 +1,5 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; +import { existsSync, rmSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; @@ -14,9 +14,23 @@ 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 GIT_ATTEMPTS = 3; const WEB_URL = (process.env.LOVSTUDIO_WEB_URL || "https://lovstudio.ai").replace(/\/$/, ""); +const CATALOG_SOURCES = process.env.LOVSTUDIO_SKILLS_CATALOG_URL + ? [process.env.LOVSTUDIO_SKILLS_CATALOG_URL] + : [ + // About 20 KB with only the install fields, from the site that also + // serves entitlements and paid downloads. The GitHub API copy of + // skills.yaml is about 740 KB, which slow cross-border links cannot + // download within the timeout. + `${WEB_URL}/api/skills/catalog`, + // 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`, + ]; function isAllSkillsName(name) { return ALL_SKILLS_NAMES.has(String(name).trim().toLowerCase()); @@ -57,27 +71,49 @@ 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, application/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}`); + // The site answers JSON, which parses as YAML too. + 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 describes the same catalog, 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) { @@ -294,6 +330,36 @@ async function downloadPaidSkill(skill, token) { } } +// Free Skills are mirrored at skills/ in the public catalog repository, +// which also carries every other Skill and its case media (over 100 MB, past +// the installer's 300 s clone limit on slow links). A partial sparse clone +// fetches only the selected directories. As with paid Skills, the copy is +// installed from a local path, so `npx skills update` leaves it alone. +export function checkoutFreeSkills(source, skills, workDir) { + const repoDir = join(workDir, "catalog"); + const steps = [ + ["clone", "--quiet", "--depth", "1", "--filter=blob:none", "--sparse", source, repoDir], + ["-C", repoDir, "sparse-checkout", "set", ...skills.map((skill) => `skills/${skill.name}`)], + ]; + for (const args of steps) { + // Each step opens its own GitHub connection (blobs are fetched on + // checkout), and flaky links drop some of them. Both steps can be rerun; + // a clone whose checkout failed leaves its directory behind. + for (let attempt = 1; ; attempt += 1) { + if (args[0] === "clone") rmSync(repoDir, { recursive: true, force: true }); + const result = runCapture("git", args, { + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + timeout: 300_000, + }); + if (result.status === 0) break; + const message = result.error?.message || result.stderr.trim() || `git exited ${result.status}`; + if (attempt === GIT_ATTEMPTS) throw new Error(message); + console.error(`GitHub 连接失败,正在重试(${attempt + 1}/${GIT_ATTEMPTS}):${message.split("\n").at(-1)}`); + } + } + return repoDir; +} + // ───────────────────────────────────────────────────────────────────────────── // Frontmatter / dependency preflight // ───────────────────────────────────────────────────────────────────────────── @@ -468,6 +534,17 @@ async function addAction(rawArgs) { workDir = download.workDir; const version = download.version ? ` v${download.version}` : ""; console.log(`✓ 已下载「${plan.skills[0].name}」${version}(${download.ref})`); + } else if (plan.skills.length) { + workDir = await mkdtemp(join(tmpdir(), "lovstudio-skills-")); + const names = plan.skills.map((skill) => skill.name).join("」「"); + try { + source = checkoutFreeSkills(plan.source, plan.skills, workDir); + } catch (error) { + await rm(workDir, { recursive: true, force: true }); + console.error(`获取「${names}」失败:${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + console.log(`✓ 已获取「${names}」`); } const skillArgs = [ "-y", SKILLS_NPX_SPEC, "add", source, diff --git a/test/skills-command.test.mjs b/test/skills-command.test.mjs index 8a7efbb..18800a7 100644 --- a/test/skills-command.test.mjs +++ b/test/skills-command.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import test from "node:test"; import { @@ -9,11 +13,159 @@ import { catalogSkillInstallPlans, catalogSkillSelector, canonicalSkillName, + checkoutFreeSkills, findCatalogSkill, + loadCatalog, locateExtractedSkill, skillSelector, } from "../src/commands/skills/index.mjs"; +const SITE_CATALOG = "https://lovstudio.ai/api/skills/catalog"; +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"; +const usesDefaultCatalog = !process.env.LOVSTUDIO_SKILLS_CATALOG_URL && !process.env.LOVSTUDIO_WEB_URL; + +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 reads the site's compact catalog before GitHub", { skip: !usesDefaultCatalog }, async (t) => { + const requests = []; + t.mock.method(globalThis, "fetch", async (url) => { + requests.push(url); + return Response.json({ + skills: [{ name: "koubo-media-creator", runtime_name: "lov-koubo-media-creator", paid: true, depends_on: ["branding-consistency"] }], + }); + }); + + const catalog = await loadCatalog(); + + assert.deepEqual(requests, [SITE_CATALOG]); + assert.deepEqual(catalog, [ + { name: "koubo-media-creator", runtime_name: "lov-koubo-media-creator", paid: true, depends_on: ["branding-consistency"] }, + ]); +}); + +test("loadCatalog falls back to the GitHub API when the site is unavailable", { skip: !usesDefaultCatalog }, async (t) => { + t.mock.method(console, "error", () => {}); + const requests = []; + t.mock.method(globalThis, "fetch", async (url) => { + requests.push(url); + if (url === SITE_CATALOG) return Response.json({ error: "catalog_unavailable" }, { status: 503 }); + return new Response("skills:\n- name: branding-consistency\n paid: false\n"); + }); + + const catalog = await loadCatalog(); + + assert.deepEqual(catalog.map((skill) => skill.name), ["branding-consistency"]); + assert.deepEqual(requests, [SITE_CATALOG, API_CATALOG]); +}); + +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 === SITE_CATALOG) throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); + 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([SITE_CATALOG, API_CATALOG, RAW_CATALOG, CDN_CATALOG]), + { + message: "catalog request failed: lovstudio.ai The operation was aborted due to timeout; " + + "api.github.com HTTP 429; " + + "raw.githubusercontent.com response is not a skills catalog; " + + "cdn.jsdelivr.net fetch failed (ENOTFOUND)", + }, + ); +}); + +async function directoryBytes(dir) { + let total = 0; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + total += entry.isDirectory() ? await directoryBytes(path) : (await stat(path)).size; + } + return total; +} + +test("checkoutFreeSkills fetches only the selected Skill directories", async (t) => { + const root = await mkdtemp(join(tmpdir(), "lovstudio-catalog-repo-")); + t.after(() => rm(root, { recursive: true, force: true })); + const origin = join(root, "origin"); + for (const name of ["branding-consistency", "human-writing", "heavy"]) { + await mkdir(join(origin, "skills", name), { recursive: true }); + await writeFile(join(origin, "skills", name, "SKILL.md"), `---\nname: lov-${name}\n---\n`); + } + // Random bytes do not compress, so fetching this blob would show in .git. + await writeFile(join(origin, "skills", "heavy", "sample.bin"), randomBytes(2 * 1024 * 1024)); + const git = (...args) => { + const result = spawnSync("git", args, { cwd: origin, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + }; + git("init", "--quiet"); + git("config", "uploadpack.allowFilter", "true"); + git("config", "uploadpack.allowAnySHA1InWant", "true"); + git("add", "."); + git("-c", "user.name=fixture", "-c", "user.email=fixture@example.com", "commit", "--quiet", "-m", "catalog"); + const workDir = join(root, "work"); + await mkdir(workDir); + + const repoDir = checkoutFreeSkills( + pathToFileURL(origin).href, + [{ name: "branding-consistency" }, { name: "human-writing" }], + workDir, + ); + + assert.ok(existsSync(join(repoDir, "skills", "branding-consistency", "SKILL.md"))); + assert.ok(existsSync(join(repoDir, "skills", "human-writing", "SKILL.md"))); + assert.equal(existsSync(join(repoDir, "skills", "heavy")), false); + assert.ok(await directoryBytes(join(repoDir, ".git")) < 1024 * 1024, "unselected blobs must not be downloaded"); +}); + +test("checkoutFreeSkills retries git, then reports its error", async (t) => { + const retries = t.mock.method(console, "error", () => {}); + const workDir = await mkdtemp(join(tmpdir(), "lovstudio-catalog-missing-")); + t.after(() => rm(workDir, { recursive: true, force: true })); + assert.throws( + () => checkoutFreeSkills(pathToFileURL(join(workDir, "missing")).href, [{ name: "x" }], workDir), + /missing/, + ); + assert.equal(retries.mock.callCount(), 2); +}); + test("catalogSkillDependencyClosure installs transitive Skill dependencies first", () => { const catalog = [ { name: "branding-consistency", runtime_name: "lov-branding-consistency" }, diff --git a/test/skills-vip-membership.test.mjs b/test/skills-vip-membership.test.mjs index c1cd662..41c35c7 100644 --- a/test/skills-vip-membership.test.mjs +++ b/test/skills-vip-membership.test.mjs @@ -24,8 +24,27 @@ async function fixture() { await mkdir(home); await mkdir(bin); await writeFile(join(home, "auth.yml"), `access_token: valid-access-token\nrefresh_token: valid-refresh-token\nexpires_at: ${Math.floor(Date.now() / 1000) + 3600}\nuser_id: fixture-user\nemail: fixture@example.com\n`); - await writeFile(join(bin, "npx"), '#!/bin/sh\necho "MOCK_NPX:$*"\nif [ -f "$4/SKILL.md" ]; then cat "$4/SKILL.md"; fi\n'); + await writeFile(join(bin, "npx"), '#!/bin/sh\necho "MOCK_NPX:$*"\nif [ -f "$4/SKILL.md" ]; then cat "$4/SKILL.md"; fi\nfor f in "$4"/skills/*/SKILL.md; do if [ -f "$f" ]; then cat "$f"; fi; done\n'); await chmod(join(bin, "npx"), 0o755); + // Records its arguments, creates the clone target and materializes each + // sparse-checkout path as a Skill directory. MOCK_GIT_FAIL_ONCE drops the + // first blob fetch, as a flaky link does. + await writeFile(join(bin, "git"), [ + "#!/bin/sh", + 'echo "$*" >> "$(dirname "$0")/git.log"', + 'if [ "$1" = "clone" ]; then for target; do :; done; mkdir -p "$target"; exit 0; fi', + 'if [ "$3" = "sparse-checkout" ] && [ -n "$MOCK_GIT_FAIL_ONCE" ] && [ ! -f "$(dirname "$0")/failed-once" ]; then', + ' touch "$(dirname "$0")/failed-once"; echo "fatal: could not fetch 46f1171c from promisor remote" >&2; exit 128', + "fi", + 'if [ "$1" = "-C" ] && [ "$3" = "sparse-checkout" ]; then', + ' dir="$2"; shift 4', + " for path; do mkdir -p \"$dir/$path\"; printf '%s\\n' '---' \"name: lov-${path#skills/}\" '---' FREE_SPARSE_SOURCE > \"$dir/$path/SKILL.md\"; done", + " exit 0", + "fi", + "exit 1", + "", + ].join("\n")); + await chmod(join(bin, "git"), 0o755); const archiveDir = join(dir, "archive"); await mkdir(join(archiveDir, "skill-v0.1.1"), { recursive: true }); await writeFile(join(archiveDir, "skill-v0.1.1", "SKILL.md"), `---\nname: lov-${skillName}\n---\nVIP_PLAIN_SOURCE\n`); @@ -61,6 +80,8 @@ const scenarios = [ { name: "rejected membership session requires reconnect and cannot download", membershipStatus: 401, status: 1 }, { name: "download entitlement refusal receives VIP link", active: true, owned: true, downloadStatus: 403, status: 1, vip: true }, { name: "free Skill installs without account, membership or download requests", free: true, status: 0 }, + { name: "free Skill fetch retries a dropped GitHub connection", free: true, gitFailOnce: true, status: 0 }, + { name: "the site's compact catalog is read before GitHub", active: true, owned: true, siteCatalog: true, status: 0 }, ]; for (const scenario of scenarios) { @@ -77,6 +98,9 @@ for (const scenario of scenarios) { res.writeHead(200, { "content-type": "text/yaml" }); return res.end(scenario.free ? freeCatalog : paidCatalog); } + if (req.url === "/api/skills/catalog") { + return json(res, 200, { skills: [{ name: skillName, runtime_name: `lov-${skillName}`, paid: true, depends_on: [] }] }); + } if (req.url === "/api/cli/auth/start") return json(res, 401, { error: "unauthorized" }); if (req.url === "/api/cli/auth/refresh") return json(res, 200, { accessToken: "refreshed-access-token", refreshToken: "refreshed-refresh-token", expiresIn: 3600, @@ -104,7 +128,13 @@ for (const scenario of scenarios) { await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve)); const baseUrl = `http://127.0.0.1:${api.address().port}`; try { - const result = await run({ LOVSTUDIO_HOME: f.home, LOVSTUDIO_WEB_URL: baseUrl, LOVSTUDIO_SKILLS_CATALOG_URL: `${baseUrl}/skills.yaml`, PATH: `${f.bin}${delimiter}${process.env.PATH}` }); + const result = await run({ + LOVSTUDIO_HOME: f.home, + LOVSTUDIO_WEB_URL: baseUrl, + LOVSTUDIO_SKILLS_CATALOG_URL: scenario.siteCatalog ? "" : `${baseUrl}/skills.yaml`, + MOCK_GIT_FAIL_ONCE: scenario.gitFailOnce ? "1" : "", + PATH: `${f.bin}${delimiter}${process.env.PATH}`, + }); assert.equal(result.status, scenario.status, result.stderr); assert.equal(requests.filter((url) => /^\/api\/skills\/(price|purchase)/.test(url)).length, 0, "removed Credits endpoints must never be requested"); if (scenario.status === 0) { @@ -123,7 +153,19 @@ for (const scenario of scenarios) { if (scenario.nameMismatch) assert.match(result.stderr, /会员权益响应未匹配请求/); if (scenario.guest) assert.match(result.stderr, /网站账号连接未完成/); if (scenario.expired) assert.equal(requests.filter((url) => url === "/api/cli/auth/refresh").length, 1); - if (scenario.free) assert.equal(requests.filter((url) => url.startsWith("/api/")).length, 0); + if (scenario.free) { + assert.equal(requests.filter((url) => url.startsWith("/api/")).length, 0); + // Only the selected directory is fetched; the installer reads the local copy. + const sparse = `-C sparse-checkout set skills/${skillName}`; + assert.deepEqual((await readFile(join(f.bin, "git.log"), "utf8")).trim().split("\n").map((line) => line.replace(/ \/\S+/g, " ")), [ + "clone --quiet --depth 1 --filter=blob:none --sparse https://github.com/lovstudio/skills.git ", + ...(scenario.gitFailOnce ? [sparse, sparse] : [sparse]), + ]); + if (scenario.gitFailOnce) assert.match(result.stderr, /正在重试(2\/3):fatal: could not fetch/); + assert.match(result.stdout, /MOCK_NPX:-y skills@latest add \/\S+ --skill lov-3d-motion-media-creator/); + assert.match(result.stdout, /FREE_SPARSE_SOURCE/); + } + if (scenario.siteCatalog) assert.equal(requests[0], "/api/skills/catalog"); } finally { await new Promise((resolve, reject) => api.close((error) => error ? reject(error) : resolve())); await rm(f.dir, { recursive: true, force: true });