Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pack.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<sha256>.<ext>` 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-<sha>` 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.
Expand Down
3 changes: 3 additions & 0 deletions scripts/pack/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand DownExpand Up@@ -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",
Expand Down
109 changes: 109 additions & 0 deletions scripts/pack/publish-logos.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 = '<svg xmlns="http://www.w3.org/2000/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<string, Buffer>();

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);
});
});
201 changes: 201 additions & 0 deletions scripts/pack/publish-logos.ts
Original file line numberDiff line numberDiff line change
@@ -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<boolean>;
put: (key: string, body: Buffer, contentType: string) => Promise<void>;
};

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<void> {
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(

Check failure on line 185 in scripts/pack/publish-logos.ts

View workflow job for this annotation

GitHub Actions/ pack

error: S3_URL and S3_PUBLIC_URL are required in CI to publish catalog logos

at publishCatalogLogosFromEnv (/home/runner/work/plugins/plugins/scripts/pack/publish-logos.ts:185:17) at /home/runner/work/plugins/plugins/scripts/pack/index.ts:88:7
"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}`);
}
Loading