diff --git a/.github/workflows/pack.yml b/.github/workflows/pack.yml index a43ce03..1701771 100644 --- a/.github/workflows/pack.yml +++ b/.github/workflows/pack.yml @@ -28,6 +28,9 @@ jobs: run: bun install --frozen-lockfile - name: Pack + env: + S3_URL: ${{ secrets.S3_URL }} + S3_PUBLIC_URL: ${{ secrets.S3_PUBLIC_URL }} run: bun run pack - name: Publish catalog-${{ github.sha }} env: diff --git a/README.md b/README.md index a4cae2c..546808b 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ bun install bun run pack # writes dist/catalog/ and dist/catalog.tar.gz ``` +CI pack (`main` / `dev`) uploads logo files to the public CDN and stamps `https://cdn-public.reforma.ai/plugins/.` into packed `plugin.json`. Local pack without `S3_URL` / `S3_PUBLIC_URL` skips that and is not a source for the API — pull the GitHub `catalog-` snapshot instead. + Pack discovers convention folders (`skills/`, `agents/`, `rules/`, `hooks/`, `tools/`, `mcp.json` or `.mcp.json`) — no path fields needed in source `plugin.json`. Custom paths are remapped into that layout. If you import a Cursor/vendor plugin, pack also normalizes `rules/*.mdc`, `.cursor/rules/`, and `instructions/` to plain `rules/*.md`, renames `.mcp.json` → `mcp.json`, and renames a lone MCP server key to the marketplace plugin name (`chatgpt_app_mcp` → `dropbox`). HTTP MCP with no `tools` / `resources` / `resourceTemplates` in `mcp.json`: pack calls `initialize`, then `tools/list`, `resources/list`, and `resources/templates/list` for whichever are missing. Bodies are not packed. Stdio MCP is not probed. Packed `plugin.json` keeps `hooks: "./hooks/hooks.json"` (+ `hookOffers`) and `tools: "./tools.mjs"` (+ `toolOffers`) when those ship. Skills/agents/rules/mcp paths are stripped — catalog and sandbox discover the folders. Pack **bundles** `tools/*.ts` into one `tools.mjs` (`@reforma/plugin-sdk` / `ai` / `zod` stay external). Export `defineTool` as the file default; name = filename (`Grep.ts` → `Grep`); `override: true` = bare name, shadows. diff --git a/scripts/pack/index.ts b/scripts/pack/index.ts index cbe2a51..b67dcc0 100644 --- a/scripts/pack/index.ts +++ b/scripts/pack/index.ts @@ -22,6 +22,7 @@ import { normalizePluginLogos, applyCatalogOverlay } from "./logos.ts"; import { discoverMcpTools } from "./mcp-tools.ts"; import { stampPackedAgent } from "./agent.ts"; import { OUT, ROOT, TAR, parseMarketplace, type MarketplaceListing } from "./shared.ts"; +import { publishCatalogLogosFromEnv } from "./publish-logos.ts"; import { logCatalogSummary } from "./summary.ts"; import { normalizePluginTools } from "./tools.ts"; @@ -84,6 +85,8 @@ writeFileSync( `${JSON.stringify({ categories: marketplace.categories }, null, 4)}\n`, ); +await publishCatalogLogosFromEnv(OUT); + mkdirSync(dirname(TAR), { recursive: true }); const tar = spawnSync( "tar", diff --git a/scripts/pack/publish-logos.test.ts b/scripts/pack/publish-logos.test.ts new file mode 100644 index 0000000..bb7c5c7 --- /dev/null +++ b/scripts/pack/publish-logos.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "bun:test"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + publishCatalogLogos, + type LogoObjectStore, +} from "./publish-logos.ts"; + +const LOGO_SVG = ''; +const CDN = /^https:\/\/cdn\.example\/plugins\/[a-f0-9]{64}\.svg$/; + +function writePackedPlugin(dir: string, name: string, logo = "assets/logo.svg"): void { + writeFileSync( + join(dir, "marketplace.json"), + `${JSON.stringify({ + categories: [ + { + id: "demo", + name: "Demo", + plugins: [{ name, source: `./${name}` }], + }, + ], + })}\n`, + ); + + const pluginDir = join(dir, name); + + mkdirSync(join(pluginDir, ".reforma-plugin"), { recursive: true }); + mkdirSync(join(pluginDir, "assets"), { recursive: true }); + writeFileSync( + join(pluginDir, ".reforma-plugin/plugin.json"), + `${JSON.stringify({ name, logo }, null, 4)}\n`, + ); + writeFileSync(join(pluginDir, "assets/logo.svg"), LOGO_SVG); +} + +function memoryStore(): LogoObjectStore { + const objects = new Map(); + + return { + exists: async (key) => objects.has(key), + put: async (key, body) => { + objects.set(key, body); + }, + }; +} + +describe("publishCatalogLogos", () => { + it("stamps a content-hash CDN url and uploads once", async () => { + const dir = mkdtempSync(join(tmpdir(), "catalog-logos-")); + + writePackedPlugin(dir, "demo-hooks"); + + const store = memoryStore(); + const first = await publishCatalogLogos(dir, { + store, + publicEndpoint: "https://cdn.example", + }); + const stamped = JSON.parse( + readFileSync(join(dir, "demo-hooks/.reforma-plugin/plugin.json"), "utf8"), + ).logo as string; + + expect(first).toEqual({ uploaded: 1, cached: 0 }); + expect(stamped).toMatch(CDN); + + writeFileSync( + join(dir, "demo-hooks/.reforma-plugin/plugin.json"), + `${JSON.stringify({ name: "demo-hooks", logo: "assets/logo.svg" }, null, 4)}\n`, + ); + + const second = await publishCatalogLogos(dir, { + store, + publicEndpoint: "https://cdn.example", + }); + + expect(second).toEqual({ uploaded: 0, cached: 1 }); + expect( + JSON.parse( + readFileSync(join(dir, "demo-hooks/.reforma-plugin/plugin.json"), "utf8"), + ).logo, + ).toBe(stamped); + }); + + it("leaves an already-public https logo untouched", async () => { + const dir = mkdtempSync(join(tmpdir(), "catalog-logos-")); + const cdn = "https://cdn.example/plugins/already.svg"; + + writePackedPlugin(dir, "stripe", cdn); + + const store = memoryStore(); + const result = await publishCatalogLogos(dir, { + store, + publicEndpoint: "https://cdn.example", + }); + + expect(result).toEqual({ uploaded: 0, cached: 0 }); + expect( + JSON.parse( + readFileSync(join(dir, "stripe/.reforma-plugin/plugin.json"), "utf8"), + ).logo, + ).toBe(cdn); + }); +}); diff --git a/scripts/pack/publish-logos.ts b/scripts/pack/publish-logos.ts new file mode 100644 index 0000000..3a4a079 --- /dev/null +++ b/scripts/pack/publish-logos.ts @@ -0,0 +1,201 @@ +/** + * Upload packed logo files to the public CDN and stamp https URLs into plugin.json. + * Local pack without S3_URL skips this; CI requires it. + */ +import { S3Client } from "bun"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + extOf, + readLogoFields, + writeLogoFields, +} from "./logos.ts"; +import { + findManifestPath, + isRecord, + parseMarketplace, + stripPath, +} from "./shared.ts"; + +const BUCKET = "reforma-public"; + +export type LogoObjectStore = { + exists: (key: string) => Promise; + put: (key: string, body: Buffer, contentType: string) => Promise; +}; + +function logoMime(ext: string): string | undefined { + if (ext === "svg") { + return "image/svg+xml"; + } + + if (ext === "png") { + return "image/png"; + } + + if (ext === "webp") { + return "image/webp"; + } + + if (ext === "jpg" || ext === "jpeg") { + return "image/jpeg"; + } + + return undefined; +} + +function catalogLogoKey(buffer: Buffer, ext: string): string { + const keyExt = ext === "jpeg" ? "jpg" : ext; + + return `plugins/${createHash("sha256").update(buffer).digest("hex")}.${keyExt}`; +} + +async function publishLogoFile( + pluginDir: string, + rel: string, + store: LogoObjectStore, + publicEndpoint: string, +): Promise<{ url: string; status: "public" | "uploaded" | "cached" }> { + if (/^https:\/\//i.test(rel)) { + return { url: rel, status: "public" }; + } + + const path = stripPath(rel); + const ext = extOf(path); + const mime = logoMime(ext); + + if (!mime) { + throw new Error(`unsupported catalog logo ${rel}`); + } + + const buffer = readFileSync(join(pluginDir, path)); + const key = catalogLogoKey(buffer, ext); + const url = `${publicEndpoint}/${key}`; + + if (await store.exists(key)) { + return { url, status: "cached" }; + } + + await store.put(key, buffer, mime); + + return { url, status: "uploaded" }; +} + +export async function publishCatalogLogos( + catalogDir: string, + options: { store: LogoObjectStore; publicEndpoint: string }, +): Promise<{ uploaded: number; cached: number }> { + const publicEndpoint = options.publicEndpoint.replace(/\/$/, ""); + const marketplace = parseMarketplace( + JSON.parse(readFileSync(join(catalogDir, "marketplace.json"), "utf8")) as unknown, + ); + let uploaded = 0; + let cached = 0; + + await Promise.all( + marketplace.plugins.map(async (plugin) => { + const pluginDir = join(catalogDir, plugin.name); + const manifestPath = findManifestPath(pluginDir); + + if (!manifestPath) { + throw new Error(`no plugin.json in ${pluginDir}`); + } + + const json = JSON.parse(readFileSync(manifestPath, "utf8")) as unknown; + + if (!isRecord(json)) { + throw new Error(`invalid plugin.json ${manifestPath}`); + } + + const fields = readLogoFields(json); + + if (!fields.logo) { + return; + } + + const logo = await publishLogoFile( + pluginDir, + fields.logo, + options.store, + publicEndpoint, + ); + const logoSmall = + fields.logoSmall && fields.logoSmall !== fields.logo + ? await publishLogoFile( + pluginDir, + fields.logoSmall, + options.store, + publicEndpoint, + ) + : undefined; + + if (logo.status === "uploaded") { + uploaded += 1; + } else if (logo.status === "cached") { + cached += 1; + } + + if (logoSmall?.status === "uploaded") { + uploaded += 1; + } else if (logoSmall?.status === "cached") { + cached += 1; + } + + writeLogoFields(json, logo.url, logoSmall?.url); + writeFileSync(manifestPath, `${JSON.stringify(json, null, 4)}\n`); + }), + ); + + return { uploaded, cached }; +} + +function bunStore(s3Url: string): LogoObjectStore { + const url = new URL(s3Url); + + if (!url.username || !url.password) { + throw new Error("S3_URL must include access key and secret"); + } + + const client = new S3Client({ + accessKeyId: decodeURIComponent(url.username), + secretAccessKey: decodeURIComponent(url.password), + bucket: BUCKET, + endpoint: url.origin, + region: "auto", + }); + + return { + exists: (key) => client.file(key).exists(), + put: async (key, body, contentType) => { + await client.file(key).write(body, { type: contentType }); + }, + }; +} + +/** Upload + stamp when S3_URL is set. CI must set it; local pack may skip. */ +export async function publishCatalogLogosFromEnv( + catalogDir: string, +): Promise { + const s3Url = process.env.S3_URL?.trim(); + const publicEndpoint = process.env.S3_PUBLIC_URL?.trim(); + + if (!s3Url || !publicEndpoint) { + if (process.env.GITHUB_ACTIONS === "true") { + throw new Error( + "S3_URL and S3_PUBLIC_URL are required in CI to publish catalog logos", + ); + } + + console.warn("catalog logos: skip publish (no S3_URL / S3_PUBLIC_URL)"); + + return; + } + + const { uploaded, cached } = await publishCatalogLogos(catalogDir, { + store: bunStore(s3Url), + publicEndpoint, + }); + + console.warn(`catalog logos uploaded×${uploaded} cached×${cached}`); +}