docs/internal/plugin-marketplace-design.md
This document proposes a decentralized, git-based package system for Fresh plugins and themes. The design prioritizes simplicity, user control, and minimal editor complexity by leveraging git as the underlying distribution mechanism—similar to how Emacs package managers (straight.el, elpaca) and Neovim (lazy.nvim, packer) approach the problem.
The core innovation: the package manager itself is a plugin, keeping the editor lean while providing full package management capabilities.
git pull operations┌─────────────────────────────────────────────────────────────────────┐
│ Fresh Editor │
│ ┌─────────────────┐ │
│ │ Plugin Loader │ ← Loads .ts files from ~/.config/fresh/plugins │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Package Manager Plugin (pkg.ts) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │ │
│ │ │ Install │ │ Update │ │ Remove │ │ List/Search │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬──────┘ │ │
│ │ │ │ │ │ │ │
│ │ └─────────────┴─────────────┴───────────────┘ │ │
│ │ │ │ │
│ │ editor.spawnProcess() │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ Git Commands │ │ │
│ │ │ clone/pull/tag │ │ │
│ │ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Package Sources (Git Repos) │
│ │
│ ┌─────────────────────────┐ ┌──────────────────────────────────┐ │
│ │ Official Registry Repo │ │ User's Private Repo │ │
│ │ (fresh-plugins/index) │ │ github.com/user/my-plugin │ │
│ │ ┌─────────────────┐ │ └──────────────────────────────────┘ │
│ │ │ plugins.json │ │ │
│ │ │ themes.json │ │ ┌──────────────────────────────────┐ │
│ │ └─────────────────┘ │ │ Community Index Repo │ │
│ └─────────────────────────┘ │ (awesome-fresh-plugins/index) │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
~/.config/fresh/
├── config.json # Editor config (includes package list)
├── plugins/
│ ├── welcome.ts # User's direct plugin files
│ └── packages/ # Git-managed packages
│ ├── vim-mode/ # git clone of vim-mode plugin
│ │ ├── .git/
│ │ ├── package.json # Package manifest
│ │ ├── main.ts # Entry point
│ │ └── lib/
│ ├── rainbow-brackets/
│ │ ├── .git/
│ │ ├── package.json
│ │ └── main.ts
│ └── .index/ # Cached registry data (git repo)
│ ├── .git/
│ ├── plugins.json
│ └── themes.json
└── themes/
├── my-custom.json # User's direct theme files
└── packages/ # Git-managed themes
├── catppuccin/
│ ├── .git/
│ ├── package.json
│ ├── mocha.json
│ ├── latte.json
│ └── frappe.json
└── tokyo-night/
├── .git/
└── tokyo-night.json
Every package has a package.json (or fresh.json) at its root:
{
"name": "rainbow-brackets",
"version": "1.2.0",
"description": "Colorize matching brackets for easier visual parsing",
"type": "plugin",
"author": "Jane Developer <[email protected]>",
"license": "MIT",
"repository": "https://github.com/jane/fresh-rainbow-brackets",
"fresh": {
"min_version": "0.1.80",
"entry": "main.ts",
"config_schema": {
"colors": {
"type": "array",
"default": ["#ff0000", "#00ff00", "#0000ff"],
"description": "Colors to cycle through for bracket pairs"
}
}
},
"keywords": ["brackets", "colors", "syntax"],
"dependencies": {}
}
{
"name": "catppuccin",
"version": "2.0.0",
"description": "Soothing pastel theme collection",
"type": "theme-pack",
"author": "Catppuccin Team",
"license": "MIT",
"repository": "https://github.com/catppuccin/fresh",
"fresh": {
"min_version": "0.1.75",
"themes": [
{ "file": "mocha.json", "name": "Catppuccin Mocha", "variant": "dark" },
{ "file": "latte.json", "name": "Catppuccin Latte", "variant": "light" },
{ "file": "frappe.json", "name": "Catppuccin Frappé", "variant": "dark" },
{ "file": "macchiato.json", "name": "Catppuccin Macchiato", "variant": "dark" }
]
},
"keywords": ["pastel", "dark", "light", "colorful"]
}
The registry is a git repository containing JSON indices:
plugins.json{
"schema_version": 1,
"updated": "2025-01-15T10:30:00Z",
"packages": {
"rainbow-brackets": {
"description": "Colorize matching brackets",
"repository": "https://github.com/jane/fresh-rainbow-brackets",
"author": "Jane Developer",
"license": "MIT",
"keywords": ["brackets", "colors"],
"stars": 142,
"downloads": 5230,
"latest_version": "1.2.0",
"fresh_min_version": "0.1.80"
},
"vim-mode": {
"description": "Vim keybindings for Fresh",
"repository": "https://github.com/bob/fresh-vim-mode",
"author": "Bob Vimmer",
"license": "MIT",
"keywords": ["vim", "modal", "keybindings"],
"stars": 890,
"downloads": 12500,
"latest_version": "3.1.0",
"fresh_min_version": "0.1.85"
}
}
}
themes.json{
"schema_version": 1,
"updated": "2025-01-15T10:30:00Z",
"packages": {
"catppuccin": {
"description": "Soothing pastel theme collection",
"repository": "https://github.com/catppuccin/fresh",
"author": "Catppuccin Team",
"license": "MIT",
"variants": ["mocha", "latte", "frappe", "macchiato"],
"keywords": ["pastel", "dark", "light"],
"stars": 2100,
"downloads": 45000
},
"tokyo-night": {
"description": "Clean dark theme with Tokyo city lights colors",
"repository": "https://github.com/tokyo-night/fresh",
"author": "Tokyo Night Team",
"license": "MIT",
"variants": ["night", "storm", "day"],
"keywords": ["dark", "blue", "purple"],
"stars": 1800
}
}
}
config.json Package Section{
"theme": "catppuccin-mocha",
"packages": {
"sources": [
"https://github.com/sinelaw/fresh-plugins-registry",
"https://github.com/awesome-fresh/community-index"
],
"plugins": {
"rainbow-brackets": {
"enabled": true,
"source": "https://github.com/jane/fresh-rainbow-brackets",
"version": "^1.2.0",
"config": {
"colors": ["#e06c75", "#98c379", "#61afef", "#c678dd"]
}
},
"vim-mode": {
"enabled": true,
"version": "3.1.0",
"config": {
"leader": " "
}
},
"my-experimental": {
"enabled": true,
"source": "~/code/my-fresh-plugin",
"version": "local"
}
},
"themes": {
"catppuccin": {
"source": "https://github.com/catppuccin/fresh",
"version": "latest"
}
}
},
"plugins": {
"welcome": { "enabled": true },
"git_grep": { "enabled": true }
}
}
Packages support multiple version specification formats:
| Format | Meaning | Example |
|---|---|---|
"1.2.0" | Exact version (git tag v1.2.0) | Pin to specific release |
"^1.2.0" | Compatible (>= 1.2.0, < 2.0.0) | Semver compatible |
"~1.2.0" | Patch updates only (>= 1.2.0, < 1.3.0) | Conservative updates |
"latest" | Latest tag or HEAD | Always newest |
"main" | Specific branch | Track development |
"abc1234" | Specific commit | Exact reproducibility |
"local" | Local directory, no git | Development mode |
A single git repository can contain multiple packages. Use URL fragments to specify a subdirectory:
https://github.com/user/fresh-plugins#packages/rainbow-brackets
https://github.com/user/fresh-plugins#packages/vim-mode
https://github.com/user/fresh-plugins#themes/catppuccin
<repo-url>#<path/to/package>
For monorepo packages:
.fresh-source.json file to track the original sourceRegistries can list monorepo packages:
{
"rainbow-brackets": {
"description": "Colorize matching brackets",
"repository": "https://github.com/user/fresh-plugins#packages/rainbow-brackets",
"author": "User"
}
}
The package manager plugin registers these commands:
| Command | Description |
|---|---|
pkg: Install Plugin | Browse registry and install a plugin |
pkg: Install Theme | Browse registry and install a theme |
pkg: Install from URL | Install directly from git URL |
pkg: Update All | Update all installed packages |
pkg: Update Plugin | Select and update a specific plugin |
pkg: Remove Plugin | Remove an installed plugin |
pkg: Remove Theme | Remove an installed theme |
pkg: List Installed | Show all installed packages |
pkg: Search | Search registry for packages |
pkg: Sync Registry | Pull latest registry data |
pkg: Show Outdated | List packages with updates available |
pkg: Lock Versions | Generate lockfile for reproducibility |
plugins/pkg.ts)/// <reference path="../types/fresh.d.ts" />
const PACKAGES_DIR = editor.getConfigDir() + "/plugins/packages";
const THEMES_PACKAGES_DIR = editor.getConfigDir() + "/themes/packages";
const INDEX_DIR = PACKAGES_DIR + "/.index";
interface PackageInfo {
name: string;
description: string;
repository: string;
version: string;
installed_version?: string;
type: "plugin" | "theme" | "theme-pack";
}
// ─────────────────────────────────────────────────────────────────
// Installation
// ─────────────────────────────────────────────────────────────────
globalThis.pkg_install = async function(): Promise<void> {
// 1. Load registry
const plugins = await loadRegistry("plugins");
// 2. Show picker
const items = Object.entries(plugins.packages).map(([name, info]) => ({
label: name,
description: info.description,
detail: `★ ${info.stars} | v${info.latest_version}`,
data: { name, ...info }
}));
editor.startPrompt("Install plugin:", "pkg-install");
editor.setPromptSuggestions(items);
};
globalThis.pkg_install_confirm = async function(): Promise<void> {
const selection = editor.getPromptSelection();
if (!selection) return;
const { name, repository } = selection.data;
editor.setStatus(`Installing ${name}...`);
const targetDir = `${PACKAGES_DIR}/${name}`;
const result = await editor.spawnProcess("git", [
"clone", "--depth", "1", repository, targetDir
]);
if (result.exit_code === 0) {
// Add to config
await addPackageToConfig(name, repository);
editor.setStatus(`Installed ${name} successfully. Restart to activate.`);
} else {
editor.setStatus(`Failed to install ${name}: ${result.stderr}`);
}
};
// ─────────────────────────────────────────────────────────────────
// Updates
// ─────────────────────────────────────────────────────────────────
globalThis.pkg_update_all = async function(): Promise<void> {
const packages = await getInstalledPackages();
let updated = 0;
let failed = 0;
for (const pkg of packages) {
editor.setStatus(`Updating ${pkg.name}...`);
const result = await editor.spawnProcess("git", [
"-C", pkg.path, "pull", "--ff-only"
]);
if (result.exit_code === 0) {
if (!result.stdout.includes("Already up to date")) {
updated++;
}
} else {
failed++;
}
}
editor.setStatus(`Update complete: ${updated} updated, ${failed} failed`);
};
// ─────────────────────────────────────────────────────────────────
// Version Management
// ─────────────────────────────────────────────────────────────────
async function checkoutVersion(pkgPath: string, version: string): Promise<boolean> {
let target: string;
if (version === "latest") {
// Get latest tag
const tags = await editor.spawnProcess("git", [
"-C", pkgPath, "tag", "--sort=-v:refname"
]);
target = tags.stdout.split("\n")[0] || "HEAD";
} else if (version.startsWith("^") || version.startsWith("~")) {
// Semver matching - find best matching tag
target = await findMatchingVersion(pkgPath, version);
} else {
target = version.startsWith("v") ? version : `v${version}`;
}
const result = await editor.spawnProcess("git", [
"-C", pkgPath, "checkout", target
]);
return result.exit_code === 0;
}
// ─────────────────────────────────────────────────────────────────
// Registry Management
// ─────────────────────────────────────────────────────────────────
async function syncRegistry(): Promise<void> {
const sources = await getRegistrySources();
for (const source of sources) {
const indexPath = `${INDEX_DIR}/${hashSource(source)}`;
if (editor.fileExists(indexPath)) {
await editor.spawnProcess("git", ["-C", indexPath, "pull"]);
} else {
await editor.spawnProcess("git", [
"clone", "--depth", "1", source, indexPath
]);
}
}
}
async function loadRegistry(type: "plugins" | "themes"): Promise<RegistryData> {
const sources = await getRegistrySources();
const merged: RegistryData = { packages: {} };
for (const source of sources) {
const indexPath = `${INDEX_DIR}/${hashSource(source)}/${type}.json`;
if (editor.fileExists(indexPath)) {
const content = await editor.readFile(indexPath);
const data = JSON.parse(content);
Object.assign(merged.packages, data.packages);
}
}
return merged;
}
// ─────────────────────────────────────────────────────────────────
// Install from URL (unlisted packages)
// ─────────────────────────────────────────────────────────────────
globalThis.pkg_install_url = async function(): Promise<void> {
editor.startPrompt("Git URL:", "pkg-install-url");
};
globalThis.pkg_install_url_confirm = async function(): Promise<void> {
const url = editor.getPromptText();
if (!url) return;
// Extract name from URL
const name = url.split("/").pop()?.replace(/\.git$/, "") || "unknown";
editor.setStatus(`Installing from ${url}...`);
const targetDir = `${PACKAGES_DIR}/${name}`;
const result = await editor.spawnProcess("git", [
"clone", "--depth", "1", url, targetDir
]);
if (result.exit_code === 0) {
await addPackageToConfig(name, url);
editor.setStatus(`Installed ${name}. Restart to activate.`);
} else {
editor.setStatus(`Failed: ${result.stderr}`);
}
};
// Register commands
editor.registerCommand("pkg_install", "pkg: Install Plugin", "pkg_install");
editor.registerCommand("pkg_install_url", "pkg: Install from URL", "pkg_install_url");
editor.registerCommand("pkg_update_all", "pkg: Update All", "pkg_update_all");
// ... more commands
Approach: Host packages on a dedicated server with REST API.
Pros:
Cons:
Verdict: Rejected. Git-based approach provides same functionality without centralized dependency.
Approach: Use tar.gz archives with checksums.
Pros:
Cons:
Verdict: Rejected. Git provides better update mechanism and easier contribution workflow.
Approach: Implement package management in Rust within the editor core.
Pros:
Cons:
Verdict: Rejected. Plugin-based approach keeps editor simple and allows customization.
Approach: Use Lua for package configuration like lazy.nvim.
Pros:
Cons:
Verdict: Rejected. TypeScript plugins can provide same power; JSON config is sufficient.
github.com/sinelaw/fresh-plugins-registry/
├── README.md
├── CONTRIBUTING.md
├── plugins.json
├── themes.json
└── schemas/
├── plugin.schema.json
└── theme.schema.json
Contribution Flow:
pkg: Sync Registry to get updatesValidation: CI/CD validates JSON schema and checks that repos exist.
Use GitHub's REST API to discover packages tagged with fresh-plugin or fresh-theme topics.
Pros: Zero maintenance, automatic discovery Cons: Rate limits, GitHub-only, no curation
Store index on IPFS with ENS/DNS pointer.
Pros: Truly decentralized, censorship-resistant Cons: Complex, slow, overkill for this use case
Users share URLs directly. Discovery via:
fresh-pluginPros: Maximum decentralization Cons: Poor discoverability for new users
fresh.lock{
"lockfile_version": 1,
"generated": "2025-01-15T10:30:00Z",
"packages": {
"rainbow-brackets": {
"source": "https://github.com/jane/fresh-rainbow-brackets",
"commit": "abc123def456789",
"version": "1.2.0",
"integrity": "sha256-xxxxx"
},
"vim-mode": {
"source": "https://github.com/bob/fresh-vim-mode",
"commit": "def789abc123456",
"version": "3.1.0",
"integrity": "sha256-yyyyy"
}
}
}
Commands:
pkg: Lock Versions - Generate lockfile from current statepkg: Install from Lockfile - Reproduce exact package versionsThe package manager shows a confirmation dialog:
┌─────────────────────────────────────────────────────────────┐
│ Install rainbow-brackets? │
│ │
│ Source: github.com/jane/fresh-rainbow-brackets │
│ Author: Jane Developer │
│ License: MIT │
│ Stars: 142 | Downloads: 5230 │
│ │
│ ⚠ Plugins can execute arbitrary code. Only install from │
│ sources you trust. │
│ │
│ [Enter] Install [v] View Source [Esc] Cancel │
└─────────────────────────────────────────────────────────────┘
Plugins run in QuickJS sandbox with limited capabilities:
editor.readFile())editor.spawnProcess())Registry maintainers can sign plugins.json:
{
"packages": { ... },
"signatures": [
{
"keyid": "maintainer-1",
"sig": "base64-signature"
}
]
}
Registry includes a blocklist.json that the package manager checks:
{
"blocked": [
{
"repository": "https://github.com/bad/malware-plugin",
"reason": "Contained cryptocurrency miner",
"blocked_at": "2025-01-10"
}
]
}
Themes from packages are discovered by scanning:
~/.config/fresh/themes/*.json (direct files)~/.config/fresh/themes/packages/*/package.json (read theme list)// In theme loader (Rust side or plugin)
function loadPackageThemes(): Theme[] {
const packagesDir = `${THEMES_DIR}/packages`;
const themes: Theme[] = [];
for (const pkgDir of listDirs(packagesDir)) {
const manifest = JSON.parse(readFile(`${pkgDir}/package.json`));
if (manifest.fresh?.themes) {
for (const themeEntry of manifest.fresh.themes) {
const themeData = JSON.parse(readFile(`${pkgDir}/${themeEntry.file}`));
themes.push({
...themeData,
name: themeEntry.name || themeData.name,
source: manifest.repository
});
}
}
}
return themes;
}
Before installing, show live preview:
globalThis.pkg_preview_theme = async function(): Promise<void> {
const selection = editor.getPromptSelection();
if (!selection) return;
// Clone to temp directory
const tempDir = `/tmp/fresh-theme-preview`;
await editor.spawnProcess("git", [
"clone", "--depth", "1", selection.data.repository, tempDir
]);
// Load and apply first theme
const manifest = JSON.parse(await editor.readFile(`${tempDir}/package.json`));
const themeFile = manifest.fresh.themes[0].file;
const themeData = await editor.readFile(`${tempDir}/${themeFile}`);
// Apply temporarily (requires editor API extension)
editor.previewTheme(JSON.parse(themeData));
// Clean up on cancel
editor.onPromptClose(() => {
editor.revertTheme();
editor.spawnProcess("rm", ["-rf", tempDir]);
});
};
Package directory scanning: Update plugin loader to scan ~/.config/fresh/plugins/packages/*/main.ts
Theme package scanning: Update theme loader to read package manifests
No other changes: All package management logic lives in the plugin
editor.previewTheme(data): Apply theme temporarily without savingeditor.reloadPlugins(): Hot-reload plugins without restarteditor.getPackageConfig(name): Access package-specific config section| Feature | Fresh (Proposed) | Emacs (straight.el) | Neovim (lazy.nvim) | VS Code |
|---|---|---|---|---|
| Distribution | Git repos | Git repos | Git repos | VSIX packages |
| Registry | Optional git repo | MELPA git | None | Centralized |
| Version control | Git tags/commits | Git commits | Git tags/commits | Semver |
| Install location | ~/.config/fresh/plugins/packages | ~/.emacs.d/straight | ~/.local/share/nvim/lazy | ~/.vscode/extensions |
| Update mechanism | git pull | git pull | git pull | VS Code API |
| Config format | JSON | Elisp | Lua | JSON |
| Manager location | Plugin | Package | Plugin | Built-in |
Dependencies between plugins: Do we need inter-plugin dependencies? Most editors avoid this complexity.
Automatic updates: Should packages auto-update on editor start? Probably opt-in only.
Plugin enable/disable: Currently via config. Should we add quick toggle command?
Conflicting packages: What if two packages define the same command?
Rollback: Should we keep previous version for quick rollback on breakage?
Currently, plugins that need rich UI (like the package manager) must manually construct their interface using raw text property entries. This is verbose, error-prone, and leads to code duplication.
We need a UI component library that plugins can use to build interfaces in virtual buffers:
This would allow plugins to declaratively define UI:
// Hypothetical API
const ui = editor.createUI(bufferId);
ui.header("Package Manager");
ui.tabBar(["All", "Installed", "Plugins", "Themes"], activeTab, onTabChange);
ui.splitView({
left: ui.list(items, { onSelect, onActivate }),
right: ui.panel([
ui.title(selected.name),
ui.text(selected.description),
ui.button("Install", onInstall),
]),
});
Benefits:
Note: The editor's settings UI already implements many of these UI elements (dropdowns, toggles, input fields, sections, etc.) but they are not organized into a reusable library. A shared component framework could unify the settings UI implementation with plugin UI needs, reducing duplication and ensuring consistency.
This design provides a simple, decentralized, git-based package system that:
The key insight is that git is already a package manager—we just need a thin UI layer on top.