Skip to content

Packaging & Marketplace

Panels install plugins from a marketplace index. The official index is catalyst-plugins and is browsed by default. You can run your own index without being listed there.

  • A plugin directory with a valid plugin.json — see Plugin Manifest.
  • A Catalyst checkout to build the SDK CLI from; the CLI is not published to npm. See Create a plugin.
  • HTTPS hosting for the package. Panels refuse plaintext http downloads from any host that is not a configured index host.

A .catpkg.zip is a zip whose entries sit at the archive root (one wrapper folder is flattened). The installer allows only:

Entry Required
plugin.json yes
backend/ no
frontend/ no
assets/ no
README.md no
LICENSE no

Anything else is rejected, including package.json, node_modules, src/, and dotfiles. ../, absolute paths, and links that escape the plugin directory are rejected before anything is written.

Limits: 256 MB compressed, 512 MB extracted, 20,000 entries.

A packaged backend.entry has to live under backend/, because that is the only allowed top-level place for backend files — the installer drops the rest. dist/ is not an allowed top-level entry, so a scaffold’s dist/... entry cannot ship in a package. Official plugins author backend/index.js directly and set entry to that path. If you did compile TypeScript, copy the output to backend/index.js and point the manifest there before packing. (The loader itself only requires backend.entry to resolve inside the plugin directory; the top-level allowlist is what constrains the package.)

A package whose plugin.json sets licensing.encrypted: true must ship at least one backend/*.enc payload. The installer refuses the package with PAYLOAD_MISSING otherwise, so a mispackaged plugin fails at install time instead of in front of an admin at enable time. plugin.json itself stays plaintext — the panel validates it before anything runs. See Plugin licensing.

The CLI ships in the Catalyst repository, not on npm, and nothing links its binary, so invoke it by path. Build it once, then pack from the plugin directory — see Create a plugin for why there is no catalyst-plugin command on PATH:

Terminal window
# once, from the Catalyst checkout root
pnpm --filter @catalyst/plugin-sdk run build
pnpm --dir packages/plugin-sdk/cli run build
# from the plugin directory ($CATALYST_REPO is your checkout)
node "$CATALYST_REPO/packages/plugin-sdk/cli/dist/index.js" pack
node "$CATALYST_REPO/packages/plugin-sdk/cli/dist/index.js" pack --out dist

pack needs archiver resolvable — a devDependency of packages/plugin-sdk that pnpm install hoists to the workspace root; without an install it prints archiver is unavailable.

pack writes <name>-<version>.catpkg.zip and <name>-<version>.catpkg.zip.sha256. It includes plugin.json, README.md, LICENSE, and the backend/, frontend/, and assets/ directories when they exist. It does not compile TypeScript and it does not build frontend.mjs. Do both before packing. A package with neither backend/ nor frontend/ installs and does nothing.

The official repository does the same thing with zip and then rewrites index.json:

Terminal window
# from a plugin directory — matches scripts/build-packages.sh
zip -qr ../my-plugin-1.0.0.catpkg.zip plugin.json backend frontend README.md \
-x '*/node_modules/*' -x '*/.*' -x 'frontend/dist/*'
sha256sum ../my-plugin-1.0.0.catpkg.zip

Serve JSON over HTTPS. The panel accepts either a bare array or:

{
"schemaVersion": 1,
"plugins": [
{
"name": "my-plugin",
"displayName": "My Plugin",
"description": "What an admin reads in the marketplace list.",
"author": "Your Name",
"version": "1.0.0",
"downloadUrl": "https://cdn.example.com/my-plugin-1.0.0.catpkg.zip",
"sha256": "hex digest of the zip",
"homepage": "https://github.com/you/my-plugin",
"tags": ["notifications"]
}
]
}
Field Required Notes
name yes Must equal the packaged plugin’s plugin.json name.
downloadUrl yes Absolute URL of the .catpkg.zip.
sha256 yes in practice Lowercase hex digest of the zip. The panel lists an entry without it but refuses to install it (CHECKSUM_REQUIRED).
version no Shown in the version column; drives the update badge.
displayName, description, author, homepage no Display only.
tags no Filter chips in the marketplace; the first 12 strings are kept.
licensing no { licenseServer, buyUrl, encrypted } for the browse badge. The installed plugin.json is authoritative.

sha256 is a hard requirement for installation: the panel verifies the downloaded bytes against it before extracting anything, and a missing or mismatched digest aborts the install.

downloadUrl must be https. Two exceptions are treated as trusted: a host that is also one of your configured index hosts, and any host when the panel sets PLUGIN_MARKETPLACE_ALLOW_LOCAL=true. Hosts that resolve to loopback, link-local, or private addresses are refused in the same cases.

Redirects are followed up to 5 hops, and the protocol and private-address checks are re-applied to every hop, so a public URL cannot redirect the download at an internal address. Index URLs themselves are fetched as-is, including LAN hosts, because an admin configured them.

The panel caches each index for 5 minutes; browsing with a forced refresh sends Cache-Control: no-cache. One failing source does not hide the others.

An admin adds the URL in the Marketplaces rail of Admin → Extensions → Marketplace, or sets environment variables on the backend:

Variable Effect
PLUGIN_MARKETPLACE_URLS Comma-separated extra index URLs, browsed together with the official index.
PLUGIN_MARKETPLACE_DISABLE_OFFICIAL true skips the official index. Use on air-gapped installs.
PLUGIN_MARKETPLACE_ALLOW_LOCAL true permits plaintext http downloads and loopback/private hosts other than your configured index hosts. Leave unset on a public panel.

When two sources list the same name, the newest version wins.

All three are clicks in Admin → Extensions — the operator steps live in Plugins. What matters when you publish:

  • The panel downloads the zip, verifies sha256, extracts, and validates plugin.json. The code lands on disk not running.
  • An admin then enables it and accepts the safety disclaimer.
  • A higher version in the index offers Update, which replaces the directory. A version or permission change forces the disclaimer again and leaves the plugin disabled until it is accepted.

POST /api/plugins/install with { "url", "sha256" } is the same flow; sha256 is a required 64-hex digest. POST /api/plugins/:name/reload re-reads the directory without a panel restart.

Third-party indexes do not need anyone’s approval. To land in the official index, open a pull request on catalystctl/catalyst-plugins that adds one directory:

  • Directory name equals plugin.json name.
  • backend.entry and frontend.entry point at files that exist.
  • An index.json entry with the same name and version. CI rejects a mismatch either way.
  • UI plugins include a vite.config.mts that emits frontend/frontend.mjs. CI runs scripts/build-bundles.mjs before packing.

CI packs every directory that contains plugin.json, writes the sha256 back into index.json, and publishes the zips on the dist branch. Download URLs look like:

https://raw.githubusercontent.com/catalystctl/catalyst-plugins/dist/plugins/<name>-<version>.catpkg.zip

After enable:

Terminal window
curl -H "Authorization: Bearer $CATALYST_API_KEY" \
https://panel.example.com/api/plugins/my-plugin

The response includes the manifest, whether consent is current, and the granted permissions. Then call one of your own routes and confirm a disabled plugin returns 503 rather than running the handler.