diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e29987..c6e18d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # lovstudio +## 0.5.0 + +### Minor Changes + +- Paid Skills now install as plain source. After the account's ownership is confirmed (Credits purchase or an activated license bound to the account), `skills add` requests the Skill's archive from lovstudio.ai, extracts it, and installs the files directly; the encrypted-bundle and public-source delivery paths and the local helper license probe are gone. Reinstalling a paid Skill with the same command replaces an older encrypted install. + ## 0.4.28 ### Patch Changes diff --git a/README.md b/README.md index 83783e4..559d301 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ npx -y lovstudio@latest account connect npx -y lovstudio@latest skills add any2pdf # Install a paid Skill (the command signs in, checks ownership, then downloads -# its declared encrypted bundle or public source) +# its source from lovstudio.ai) npx -y lovstudio@latest skills add subtitle-freedom # Install a skill + preflight its runtime deps, auto-installing any that are missing @@ -108,18 +108,16 @@ npx -y lovstudio@latest skills list ``` `skills add` reads the unified `lovstudio/skills` catalog before installation. -Free Skills install directly. Paid Skills must declare either an encrypted -bundle or an explicit public-source delivery. The CLI first refreshes and checks -an activated local license, including dynamic `all` access; a matching license -installs without Credits. Otherwise it connects this device to the user's -Lovstudio website account and checks that account's current ownership: a Skill -already purchased on the website installs immediately without another Credits -confirmation or purchase request. For a Skill not yet owned, the CLI confirms -the current Credits price and completes the redemption before downloading from -the declared source. Encrypted placeholders use the same account entitlement at -runtime, so no activation key is needed and protected plaintext is not written -to disk. Public-source paid Skills remain openly inspectable and install from -their catalog-declared repository after the same ownership check. +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 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 7798041..ca54806 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lovstudio", - "version": "0.4.28", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lovstudio", - "version": "0.4.28", + "version": "0.5.0", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.6", diff --git a/package.json b/package.json index 5c1654c..df91625 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lovstudio", - "version": "0.4.28", + "version": "0.5.0", "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 2a750a1..332288e 100644 --- a/src/commands/skills/index.mjs +++ b/src/commands/skills/index.mjs @@ -1,12 +1,12 @@ -import { readFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { homedir } from "node:os"; +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"; -import { runHelper, UVX_PREFIX } from "../../lib/helper.mjs"; +import { runHelper } from "../../lib/helper.mjs"; import { AccountError, requireAccountSession } from "../../lib/account.mjs"; const GALLERY = "lovstudio/skills"; @@ -155,19 +155,19 @@ export function catalogSkillDependencyClosure(catalog, rootSkill) { export function catalogSkillInstallPlans(skills) { const plans = []; for (const skill of skills) { - const source = skill?.paid ? paidSkillInstallSource(skill) : GALLERY_INSTALL_SOURCE; - if (!source) { - throw new Error( - `Skill「${skill?.name || "unknown"}」已标记为付费,但聚合目录还没有可分发的加密包。`, - ); - } const selector = catalogSkillSelector(skill); + if (skill?.paid) { + // Paid sources are private; each one is downloaded on its own once the + // account's entitlement is confirmed. + plans.push({ paid: true, source: null, selectors: [selector], skills: [skill] }); + continue; + } const current = plans.at(-1); - if (current?.source === source) { + if (current && !current.paid) { current.selectors.push(selector); current.skills.push(skill); } else { - plans.push({ source, selectors: [selector], skills: [skill] }); + plans.push({ paid: false, source: GALLERY_INSTALL_SOURCE, selectors: [selector], skills: [skill] }); } } return plans; @@ -221,52 +221,10 @@ async function confirmPurchase(name, price, yes) { } } -export function paidSkillInstallSource(skill) { - if (skill?.encrypted_bundle) return GALLERY_INSTALL_SOURCE; - if (skill?.public_source && String(skill?.repo || "").trim()) { - return String(skill.repo).trim(); - } - return null; -} - -export function licenseStatusEntitlesSkill(status, skillName) { - const canonical = canonicalSkillName(skillName); - return Array.isArray(status?.licenses) && status.licenses.some((license) => - Array.isArray(license?.entitled_skills) && - license.entitled_skills.some((name) => canonicalSkillName(name) === canonical) - ); -} - -function readLocalLicenseStatus() { - if (process.env.LOVSTUDIO_SKIP_LOCAL_LICENSE === "1" || !hasBin("uvx")) return null; - const result = runCapture("uvx", [...UVX_PREFIX, "status", "--json"]); - if (result.status !== 0) return null; - try { - return JSON.parse(result.stdout); - } catch { - return null; - } -} - -function localLicenseEntitlesSkill(skillName) { - let status = readLocalLicenseStatus(); - if (licenseStatusEntitlesSkill(status, skillName)) return true; - if (!status?.activated) return false; - - // Dynamic all licenses learn about newly listed Skills on heartbeat. Ignore - // refresh failures here so account ownership remains a safe fallback. - runCapture("uvx", [...UVX_PREFIX, "heartbeat"]); - status = readLocalLicenseStatus(); - return licenseStatusEntitlesSkill(status, skillName); -} - -async function redeemPaidSkill(skill, yes) { - if (localLicenseEntitlesSkill(skill.name)) { - console.log(`✓ 本机 license 已授权「${skill.name}」,直接安装,不需要 Credits。`); - return; - } - - const token = await requireAccountToken(); +// 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); @@ -309,6 +267,62 @@ async function redeemPaidSkill(skill, yes) { console.log(body.already_owned ? `✓ 已拥有「${skill.name}」,无需重复扣除 Credits${balance}。` : `✓ 已兑换「${skill.name}」${balance}。`); } +export async function locateExtractedSkill(root, skillPath = "") { + const entries = (await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory()); + if (entries.length !== 1) throw new Error(`unexpected archive layout (${entries.length} top-level directories)`); + const dir = join(root, entries[0].name, skillPath || ""); + if (!existsSync(join(dir, "SKILL.md"))) { + throw new Error(`SKILL.md not found at ${skillPath || "the repository root"}`); + } + return dir; +} + +// Paid Skill sources live in private repositories. The website checks the +// account's entitlement and returns a short-lived archive URL; the installed +// copy is plain source. +async function downloadPaidSkill(skill, token) { + const response = await hfetch(`${WEB_URL}/api/skills/download`, { + 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 || typeof body.download_url !== "string") { + console.error(`获取「${skill.name}」的下载地址失败:${body.error || `HTTP ${response.status}`}`); + process.exit(1); + } + + const archive = await hfetch(body.download_url, { signal: AbortSignal.timeout(180_000) }); + if (!archive.ok) { + console.error(`下载「${skill.name}」失败:HTTP ${archive.status}`); + process.exit(1); + } + const workDir = await mkdtemp(join(tmpdir(), "lovstudio-skill-")); + const tarball = join(workDir, "source.tar.gz"); + const extracted = join(workDir, "source"); + await writeFile(tarball, Buffer.from(await archive.arrayBuffer())); + await mkdir(extracted); + const untar = runCapture("tar", ["-xzf", tarball, "-C", extracted]); + if (untar.status !== 0) { + await rm(workDir, { recursive: true, force: true }); + console.error(`解压「${skill.name}」失败:${untar.stderr || `tar exited ${untar.status}`}`); + process.exit(1); + } + try { + const skillDir = await locateExtractedSkill(extracted, body.skill_path); + return { workDir, skillDir, version: body.version, ref: body.ref }; + } catch (error) { + await rm(workDir, { recursive: true, force: true }); + console.error(`「${skill.name}」的源码包无法安装:${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} + // ───────────────────────────────────────────────────────────────────────────── // Frontmatter / dependency preflight // ───────────────────────────────────────────────────────────────────────────── @@ -443,6 +457,7 @@ async function addAction(rawArgs) { const installAll = isAllSkillsName(args.name); let installPlans; let selectedSkills = []; + let accountToken = null; if (!installAll) { const { catalog, skill } = await resolveCatalogSkill(args.name); try { @@ -453,12 +468,13 @@ async function addAction(rawArgs) { process.exit(1); } for (const selected of selectedSkills) { - if (selected.paid) await redeemPaidSkill(selected, args.yes); + if (!selected.paid) continue; + accountToken ??= await requireAccountToken(); + await redeemPaidSkill(selected, args.yes, 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 and the - // installer never pulls a paid delivery source before entitlement is confirmed. + // redeemed one at a time so the user sees the exact Credits cost. const selectors = await resolveFreeCatalogSelectors(); installPlans = [{ source: GALLERY_INSTALL_SOURCE, selectors, skills: [] }]; } @@ -473,8 +489,17 @@ async function addAction(rawArgs) { : ""; console.log(`Installing ${installAll ? "all free Lovstudio skills" : args.name}${dependencyLabel}...`); for (const plan of installPlans) { + let source = plan.source; + let workDir = null; + if (plan.paid) { + const download = await downloadPaidSkill(plan.skills[0], accountToken); + source = download.skillDir; + workDir = download.workDir; + const version = download.version ? ` v${download.version}` : ""; + console.log(`✓ 已下载「${plan.skills[0].name}」${version}(${download.ref})`); + } const skillArgs = [ - "-y", SKILLS_NPX_SPEC, "add", plan.source, + "-y", SKILLS_NPX_SPEC, "add", source, "--skill", ...plan.selectors, ]; if (args.agent) skillArgs.push("-a", args.agent); @@ -483,6 +508,7 @@ async function addAction(rawArgs) { skillArgs.push(...args.extra); const installCode = runInherit("npx", skillArgs); + if (workDir) await rm(workDir, { recursive: true, force: true }); if (installCode !== 0) { console.error(`\nnpx skills add exited ${installCode}`); process.exit(installCode); @@ -548,9 +574,9 @@ 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 must declare either an encrypted bundle or an -explicit public-source delivery; the command signs in and redeems its Credits -before downloading from that source. Passing \`skills\`, \`all\`, +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 automatically include the catalog's transitive \`depends_on\` Skill closure, then read each installed Skill's executable \`dependencies:\` frontmatter and run its diff --git a/test/account-entitlement.test.mjs b/test/account-entitlement.test.mjs index 294c7ca..c501913 100644 --- a/test/account-entitlement.test.mjs +++ b/test/account-entitlement.test.mjs @@ -33,7 +33,6 @@ function run(args, env) { https_proxy: "", http_proxy: "", LOVSTUDIO_NO_BROWSER: "1", - LOVSTUDIO_SKIP_LOCAL_LICENSE: "1", ...env, }, stdio: ["ignore", "pipe", "pipe"], @@ -178,8 +177,8 @@ test("account disconnect revokes the website session and removes the shared loca } }); -test("an already-owned website Skill installs without purchase confirmation or purchase mutation", async () => { - const fixture = await mkdtemp(join(tmpdir(), "lovstudio-account-owned-")); +async function paidFixture(prefix) { + const fixture = await mkdtemp(join(tmpdir(), prefix)); const home = join(fixture, ".lovstudio"); const bin = join(fixture, "bin"); await mkdir(home, { recursive: true }); @@ -194,14 +193,32 @@ test("an already-owned website Skill installs without purchase confirmation or p email: "buyer@example.com", }), ); + // $4 is the install source passed to `npx -y skills@latest add `. const npx = join(bin, "npx"); - await writeFile(npx, "#!/bin/sh\necho \"MOCK_NPX:$*\"\n"); + await writeFile(npx, "#!/bin/sh\necho \"MOCK_NPX:$*\"\ncat \"$4/SKILL.md\"\n"); await chmod(npx, 0o755); + + // A GitHub-style archive: one top-level directory, the Skill under src/. + const repo = join(fixture, "archive", "lovstudio-write-professional-book-skill-abc123"); + await mkdir(join(repo, "src"), { recursive: true }); + await writeFile(join(repo, "README.md"), "# root readme\n"); + await writeFile(join(repo, "src", "SKILL.md"), "---\nname: lov-write-professional-book\n---\nPLAIN_SOURCE_MARKER\n"); + const tarball = join(fixture, "source.tar.gz"); + const tar = spawn("tar", ["-czf", tarball, "-C", join(fixture, "archive"), "lovstudio-write-professional-book-skill-abc123"]); + await new Promise((resolve, reject) => tar.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`tar ${code}`))))); + return { home, bin, tarball: await readFile(tarball) }; +} + +const paidCatalog = `skills:\n- name: write-professional-book\n runtime_name: lov-write-professional-book\n repo: lovstudio/write-professional-book-skill\n skill_path: src\n paid: true\n`; + +test("an already-owned paid Skill downloads its private source and installs plain files", async () => { + const { home, bin, tarball } = await paidFixture("lovstudio-account-owned-"); let purchaseCalls = 0; + let downloadAuth = null; const api = await listen(async (req, res) => { if (req.url === "/skills.yaml") { res.writeHead(200, { "content-type": "text/yaml" }); - return res.end(`skills:\n- name: write-professional-book\n runtime_name: lov-write-professional-book\n paid: true\n encrypted_bundle: true\n`); + return res.end(paidCatalog); } if (req.url?.startsWith("/api/skills/price")) { assert.equal(req.headers.authorization, "Bearer valid-access-token"); @@ -218,72 +235,26 @@ test("an already-owned website Skill installs without purchase confirmation or p purchaseCalls += 1; return json(res, 500, { error: "must_not_purchase" }); } - return json(res, 404, { error: "not_found" }); - }); - try { - const result = await run( - ["skills", "add", "write-professional-book", "-g", "-a", "codex", "-y"], - { - LOVSTUDIO_HOME: home, - LOVSTUDIO_WEB_URL: api.baseUrl, - LOVSTUDIO_SKILLS_CATALOG_URL: `${api.baseUrl}/skills.yaml`, - PATH: `${bin}${delimiter}${process.env.PATH}`, - }, - ); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /网站账号已拥有.*直接安装/); - assert.match(result.stdout, /MOCK_NPX:.*--skill lov-write-professional-book/); - assert.equal(purchaseCalls, 0); - } finally { - await api.close(); - } -}); - -test("an already-owned public-source paid Skill installs from its source repository", async () => { - const fixture = await mkdtemp(join(tmpdir(), "lovstudio-account-public-source-")); - const home = join(fixture, ".lovstudio"); - const bin = join(fixture, "bin"); - await mkdir(home, { recursive: true }); - await mkdir(bin, { recursive: true }); - await writeFile( - join(home, "auth.yml"), - stringifyYaml({ - access_token: "valid-access-token", - refresh_token: "valid-refresh-token", - expires_at: Math.floor(Date.now() / 1000) + 3600, - user_id: "user-123", - email: "buyer@example.com", - }), - ); - const npx = join(bin, "npx"); - await writeFile(npx, "#!/bin/sh\necho \"MOCK_NPX:$*\"\n"); - await chmod(npx, 0o755); - let purchaseCalls = 0; - const api = await listen(async (req, res) => { - if (req.url === "/skills.yaml") { - res.writeHead(200, { "content-type": "text/yaml" }); - return res.end(`skills:\n- name: media-creator\n runtime_name: lov-media-creator\n repo: lovstudio/media-creator-skill\n paid: true\n public_source: true\n`); - } - if (req.url?.startsWith("/api/skills/price")) { - assert.equal(req.headers.authorization, "Bearer valid-access-token"); + if (req.url === "/api/skills/download") { + downloadAuth = req.headers.authorization; return json(res, 200, { - skill_id: 637, - skill_name: "media-creator", - price_credits: 1394, - list_price_credits: 1394, - discount_percent: 0, - owned: true, + skill_name: "write-professional-book", + runtime_name: "lov-write-professional-book", + version: "0.4.1", + ref: "v0.4.1", + skill_path: "src", + download_url: `${api.baseUrl}/codeload/archive.tar.gz?token=tmp`, }); } - if (req.url === "/api/skills/purchase") { - purchaseCalls += 1; - return json(res, 500, { error: "must_not_purchase" }); + if (req.url?.startsWith("/codeload/")) { + res.writeHead(200, { "content-type": "application/x-gzip" }); + return res.end(tarball); } return json(res, 404, { error: "not_found" }); }); try { const result = await run( - ["skills", "add", "media-creator", "-g", "-a", "codex", "-y"], + ["skills", "add", "write-professional-book", "-g", "-a", "codex", "-y"], { LOVSTUDIO_HOME: home, LOVSTUDIO_WEB_URL: api.baseUrl, @@ -293,63 +264,44 @@ test("an already-owned public-source paid Skill installs from its source reposit ); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /网站账号已拥有.*直接安装/); - assert.match( - result.stdout, - /MOCK_NPX:.*add lovstudio\/media-creator-skill .*--skill lov-media-creator/, - ); + assert.match(result.stdout, /已下载「write-professional-book」 v0\.4\.1(v0\.4\.1)/); + const source = result.stdout.match(/MOCK_NPX:-y skills@latest add (\S+) --skill lov-write-professional-book/)?.[1]; + assert.ok(source?.endsWith("/src"), result.stdout); + assert.match(result.stdout, /PLAIN_SOURCE_MARKER/); + assert.equal(downloadAuth, "Bearer valid-access-token"); assert.equal(purchaseCalls, 0); + await assert.rejects(stat(source), { code: "ENOENT" }); } finally { await api.close(); } }); -test("a local license entitlement installs a paid Skill without account or Credits requests", async () => { - const fixture = await mkdtemp(join(tmpdir(), "lovstudio-local-license-owned-")); - const home = join(fixture, ".lovstudio"); - const bin = join(fixture, "bin"); - await mkdir(home, { recursive: true }); - await mkdir(bin, { recursive: true }); - - const uvx = join(bin, "uvx"); - await writeFile( - uvx, - "#!/bin/sh\nprintf '%s\\n' '{\"activated\":true,\"licenses\":[{\"entitled_skills\":[\"media-creator\"]}]}'\n", - ); - await chmod(uvx, 0o755); - const npx = join(bin, "npx"); - await writeFile(npx, "#!/bin/sh\necho \"MOCK_NPX:$*\"\n"); - await chmod(npx, 0o755); - - let priceCalls = 0; - let purchaseCalls = 0; +test("a paid Skill is not installed when the download is refused", async () => { + const { home, bin } = await paidFixture("lovstudio-account-refused-"); const api = await listen(async (req, res) => { if (req.url === "/skills.yaml") { res.writeHead(200, { "content-type": "text/yaml" }); - return res.end(`skills:\n- name: media-creator\n runtime_name: lov-media-creator\n repo: lovstudio/media-creator-skill\n paid: true\n public_source: true\n`); + return res.end(paidCatalog); } - if (req.url?.startsWith("/api/skills/price")) priceCalls += 1; - if (req.url === "/api/skills/purchase") purchaseCalls += 1; - return json(res, 500, { error: "must_not_call_account_or_credits" }); + 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 === "/api/skills/download") return json(res, 403, { error: "skill_not_owned" }); + return json(res, 404, { error: "not_found" }); }); try { const result = await run( - ["skills", "add", "media-creator", "-g", "-a", "codex", "-y"], + ["skills", "add", "write-professional-book", "-g", "-a", "codex", "-y"], { LOVSTUDIO_HOME: home, LOVSTUDIO_WEB_URL: api.baseUrl, LOVSTUDIO_SKILLS_CATALOG_URL: `${api.baseUrl}/skills.yaml`, - LOVSTUDIO_SKIP_LOCAL_LICENSE: "0", PATH: `${bin}${delimiter}${process.env.PATH}`, }, ); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /本机 license 已授权.*不需要 Credits/); - assert.match( - result.stdout, - /MOCK_NPX:.*add lovstudio\/media-creator-skill .*--skill lov-media-creator/, - ); - assert.equal(priceCalls, 0); - assert.equal(purchaseCalls, 0); + assert.equal(result.status, 1); + assert.match(result.stderr, /获取「write-professional-book」的下载地址失败:skill_not_owned/); + assert.doesNotMatch(result.stdout, /MOCK_NPX/); } finally { await api.close(); } diff --git a/test/skills-command.test.mjs b/test/skills-command.test.mjs index f6d0f24..8a7efbb 100644 --- a/test/skills-command.test.mjs +++ b/test/skills-command.test.mjs @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; import { @@ -7,8 +10,7 @@ import { catalogSkillSelector, canonicalSkillName, findCatalogSkill, - licenseStatusEntitlesSkill, - paidSkillInstallSource, + locateExtractedSkill, skillSelector, } from "../src/commands/skills/index.mjs"; @@ -42,30 +44,23 @@ test("catalogSkillDependencyClosure rejects missing and cyclic dependencies", () ); }); -test("catalogSkillInstallPlans groups adjacent selectors by delivery source", () => { +test("catalogSkillInstallPlans groups free Skills and downloads each paid Skill on its own", () => { const freeBrand = { name: "branding-consistency", runtime_name: "lov-branding-consistency", paid: false }; const freeHuman = { name: "human-writing", runtime_name: "lov-human-writing", paid: false }; - const paidPublic = { - name: "public-paid", - runtime_name: "lov-public-paid", - paid: true, - public_source: true, - repo: "lovstudio/public-paid-skill", - }; + const paidA = { name: "proposal", runtime_name: "lov-proposal", paid: true }; + const paidB = { name: "event-poster", runtime_name: "lov-event-poster", paid: true }; assert.deepEqual( - catalogSkillInstallPlans([freeBrand, freeHuman, paidPublic]), + catalogSkillInstallPlans([freeBrand, freeHuman, paidA, paidB]), [ { + paid: false, source: "https://github.com/lovstudio/skills.git", selectors: ["lov-branding-consistency", "lov-human-writing"], skills: [freeBrand, freeHuman], }, - { - source: "lovstudio/public-paid-skill", - selectors: ["lov-public-paid"], - skills: [paidPublic], - }, + { paid: true, source: null, selectors: ["lov-proposal"], skills: [paidA] }, + { paid: true, source: null, selectors: ["lov-event-poster"], skills: [paidB] }, ], ); }); @@ -128,31 +123,11 @@ test("findCatalogSkill accepts both product slugs and exact runtime names", () = assert.equal(findCatalogSkill(catalog, "lov-legacy-entry"), catalog[3]); }); -test("paidSkillInstallSource distinguishes encrypted and public-source delivery", () => { - assert.equal( - paidSkillInstallSource({ paid: true, encrypted_bundle: true }), - "https://github.com/lovstudio/skills.git", - ); - assert.equal( - paidSkillInstallSource({ - paid: true, - public_source: true, - repo: "lovstudio/media-creator-skill", - }), - "lovstudio/media-creator-skill", - ); - assert.equal(paidSkillInstallSource({ paid: true }), null); - assert.equal(paidSkillInstallSource({ paid: true, public_source: true }), null); -}); +test("locateExtractedSkill resolves the Skill directory inside a GitHub archive", async () => { + const root = await mkdtemp(join(tmpdir(), "lovstudio-extract-")); + await mkdir(join(root, "lovstudio-proposal-skill-abc", "src"), { recursive: true }); + await writeFile(join(root, "lovstudio-proposal-skill-abc", "src", "SKILL.md"), "---\nname: lov-proposal\n---\n"); -test("licenseStatusEntitlesSkill accepts catalog and runtime aliases", () => { - const status = { - licenses: [ - { entitled_skills: ["lov-media-creator", "lovstudio-write-professional-book"] }, - ], - }; - assert.equal(licenseStatusEntitlesSkill(status, "media-creator"), true); - assert.equal(licenseStatusEntitlesSkill(status, "lovstudio:write-professional-book"), true); - assert.equal(licenseStatusEntitlesSkill(status, "subtitle-freedom"), false); - assert.equal(licenseStatusEntitlesSkill({ activated: true }, "media-creator"), false); + assert.equal(await locateExtractedSkill(root, "src"), join(root, "lovstudio-proposal-skill-abc", "src")); + await assert.rejects(locateExtractedSkill(root, ""), /SKILL\.md not found at the repository root/); });