From d927f9fbdbca8e7e9fdd5b22d50fe99ee17dac04 Mon Sep 17 00:00:00 2001 From: Maksim Kachurin Date: Fri, 4 Sep 2026 11:20:18 -0300 Subject: [PATCH 1/2] feat: stamp catalog logos onto the CDN --- .github/workflows/pack.yml | 3 + README.md | 2 + scripts/pack/index.ts | 3 + scripts/pack/publish-logos.test.ts | 115 +++++++++++++++ scripts/pack/publish-logos.ts | 220 +++++++++++++++++++++++++++++ 5 files changed, 343 insertions(+) create mode 100644 scripts/pack/publish-logos.test.ts create mode 100644 scripts/pack/publish-logos.ts 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..0de9aac --- /dev/null +++ b/scripts/pack/publish-logos.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + catalogLogoKey, + publishCatalogLogos, + type LogoObjectStore, +} from "./publish-logos.ts"; + +const LOGO_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 key = catalogLogoKey(Buffer.from(LOGO_SVG), "svg"); + const expected = `https://cdn.example/${key}`; + const digest = createHash("sha256").update(LOGO_SVG).digest("hex"); + + expect(key).toBe(`plugins/${digest}.svg`); + expect(first).toEqual({ uploaded: 1, cached: 0 }); + expect( + JSON.parse( + readFileSync(join(dir, "demo-hooks/.reforma-plugin/plugin.json"), "utf8"), + ).logo, + ).toBe(expected); + + 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(expected); + }); + + 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..fa93438 --- /dev/null +++ b/scripts/pack/publish-logos.ts @@ -0,0 +1,220 @@ +/** + * 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 { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + extOf, + readLogoFields, + writeLogoFields, +} from "./logos.ts"; +import { + findManifestPath, + isRecord, + parseMarketplace, +} from "./shared.ts"; + +export const CATALOG_LOGO_BUCKET = "reforma-public"; + +export type LogoObjectStore = { + exists: (key: string) => Promise; + put: (key: string, body: Buffer, contentType: string) => Promise; +}; + +export function catalogLogoKey(buffer: Buffer, ext: string): string { + const keyExt = ext === "jpeg" ? "jpg" : ext; + + return `plugins/${createHash("sha256").update(buffer).digest("hex")}.${keyExt}`; +} + +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; +} + +async function publishLogoFile( + pluginDir: string, + rel: string, + store: LogoObjectStore, + publicEndpoint: string, +): Promise { + if (/^https:\/\//i.test(rel)) { + return rel; + } + + const abs = join(pluginDir, rel.replace(/^\.\//, "")); + const buffer = readFileSync(abs); + const ext = extOf(rel); + const mime = logoMime(ext); + + if (!mime) { + throw new Error(`unsupported catalog logo ${rel}`); + } + + const key = catalogLogoKey(buffer, ext); + + if (!(await store.exists(key))) { + await store.put(key, buffer, mime); + } + + return `${publicEndpoint}/${key}`; +} + +export async function publishCatalogLogos( + catalogDir: string, + options: { store: LogoObjectStore; publicEndpoint: string }, +): Promise<{ uploaded: number; cached: number }> { + const publicEndpoint = options.publicEndpoint.replace(/\/$/, ""); + const marketplacePath = join(catalogDir, "marketplace.json"); + const marketplace = parseMarketplace( + JSON.parse(readFileSync(marketplacePath, "utf8")) as unknown, + ); + let uploaded = 0; + let cached = 0; + const store: LogoObjectStore = { + exists: async (key) => { + const hit = await options.store.exists(key); + + if (hit) { + cached += 1; + } else { + uploaded += 1; + } + + return hit; + }, + put: options.store.put, + }; + + 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, + store, + publicEndpoint, + ); + const logoSmall = + fields.logoSmall && fields.logoSmall !== fields.logo + ? await publishLogoFile( + pluginDir, + fields.logoSmall, + store, + publicEndpoint, + ) + : undefined; + + writeLogoFields(json, logo, logoSmall); + writeFileSync(manifestPath, `${JSON.stringify(json, null, 4)}\n`); + }), + ); + + return { uploaded, cached }; +} + +function parseS3Url(raw: string): { + endpoint: string; + accessKeyId: string; + secretAccessKey: string; +} { + const url = new URL(raw); + + if (!url.username || !url.password) { + throw new Error("S3_URL must include access key and secret"); + } + + return { + endpoint: url.origin, + accessKeyId: decodeURIComponent(url.username), + secretAccessKey: decodeURIComponent(url.password), + }; +} + +function bunStore(s3Url: string): LogoObjectStore { + const parsed = parseS3Url(s3Url); + const client = new S3Client({ + accessKeyId: parsed.accessKeyId, + secretAccessKey: parsed.secretAccessKey, + bucket: CATALOG_LOGO_BUCKET, + endpoint: parsed.endpoint, + 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(); + const inCi = process.env.GITHUB_ACTIONS === "true"; + + if (!s3Url || !publicEndpoint) { + if (inCi) { + throw new Error( + "S3_URL and S3_PUBLIC_URL are required in CI to publish catalog logos", + ); + } + + if (!existsSync(join(catalogDir, "marketplace.json"))) { + return false; + } + + console.warn("catalog logos: skip publish (no S3_URL / S3_PUBLIC_URL)"); + + return false; + } + + const { uploaded, cached } = await publishCatalogLogos(catalogDir, { + store: bunStore(s3Url), + publicEndpoint, + }); + + console.warn(`catalog logos uploaded×${uploaded} cached×${cached}`); + + return true; +} From 2fe94a136299e058ef08d5686ed46dbc8e99375e Mon Sep 17 00:00:00 2001 From: Maksim Kachurin Date: Fri, 4 Sep 2026 11:24:47 -0300 Subject: [PATCH 2/2] refactor: shrink catalog logo publish --- scripts/pack/publish-logos.test.ts | 18 ++--- scripts/pack/publish-logos.ts | 107 ++++++++++++----------------- 2 files changed, 50 insertions(+), 75 deletions(-) diff --git a/scripts/pack/publish-logos.test.ts b/scripts/pack/publish-logos.test.ts index 0de9aac..bb7c5c7 100644 --- a/scripts/pack/publish-logos.test.ts +++ b/scripts/pack/publish-logos.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "bun:test"; -import { createHash } from "node:crypto"; import { mkdirSync, mkdtempSync, @@ -9,12 +8,12 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { - catalogLogoKey, 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( @@ -63,17 +62,12 @@ describe("publishCatalogLogos", () => { store, publicEndpoint: "https://cdn.example", }); - const key = catalogLogoKey(Buffer.from(LOGO_SVG), "svg"); - const expected = `https://cdn.example/${key}`; - const digest = createHash("sha256").update(LOGO_SVG).digest("hex"); + const stamped = JSON.parse( + readFileSync(join(dir, "demo-hooks/.reforma-plugin/plugin.json"), "utf8"), + ).logo as string; - expect(key).toBe(`plugins/${digest}.svg`); expect(first).toEqual({ uploaded: 1, cached: 0 }); - expect( - JSON.parse( - readFileSync(join(dir, "demo-hooks/.reforma-plugin/plugin.json"), "utf8"), - ).logo, - ).toBe(expected); + expect(stamped).toMatch(CDN); writeFileSync( join(dir, "demo-hooks/.reforma-plugin/plugin.json"), @@ -90,7 +84,7 @@ describe("publishCatalogLogos", () => { JSON.parse( readFileSync(join(dir, "demo-hooks/.reforma-plugin/plugin.json"), "utf8"), ).logo, - ).toBe(expected); + ).toBe(stamped); }); it("leaves an already-public https logo untouched", async () => { diff --git a/scripts/pack/publish-logos.ts b/scripts/pack/publish-logos.ts index fa93438..3a4a079 100644 --- a/scripts/pack/publish-logos.ts +++ b/scripts/pack/publish-logos.ts @@ -4,7 +4,7 @@ */ import { S3Client } from "bun"; import { createHash } from "node:crypto"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { extOf, @@ -15,21 +15,16 @@ import { findManifestPath, isRecord, parseMarketplace, + stripPath, } from "./shared.ts"; -export const CATALOG_LOGO_BUCKET = "reforma-public"; +const BUCKET = "reforma-public"; export type LogoObjectStore = { exists: (key: string) => Promise; put: (key: string, body: Buffer, contentType: string) => Promise; }; -export function catalogLogoKey(buffer: Buffer, ext: string): string { - const keyExt = ext === "jpeg" ? "jpg" : ext; - - return `plugins/${createHash("sha256").update(buffer).digest("hex")}.${keyExt}`; -} - function logoMime(ext: string): string | undefined { if (ext === "svg") { return "image/svg+xml"; @@ -50,32 +45,41 @@ function logoMime(ext: string): string | undefined { 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 { +): Promise<{ url: string; status: "public" | "uploaded" | "cached" }> { if (/^https:\/\//i.test(rel)) { - return rel; + return { url: rel, status: "public" }; } - const abs = join(pluginDir, rel.replace(/^\.\//, "")); - const buffer = readFileSync(abs); - const ext = extOf(rel); + 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))) { - await store.put(key, buffer, mime); + if (await store.exists(key)) { + return { url, status: "cached" }; } - return `${publicEndpoint}/${key}`; + await store.put(key, buffer, mime); + + return { url, status: "uploaded" }; } export async function publishCatalogLogos( @@ -83,26 +87,11 @@ export async function publishCatalogLogos( options: { store: LogoObjectStore; publicEndpoint: string }, ): Promise<{ uploaded: number; cached: number }> { const publicEndpoint = options.publicEndpoint.replace(/\/$/, ""); - const marketplacePath = join(catalogDir, "marketplace.json"); const marketplace = parseMarketplace( - JSON.parse(readFileSync(marketplacePath, "utf8")) as unknown, + JSON.parse(readFileSync(join(catalogDir, "marketplace.json"), "utf8")) as unknown, ); let uploaded = 0; let cached = 0; - const store: LogoObjectStore = { - exists: async (key) => { - const hit = await options.store.exists(key); - - if (hit) { - cached += 1; - } else { - uploaded += 1; - } - - return hit; - }, - put: options.store.put, - }; await Promise.all( marketplace.plugins.map(async (plugin) => { @@ -128,7 +117,7 @@ export async function publishCatalogLogos( const logo = await publishLogoFile( pluginDir, fields.logo, - store, + options.store, publicEndpoint, ); const logoSmall = @@ -136,12 +125,24 @@ export async function publishCatalogLogos( ? await publishLogoFile( pluginDir, fields.logoSmall, - store, + options.store, publicEndpoint, ) : undefined; - writeLogoFields(json, logo, logoSmall); + 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`); }), ); @@ -149,31 +150,18 @@ export async function publishCatalogLogos( return { uploaded, cached }; } -function parseS3Url(raw: string): { - endpoint: string; - accessKeyId: string; - secretAccessKey: string; -} { - const url = new URL(raw); +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"); } - return { - endpoint: url.origin, + const client = new S3Client({ accessKeyId: decodeURIComponent(url.username), secretAccessKey: decodeURIComponent(url.password), - }; -} - -function bunStore(s3Url: string): LogoObjectStore { - const parsed = parseS3Url(s3Url); - const client = new S3Client({ - accessKeyId: parsed.accessKeyId, - secretAccessKey: parsed.secretAccessKey, - bucket: CATALOG_LOGO_BUCKET, - endpoint: parsed.endpoint, + bucket: BUCKET, + endpoint: url.origin, region: "auto", }); @@ -188,25 +176,20 @@ function bunStore(s3Url: string): LogoObjectStore { /** Upload + stamp when S3_URL is set. CI must set it; local pack may skip. */ export async function publishCatalogLogosFromEnv( catalogDir: string, -): Promise { +): Promise { const s3Url = process.env.S3_URL?.trim(); const publicEndpoint = process.env.S3_PUBLIC_URL?.trim(); - const inCi = process.env.GITHUB_ACTIONS === "true"; if (!s3Url || !publicEndpoint) { - if (inCi) { + if (process.env.GITHUB_ACTIONS === "true") { throw new Error( "S3_URL and S3_PUBLIC_URL are required in CI to publish catalog logos", ); } - if (!existsSync(join(catalogDir, "marketplace.json"))) { - return false; - } - console.warn("catalog logos: skip publish (no S3_URL / S3_PUBLIC_URL)"); - return false; + return; } const { uploaded, cached } = await publishCatalogLogos(catalogDir, { @@ -215,6 +198,4 @@ export async function publishCatalogLogosFromEnv( }); console.warn(`catalog logos uploaded×${uploaded} cached×${cached}`); - - return true; }