From 8b4787dc16d6de345666d87c557e7f47e73e61dc Mon Sep 17 00:00:00 2001 From: markshawn2020 Date: Wed, 14 Oct 2026 16:40:29 +0800 Subject: [PATCH] fix(skills): install VIP Skills through membership entitlement --- CHANGELOG.md | 8 ++ README.md | 17 ++-- package-lock.json | 4 +- package.json | 4 +- src/commands/skills/index.mjs | 118 ++++++++++--------------- test/account-entitlement.test.mjs | 14 ++- test/skills-vip-membership.test.mjs | 132 ++++++++++++++++++++++++++++ 7 files changed, 202 insertions(+), 95 deletions(-) create mode 100644 test/skills-vip-membership.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e18d0..18cc6ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # lovstudio +## 0.5.1 + +### Patch Changes + +- Install paid Skills through VIP Membership and existing account-bound ownership: check `/api/membership?skill=` before the private source download, without the removed price or purchase endpoints and without per-Skill Credits spending. +- Direct accounts without access to `/vip?from=skill:`; preserve free installs, license activation and session refresh, and report expired sessions or refused downloads without installing. +- Add HTTP mock regressions for active VIP, existing ownership, denied/guest accounts, expired or rejected sessions, refused downloads and free Skills. + ## 0.5.0 ### Minor Changes diff --git a/README.md b/README.md index 559d301..e8637bb 100644 --- a/README.md +++ b/README.md @@ -109,15 +109,14 @@ npx -y lovstudio@latest skills list `skills add` reads the unified `lovstudio/skills` catalog before installation. Free Skills install directly. For a paid Skill the CLI connects this device to -the user's Lovstudio website account and checks that account's ownership: a -Credits purchase or an activated license bound to the account (including dynamic -`all` access) installs immediately without another Credits confirmation. For a -Skill not yet owned, the CLI confirms the current Credits price and completes the -redemption first. It then asks lovstudio.ai for the Skill's source archive, which -the website only grants to owners, and installs the files as-is: paying controls -who can download a paid Skill, and the installed copy has no encryption or -runtime checks. Reinstalling with the same command replaces an older encrypted -install. +the user's Lovstudio website account and checks `/api/membership?skill=`. +An active VIP Membership or existing account-bound ownership (including licenses +with dynamic `all` access) installs immediately. Accounts without access receive +a `/vip?from=skill:` link instead of a per-Skill Credits purchase. The CLI +then asks lovstudio.ai for the Skill's source archive; the download endpoint +verifies access again before granting a temporary URL. Files install as-is with +no encryption or runtime checks. Reinstalling with the same command replaces an +older encrypted install. The command automatically installs the selected Skill's transitive Skill dependencies from the catalog's `depends_on` graph. It then resolves each 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..c6b976f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lovstudio", - "version": "0.5.0", - "description": "Lovstudio CLI — install free Skills directly and redeem paid Skills with Credits.", + "version": "0.5.1", + "description": "Lovstudio CLI \u2014 install free Skills directly and access paid Skills with VIP Membership.", "type": "module", "packageManager": "npm@11.17.0", "bin": { diff --git a/src/commands/skills/index.mjs b/src/commands/skills/index.mjs index 332288e..c6e7404 100644 --- a/src/commands/skills/index.mjs +++ b/src/commands/skills/index.mjs @@ -2,7 +2,6 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promis import { existsSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; -import { createInterface } from "node:readline/promises"; import { parse as parseYaml } from "yaml"; import { hasBin, runCapture, runInherit } from "../../lib/exec.mjs"; import { hfetch } from "../../lib/fetch.mjs"; @@ -193,78 +192,45 @@ async function resolveFreeCatalogSelectors() { return selectors; } -async function fetchRedemptionPrice(name, token) { - const url = `${WEB_URL}/api/skills/price?name=${encodeURIComponent(name)}`; - const response = await hfetch(url, { - headers: { authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(15_000), - }); - const body = await response.json().catch(() => ({})); - if (!response.ok || typeof body.price_credits !== "number") { - throw new Error(body.error || `price request failed: HTTP ${response.status}`); - } - return body; +function membershipUpgradeUrl(name) { + return `${WEB_URL}/vip?from=skill:${encodeURIComponent(name)}`; } -async function confirmPurchase(name, price, yes) { - if (yes) return true; - if (!process.stdin.isTTY) { - console.error(`付费 Skill 需要确认兑换 ${price.price_credits} Credits;非交互环境请追加 --yes。`); - return false; - } - const prompt = createInterface({ input: process.stdin, output: process.stdout }); +// The membership endpoint includes active VIP access and existing account-bound +// licenses. The download endpoint remains the final entitlement authority. +async function requirePaidSkillEntitlement(skill, token) { + let response; + let body; try { - const answer = await prompt.question(`「${name}」需要 ${price.price_credits} Credits,继续兑换并安装吗?[y/N] `); - return /^y(es)?$/i.test(answer.trim()); - } finally { - prompt.close(); - } -} - -// Owning a paid Skill — a Credits purchase or a license bound to the account — -// is what the download endpoint checks, so this only has to make sure the -// account owns it before the archive is requested. -async function redeemPaidSkill(skill, yes, token) { - let price; - try { - price = await fetchRedemptionPrice(skill.name, token); + response = await hfetch(`${WEB_URL}/api/membership?skill=${encodeURIComponent(skill.name)}`, { + headers: { accept: "application/json", authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(15_000), + }); + body = await response.json().catch(() => ({})); } catch (error) { - console.error(`读取「${skill.name}」的 Credits 兑换价失败:${error instanceof Error ? error.message : String(error)}`); - console.error("价格未确认前不会安装付费 Skill。"); + console.error(`确认「${skill.name}」的会员权益失败:${error instanceof Error ? error.message : String(error)}`); process.exit(1); } - if (price.owned === true) { - console.log(`✓ 网站账号已拥有「${skill.name}」,直接安装,不会再次确认或扣除 Credits。`); - return; - } - if (!(await confirmPurchase(skill.name, price, yes))) { - console.log("已取消兑换,未安装付费 Skill。"); - process.exit(0); + if (response.status === 401) { + console.error("Lovstudio 登录状态已失效,请重新连接网站账号后重试。"); + process.exit(1); } - const response = await hfetch(`${WEB_URL}/api/skills/purchase`, { - method: "POST", - headers: { - accept: "application/json", - authorization: `Bearer ${token}`, - "content-type": "application/json", - }, - body: JSON.stringify({ skill_name: skill.name }), - signal: AbortSignal.timeout(20_000), - }); - const body = await response.json().catch(() => ({})); if (!response.ok) { - if (response.status === 402) { - console.error(`Credits 余额不足,需要 ${price.price_credits} Credits。`); - console.error(`充值后重新运行:npx -y lovstudio@latest skills add ${skill.name}`); - } else if (response.status === 401) { - console.error("Lovstudio 登录状态已失效,请重新运行命令完成登录。 "); - } else { - console.error(`兑换「${skill.name}」失败:${body.error || `HTTP ${response.status}`}`); - } + console.error(`确认「${skill.name}」的会员权益失败:${body.error || `HTTP ${response.status}`}`); + process.exit(1); + } + if (body.skill?.name !== skill.name) { + console.error(`「${skill.name}」的会员权益响应未匹配请求,未安装。`); process.exit(1); } - const balance = typeof body.remaining_balance === "number" ? `,余额 ${body.remaining_balance} Credits` : ""; - console.log(body.already_owned ? `✓ 已拥有「${skill.name}」,无需重复扣除 Credits${balance}。` : `✓ 已兑换「${skill.name}」${balance}。`); + if (body.skill?.owned !== true) { + console.error(`「${skill.name}」包含在 Lovstudio.AI VIP Membership 中,当前账号没有安装权益。`); + console.error(`开通会员后重新安装:${membershipUpgradeUrl(skill.name)}`); + process.exit(1); + } + console.log(body.membership?.active === true + ? `✓ VIP Membership 已生效,「${skill.name}」直接安装,不另收费。` + : `✓ 网站账号已拥有「${skill.name}」,直接安装,不另收费。`); } export async function locateExtractedSkill(root, skillPath = "") { @@ -293,7 +259,12 @@ async function downloadPaidSkill(skill, token) { }); const body = await response.json().catch(() => ({})); if (!response.ok || typeof body.download_url !== "string") { - console.error(`获取「${skill.name}」的下载地址失败:${body.error || `HTTP ${response.status}`}`); + if (response.status === 401) { + console.error("Lovstudio 登录状态已失效,请重新连接网站账号后重试。"); + } else { + console.error(`获取「${skill.name}」的下载地址失败:${body.error || `HTTP ${response.status}`}`); + if (response.status === 403) console.error(`查看会员权益:${membershipUpgradeUrl(skill.name)}`); + } process.exit(1); } @@ -442,7 +413,7 @@ async function addAction(rawArgs) { } // 1. Keep the legacy license-key option for existing users. New paid Skills - // use the Lovstudio account + Credits path below. + // use the Lovstudio account membership/ownership path below. if (args.key) { const code = runHelper(["activate", args.key]); if (code !== 0) { @@ -452,7 +423,7 @@ async function addAction(rawArgs) { } // 2. Resolve the unified catalog before installing a single Skill. This is - // also the paid gate: purchase/login completes before npx downloads from + // also the paid gate: entitlement/login completes before npx downloads from // the delivery source declared by the catalog. const installAll = isAllSkillsName(args.name); let installPlans; @@ -470,11 +441,11 @@ async function addAction(rawArgs) { for (const selected of selectedSkills) { if (!selected.paid) continue; accountToken ??= await requireAccountToken(); - await redeemPaidSkill(selected, args.yes, accountToken); + await requirePaidSkillEntitlement(selected, accountToken); } } else { - // The aggregate command is intentionally free-only. Paid Skills must be - // redeemed one at a time so the user sees the exact Credits cost. + // The aggregate command is intentionally free-only. Membership Skills are + // added individually after checking account entitlements. const selectors = await resolveFreeCatalogSelectors(); installPlans = [{ source: GALLERY_INSTALL_SOURCE, selectors, skills: [] }]; } @@ -574,10 +545,11 @@ Options for \`add\`: --with-deps auto-install missing executable deps declared in SKILL.md \`add\` installs from ${GALLERY} (no need to type the gallery path). Free Skills -install directly. A paid Skill signs in, redeems its Credits unless the account -already owns it (purchase or bound license), then downloads its source from -lovstudio.ai and installs it as plain files. Passing \`skills\`, \`all\`, -\`*\`, or \`${GALLERY}\` installs all free entries; paid entries are added one at a time after redemption. Single-skill installs +install directly. A paid Skill signs in, checks VIP Membership or existing +account-bound ownership, then downloads its source from lovstudio.ai and installs +it as plain files. Accounts without access are directed to VIP Membership; +there is no per-Skill Credits redemption. Passing \`skills\`, \`all\`, +\`*\`, or \`${GALLERY}\` installs all free entries; paid entries are added individually after entitlement checks. Single-skill installs automatically include the catalog's transitive \`depends_on\` Skill closure, then read each installed Skill's executable \`dependencies:\` frontmatter and run its \`check\` commands. With --with-deps, missing executable dependencies are installed automatically. diff --git a/test/account-entitlement.test.mjs b/test/account-entitlement.test.mjs index c501913..7c4cade 100644 --- a/test/account-entitlement.test.mjs +++ b/test/account-entitlement.test.mjs @@ -220,15 +220,11 @@ test("an already-owned paid Skill downloads its private source and installs plai res.writeHead(200, { "content-type": "text/yaml" }); return res.end(paidCatalog); } - if (req.url?.startsWith("/api/skills/price")) { + if (req.url?.startsWith("/api/membership?skill=")) { assert.equal(req.headers.authorization, "Bearer valid-access-token"); return json(res, 200, { - skill_id: 7, - skill_name: "write-professional-book", - price_credits: 100, - list_price_credits: 100, - discount_percent: 0, - owned: true, + membership: null, + skill: { name: "write-professional-book", owned: true }, }); } if (req.url === "/api/skills/purchase") { @@ -283,8 +279,8 @@ test("a paid Skill is not installed when the download is refused", async () => { res.writeHead(200, { "content-type": "text/yaml" }); return res.end(paidCatalog); } - if (req.url?.startsWith("/api/skills/price")) { - return json(res, 200, { skill_id: 7, skill_name: "write-professional-book", price_credits: 100, owned: true }); + if (req.url?.startsWith("/api/membership?skill=")) { + return json(res, 200, { membership: null, skill: { name: "write-professional-book", owned: true } }); } if (req.url === "/api/skills/download") return json(res, 403, { error: "skill_not_owned" }); return json(res, 404, { error: "not_found" }); diff --git a/test/skills-vip-membership.test.mjs b/test/skills-vip-membership.test.mjs new file mode 100644 index 0000000..c1cd662 --- /dev/null +++ b/test/skills-vip-membership.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn, spawnSync } from "node:child_process"; +import test from "node:test"; + +const repoRoot = process.env.LOVSTUDIO_TEST_CLI_ROOT || dirname(dirname(fileURLToPath(import.meta.url))); +const skillName = "3d-motion-media-creator"; +const paidCatalog = `skills:\n- name: ${skillName}\n runtime_name: lov-${skillName}\n repo: lovstudio/${skillName}-skill\n paid: true\n`; +const freeCatalog = paidCatalog.replace("paid: true", "paid: false"); + +function json(res, status, body) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +async function fixture() { + const dir = await mkdtemp(join(tmpdir(), "lovstudio-vip-install-")); + const home = join(dir, "account"); + const bin = join(dir, "bin"); + 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 chmod(join(bin, "npx"), 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`); + const tarball = join(dir, "skill.tar.gz"); + const packed = spawnSync("tar", ["-czf", tarball, "-C", archiveDir, "skill-v0.1.1"]); + assert.equal(packed.status, 0, String(packed.stderr)); + return { dir, home, bin, archive: await readFile(tarball) }; +} + +function run(env) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [join(repoRoot, "bin/lovstudio.mjs"), "skills", "add", skillName, "--project", "-a", "codex", "-y"], { + cwd: repoRoot, + env: { ...process.env, HTTPS_PROXY: "", HTTP_PROXY: "", https_proxy: "", http_proxy: "", LOVSTUDIO_NO_BROWSER: "1", ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.on("close", (status) => resolve({ status, stdout, stderr })); + }); +} + +const scenarios = [ + { name: "active VIP installs without Credits requests", active: true, owned: true, status: 0 }, + { name: "existing license owner installs without VIP or Credits requests", active: false, owned: true, status: 0 }, + { name: "mismatched Skill entitlement cannot download or install", active: true, owned: true, membershipName: "another-skill", status: 1, nameMismatch: true }, + { name: "missing Skill name in entitlement cannot download or install", active: true, owned: true, missingName: true, status: 1, nameMismatch: true }, + { name: "account without entitlement receives VIP link and cannot download", active: false, owned: false, status: 1, vip: true }, + { name: "guest device binding failure cannot download", guest: true, status: 1 }, + { name: "expired session refreshes before membership and download", active: true, owned: true, expired: true, status: 0 }, + { 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 }, +]; + +for (const scenario of scenarios) { + test(scenario.name, async () => { + const f = await fixture(); + if (scenario.guest || scenario.free) await rm(join(f.home, "auth.yml")); + if (scenario.expired) { + await writeFile(join(f.home, "auth.yml"), "access_token: expired-access-token\nrefresh_token: valid-refresh-token\nexpires_at: 1\nuser_id: fixture-user\n"); + } + const requests = []; + const api = createServer(async (req, res) => { + requests.push(req.url); + if (req.url === "/skills.yaml") { + res.writeHead(200, { "content-type": "text/yaml" }); + return res.end(scenario.free ? freeCatalog : paidCatalog); + } + 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, + expiresAt: Math.floor(Date.now() / 1000) + 3600, user: { id: "fixture-user", email: "fixture@example.com" }, + }); + if (req.url === `/api/membership?skill=${skillName}`) { + assert.equal(req.headers.authorization, `Bearer ${scenario.expired ? "refreshed" : "valid"}-access-token`); + if (scenario.membershipStatus) return json(res, scenario.membershipStatus, { error: "unauthorized" }); + return json(res, 200, { plan: {}, membership: scenario.active ? { active: true, lifetime: true, expiresAt: null } : null, skill: { ...(scenario.missingName ? {} : { name: scenario.membershipName || skillName }), owned: scenario.owned === true } }); + } + if (req.url === "/api/skills/download") { + assert.equal(req.headers.authorization, `Bearer ${scenario.expired ? "refreshed" : "valid"}-access-token`); + let body = ""; + for await (const chunk of req) body += chunk; + assert.deepEqual(JSON.parse(body), { skill_name: skillName }); + if (scenario.downloadStatus) return json(res, scenario.downloadStatus, { error: "ownership_required", vip_url: `/vip?from=skill:${skillName}` }); + return json(res, 200, { download_url: `${baseUrl}/archive.tar.gz`, version: "0.1.1", ref: "v0.1.1", skill_path: "" }); + } + if (req.url === "/archive.tar.gz") { + res.writeHead(200, { "content-type": "application/gzip" }); + return res.end(f.archive); + } + return json(res, 404, { error: "not_found" }); + }); + 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}` }); + 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) { + assert.match(result.stdout, /MOCK_NPX/); + assert.doesNotMatch(result.stdout, /MOCK_NPX:.*--global/, "--project must not install globally"); + if (!scenario.free) { + assert.match(result.stdout, /VIP_PLAIN_SOURCE/); + assert.ok(requests.indexOf(`/api/membership?skill=${skillName}`) < requests.indexOf("/api/skills/download")); + } + } else { + assert.doesNotMatch(result.stdout, /MOCK_NPX/); + if (!scenario.downloadStatus) assert.equal(requests.filter((url) => url === "/api/skills/download").length, 0); + } + if (scenario.vip) assert.ok(result.stderr.includes(`/vip?from=skill:${skillName}`), result.stderr); + if (scenario.membershipStatus === 401) assert.match(result.stderr, /登录状态已失效/); + 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); + } finally { + await new Promise((resolve, reject) => api.close((error) => error ? reject(error) : resolve())); + await rm(f.dir, { recursive: true, force: true }); + } + }); +}