feat: Implement Plugin Marketplace Manager and Client

- Added PluginMarketplaceManager to coordinate marketplace reads and manage installed plugins.
- Introduced PluginMarketplaceClient for fetching marketplace metadata.
- Implemented storage management for installed plugins using MemoryStorage.
- Added methods for installing, uninstalling, and enabling/disabling plugins.
- Created type definitions for PluginMarketplaceManager options and installed plugin records.
- Added support for fetching full marketplace catalog and individual plugin metadata.
- Included error handling for fetch operations and validation of plugin data.
- Updated index.browser.ts to export new modules and types.
This commit is contained in:
2026-04-19 01:03:58 +10:00
parent da18b7dfe5
commit dcc17946ef
22 changed files with 1018 additions and 44 deletions

View File

@@ -14,7 +14,12 @@
const pmEntry = path.join(
__dirname, 'node_modules', '@paarrot', 'plugin-manager', 'dist', 'index.js'
);
const { PluginRegistry, createPluginContext } = await import(pathToFileURL(pmEntry).href);
const {
PluginMarketplaceClient,
PluginMarketplaceManager,
PluginRegistry,
createPluginContext,
} = await import(pathToFileURL(pmEntry).href);
// ---------------------------------------------------------------------------
// Module loader — supports both CJS (module.exports) and ESM (export default)
@@ -149,6 +154,11 @@
},
});
const marketplaceClient = new PluginMarketplaceClient({
indexUrl: 'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json',
baseUrl: 'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/',
});
// ---------------------------------------------------------------------------
// Load the example plugin from disk (CJS eval — same pattern as cinny)
// ---------------------------------------------------------------------------
@@ -180,7 +190,20 @@
* Persistent record of every plugin we have ever seen, keyed by plugin id.
* Survives unload so the Installed list can show a Reload button.
*
* @type {Map<string, { name: string, version: string, pluginModule: object, filePath?: string, downloadUrl?: string }>}
* @type {Map<string, {
* name: string,
* version: string,
* pluginModule: object,
* filePath?: string,
* downloadUrl?: string,
* description?: string,
* author?: string,
* repository?: string,
* thumbnail?: string,
* homepage?: string,
* tags?: string[],
* addedDate?: string
* }>}
*/
const knownPlugins = new Map();
@@ -189,7 +212,19 @@
* registers and loads the plugin, then records it in knownPlugins.
* @param {string} id
* @param {object} pluginModule
* @param {{ filePath?: string, downloadUrl?: string }} [source]
* @param {{
* filePath?: string,
* downloadUrl?: string,
* name?: string,
* version?: string,
* description?: string,
* author?: string,
* repository?: string,
* thumbnail?: string,
* homepage?: string,
* tags?: string[],
* addedDate?: string,
* }} [source]
*/
async function registerAndLoad(id, pluginModule, source = {}) {
const ctx = createPluginContext(
@@ -211,13 +246,60 @@
await pluginModule.onLoad(ctx);
knownPlugins.set(id, {
name: pluginModule.name ?? id,
version: pluginModule.version ?? '?',
name: source.name ?? pluginModule.name ?? id,
version: source.version ?? pluginModule.version ?? '?',
pluginModule,
...source,
});
}
const marketplace = new PluginMarketplaceManager({
client: marketplaceClient,
storage: localStorage,
registry,
host: {
listInstalledPlugins: async () => Array.from(knownPlugins.entries()).map(([id, known]) => ({
id,
name: known.name ?? id,
version: known.version ?? '',
description: known.description ?? '',
author: known.author ?? '',
repository: known.repository ?? '',
thumbnail: known.thumbnail ?? '',
downloadUrl: known.downloadUrl ?? '',
homepage: known.homepage ?? '',
tags: Array.isArray(known.tags) ? known.tags : [],
addedDate: known.addedDate ?? '',
})),
installPlugin: async (pluginMetadata) => {
if (!pluginMetadata.downloadUrl) {
throw new Error(`No download URL for plugin ${pluginMetadata.id}`);
}
addLog(`Downloading plugin ${pluginMetadata.id}`, 'info');
const code = await fetchPluginCode(pluginMetadata.downloadUrl, pluginMetadata.repository);
const raw = await evalPluginCode(code);
const pluginModule = normalisePlugin(raw);
await registerAndLoad(pluginMetadata.id, pluginModule, {
downloadUrl: pluginMetadata.downloadUrl,
name: pluginMetadata.name,
version: pluginMetadata.version,
description: pluginMetadata.description,
author: pluginMetadata.author,
repository: pluginMetadata.repository,
thumbnail: pluginMetadata.thumbnail,
homepage: pluginMetadata.homepage,
tags: pluginMetadata.tags,
addedDate: pluginMetadata.addedDate,
});
},
uninstallPlugin: async (pluginId) => {
knownPlugins.delete(pluginId);
},
},
});
await registerAndLoad('example-plugin', plugin, {
filePath: pluginPath,
});
@@ -367,11 +449,6 @@
// ---------------------------------------------------------------------------
// Plugin Directory — fetch from remote index
// ---------------------------------------------------------------------------
const PLUGIN_INDEX_URL =
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json';
const PLUGIN_BASE_URL =
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
let directoryPlugins = [];
const dirStatusEl = document.getElementById('dir-status');
const dirListEl = document.getElementById('dir-list');
@@ -415,11 +492,7 @@
${(p.tags ?? []).map(t => `<span class="market-tag">${t}</span>`).join('')}
${p.homepage ? `<a href="${p.homepage}" target="_blank"
style="font-size:10px;color:#7986cb;text-decoration:none;margin-left:auto;margin-right:6px;">↗ Homepage</a>` : ''}
<button class="market-install-btn"
data-url="${p.downloadUrl ?? ''}"
data-repo="${p.repository ?? ''}"
data-id="${p.id}"
${isLoaded ? 'disabled' : ''}>
<button class="market-install-btn" data-id="${p.id}" ${isLoaded ? 'disabled' : ''}>
${isLoaded ? 'Loaded' : 'Download'}
</button>
</div>
@@ -428,24 +501,21 @@
}).join('');
dirListEl.querySelectorAll('.market-install-btn:not([disabled])').forEach(btn => {
btn.addEventListener('click', () => downloadAndLoadPlugin(btn.dataset.id, btn.dataset.url, btn.dataset.repo));
btn.addEventListener('click', () => downloadAndLoadPlugin(btn.dataset.id));
});
}
/** Downloads a plugin's index.js from the marketplace and loads it into the registry. */
async function downloadAndLoadPlugin(pluginId, downloadUrl, repositoryUrl) {
if (!downloadUrl) {
addLog(`No download URL for plugin ${pluginId}`, 'error');
async function downloadAndLoadPlugin(pluginId) {
const pluginMetadata = directoryPlugins.find((entry) => entry.id === pluginId);
if (!pluginMetadata) {
addLog(`Plugin ${pluginId} is not present in directory cache`, 'error');
return;
}
try {
addLog(`Downloading plugin ${pluginId}`, 'info');
const code = await fetchPluginCode(downloadUrl, repositoryUrl);
const raw = await evalPluginCode(code);
const p = normalisePlugin(raw);
await registerAndLoad(pluginId, p, { downloadUrl });
await marketplace.installPlugin(pluginMetadata);
addLog(`Plugin installed from directory: ${pluginId}`, 'success');
refreshPluginList();
refreshCommandList();
@@ -460,23 +530,9 @@
async function fetchDirectory() {
dirStatusEl.textContent = 'Fetching plugin directory…';
dirListEl.innerHTML = '';
try {
const indexRes = await fetch(PLUGIN_INDEX_URL);
if (!indexRes.ok) throw new Error(`HTTP ${indexRes.status}`);
const index = await indexRes.json();
const metas = await Promise.all(
index.plugins.map(async (id) => {
try {
const r = await fetch(`${PLUGIN_BASE_URL}${id}.json`);
return r.ok ? r.json() : null;
} catch {
return null;
}
})
);
directoryPlugins = metas.filter(Boolean);
directoryPlugins = await marketplace.fetchMarketplacePlugins();
dirStatusEl.textContent = `${directoryPlugins.length} plugin${directoryPlugins.length !== 1 ? 's' : ''} in directory.`;
addLog(`Plugin directory fetched: ${directoryPlugins.length} entries`, 'success');
renderDirectoryList(dirSearchEl.value);