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

53
dist/PluginMarketplaceClient.d.ts vendored Normal file
View File

@@ -0,0 +1,53 @@
import type { PluginIndex, PluginMetadata } from './types.js';
/**
* Minimal response shape required by marketplace client.
*/
export interface PluginMarketplaceResponse {
ok: boolean;
status: number;
json(): Promise<unknown>;
}
/**
* Fetch function used by marketplace client.
*/
export type PluginMarketplaceFetch = (input: string, init?: Record<string, unknown>) => Promise<PluginMarketplaceResponse>;
/**
* Construction options for {@link PluginMarketplaceClient}.
*/
export interface PluginMarketplaceClientOptions {
/** URL of marketplace index JSON file. */
indexUrl: string;
/** Base URL used to resolve `${pluginId}.json` metadata files. */
baseUrl: string;
/** Optional fetch implementation. Falls back to global `fetch`. */
fetchFn?: PluginMarketplaceFetch;
}
/**
* Host-agnostic client for reading marketplace metadata.
*/
export declare class PluginMarketplaceClient {
private readonly indexUrl;
private readonly baseUrl;
private readonly fetchFn?;
constructor(options: PluginMarketplaceClientOptions);
/**
* Fetches and validates marketplace index.
*/
fetchIndex(): Promise<PluginIndex>;
/**
* Fetches and validates single plugin metadata entry.
*
* @param pluginId - Identifier referenced by marketplace index.
*/
fetchPlugin(pluginId: string): Promise<PluginMetadata>;
/**
* Fetches full marketplace catalog.
*
* Entries that fail fetch or validation get skipped so host can still render
* rest of catalog.
*/
fetchPlugins(): Promise<PluginMetadata[]>;
private buildMetadataUrl;
private getFetchFn;
}
//# sourceMappingURL=PluginMarketplaceClient.d.ts.map

1
dist/PluginMarketplaceClient.d.ts.map vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"PluginMarketplaceClient.d.ts","sourceRoot":"","sources":["../src/PluginMarketplaceClient.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAE9D;;GAEG;AACH,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1B;AAED;;GAEG;AACH,MAAM,MAAM,sBAAsB,GAAG,CACnC,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC3B,OAAO,CAAC,yBAAyB,CAAC,CAAC;AAExC;;GAEG;AACH,MAAM,WAAW,8BAA8B;IAC7C,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAC;IAEjB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAC;IAEhB,mEAAmE;IACnE,OAAO,CAAC,EAAE,sBAAsB,CAAC;CAClC;AAED;;GAEG;AACH,qBAAa,uBAAuB;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAyB;gBAEtC,OAAO,EAAE,8BAA8B;IAMnD;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,WAAW,CAAC;IAUxC;;;;OAIG;IACG,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAU5D;;;;;OAKG;IACG,YAAY,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAO/C,OAAO,CAAC,gBAAgB;IAKxB,OAAO,CAAC,UAAU;CAWnB"}

94
dist/PluginMarketplaceClient.js vendored Normal file
View File

@@ -0,0 +1,94 @@
/**
* Host-agnostic client for reading marketplace metadata.
*/
export class PluginMarketplaceClient {
constructor(options) {
this.indexUrl = options.indexUrl;
this.baseUrl = options.baseUrl;
this.fetchFn = options.fetchFn;
}
/**
* Fetches and validates marketplace index.
*/
async fetchIndex() {
const response = await this.getFetchFn()(this.indexUrl);
if (!response.ok) {
throw new Error(`Failed to fetch plugin index: HTTP ${response.status}`);
}
return normalizePluginIndex(await response.json());
}
/**
* Fetches and validates single plugin metadata entry.
*
* @param pluginId - Identifier referenced by marketplace index.
*/
async fetchPlugin(pluginId) {
const response = await this.getFetchFn()(this.buildMetadataUrl(pluginId));
if (!response.ok) {
throw new Error(`Failed to fetch plugin ${pluginId}: HTTP ${response.status}`);
}
return normalizePluginMetadata(await response.json(), pluginId);
}
/**
* Fetches full marketplace catalog.
*
* Entries that fail fetch or validation get skipped so host can still render
* rest of catalog.
*/
async fetchPlugins() {
const index = await this.fetchIndex();
const results = await Promise.allSettled(index.plugins.map((pluginId) => this.fetchPlugin(pluginId)));
return results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []));
}
buildMetadataUrl(pluginId) {
const trimmedBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`;
return `${trimmedBaseUrl}${pluginId}.json`;
}
getFetchFn() {
if (this.fetchFn) {
return this.fetchFn;
}
if (typeof fetch !== 'function') {
throw new Error('No fetch implementation available for PluginMarketplaceClient');
}
return fetch;
}
}
function normalizePluginIndex(value) {
const record = asRecord(value, 'Plugin index payload must be object');
return {
version: asString(record.version),
updatedAt: asString(record.updatedAt),
plugins: Array.isArray(record.plugins)
? record.plugins.filter((entry) => typeof entry === 'string')
: [],
};
}
function normalizePluginMetadata(value, fallbackId) {
const record = asRecord(value, `Plugin metadata for ${fallbackId} must be object`);
return {
id: asString(record.id) || fallbackId,
name: asString(record.name),
version: asString(record.version),
description: asString(record.description),
author: asString(record.author),
repository: asString(record.repository),
thumbnail: asString(record.thumbnail),
downloadUrl: asString(record.downloadUrl),
homepage: asString(record.homepage),
tags: Array.isArray(record.tags)
? record.tags.filter((entry) => typeof entry === 'string')
: [],
addedDate: asString(record.addedDate),
};
}
function asRecord(value, message) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(message);
}
return value;
}
function asString(value) {
return typeof value === 'string' ? value : '';
}
//# sourceMappingURL=PluginMarketplaceClient.js.map

1
dist/PluginMarketplaceClient.js.map vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"PluginMarketplaceClient.js","sourceRoot":"","sources":["../src/PluginMarketplaceClient.ts"],"names":[],"mappings":"AAiCA;;GAEG;AACH,MAAM,OAAO,uBAAuB;IAKlC,YAAY,OAAuC;QACjD,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;QACjC,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;QAC/B,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IACjC,CAAC;IAED;;OAEG;IACH,KAAK,CAAC,UAAU;QACd,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAExD,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,IAAI,KAAK,CAAC,sCAAsC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;QAC3E,CAAC;QAED,OAAO,oBAAoB,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IACrD,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,WAAW,CAAC,QAAgB;QAChC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC,IAAI,CAAC,gBAAgB,CAAC,QAAQ,CAAC,CAAC,CAAC;QAE1E,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,IAAI,KAAK,CAAC,0BAA0B,QAAQ,UAAU,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;QACjF,CAAC;QAED,OAAO,uBAAuB,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,EAAE,QAAQ,CAAC,CAAC;IAClE,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,YAAY;QAChB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC;QACtC,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;QAEtG,OAAO,OAAO,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,KAAK,WAAW,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC5F,CAAC;IAEO,gBAAgB,CAAC,QAAgB;QACvC,MAAM,cAAc,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,OAAO,GAAG,CAAC;QACtF,OAAO,GAAG,cAAc,GAAG,QAAQ,OAAO,CAAC;IAC7C,CAAC;IAEO,UAAU;QAChB,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YACjB,OAAO,IAAI,CAAC,OAAO,CAAC;QACtB,CAAC;QAED,IAAI,OAAO,KAAK,KAAK,UAAU,EAAE,CAAC;YAChC,MAAM,IAAI,KAAK,CAAC,+DAA+D,CAAC,CAAC;QACnF,CAAC;QAED,OAAO,KAA0C,CAAC;IACpD,CAAC;CACF;AAED,SAAS,oBAAoB,CAAC,KAAc;IAC1C,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,EAAE,qCAAqC,CAAC,CAAC;IAEtE,OAAO;QACL,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC;QACjC,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC;QACrC,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC;YACpC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAmB,EAAE,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC;YAC9E,CAAC,CAAC,EAAE;KACP,CAAC;AACJ,CAAC;AAED,SAAS,uBAAuB,CAAC,KAAc,EAAE,UAAkB;IACjE,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,EAAE,uBAAuB,UAAU,iBAAiB,CAAC,CAAC;IAEnF,OAAO;QACL,EAAE,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,UAAU;QACrC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC;QAC3B,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC;QACjC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,WAAW,CAAC;QACzC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC;QAC/B,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC;QACvC,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC;QACrC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,WAAW,CAAC;QACzC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC;QACnC,IAAI,EAAE,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC;YAC9B,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,KAAK,EAAmB,EAAE,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC;YAC3E,CAAC,CAAC,EAAE;QACN,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC;KACtC,CAAC;AACJ,CAAC;AAED,SAAS,QAAQ,CAAC,KAAc,EAAE,OAAe;IAC/C,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QAChE,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC;IAC3B,CAAC;IAED,OAAO,KAAgC,CAAC;AAC1C,CAAC;AAED,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;AAChD,CAAC"}

88
dist/PluginMarketplaceManager.d.ts vendored Normal file
View File

@@ -0,0 +1,88 @@
import { type IPluginStorage } from './interfaces.js';
import type { PluginRegistry } from './PluginRegistry.js';
import type { InstalledPlugin, PluginMetadata } from './types.js';
import { PluginMarketplaceClient } from './PluginMarketplaceClient.js';
/**
* Host-visible installed-plugin metadata.
*
* Hosts may only know filesystem facts such as `id`, `name`, or `installedDate`.
* Stored marketplace metadata fills remaining fields.
*/
export type PluginMarketplaceInstalledRecord = Partial<InstalledPlugin> & Pick<InstalledPlugin, 'id'>;
/**
* Host callbacks used by {@link PluginMarketplaceManager}.
*/
export interface PluginMarketplaceHostAdapter {
/**
* Returns host-known installed plugins.
*/
listInstalledPlugins?(): Promise<PluginMarketplaceInstalledRecord[]>;
/**
* Performs host-specific install side effects.
*/
installPlugin?(plugin: PluginMetadata): Promise<void>;
/**
* Performs host-specific uninstall side effects.
*/
uninstallPlugin?(pluginId: string): Promise<void>;
}
/**
* Construction options for {@link PluginMarketplaceManager}.
*/
export interface PluginMarketplaceManagerOptions {
/** Shared marketplace client used to query remote metadata. */
client: PluginMarketplaceClient;
/** Optional persistent storage for installed-plugin state. */
storage?: IPluginStorage;
/** Optional host adapter for install and discovery side effects. */
host?: PluginMarketplaceHostAdapter;
/** Optional registry used when disabling or uninstalling loaded plugins. */
registry?: PluginRegistry;
/** Optional override for installed-plugin storage key. */
installedPluginsStorageKey?: string;
}
/**
* Coordinates marketplace reads with host-specific install and enable state.
*/
export declare class PluginMarketplaceManager {
private readonly client;
private readonly storage;
private readonly host?;
private readonly registry?;
private readonly installedPluginsStorageKey;
constructor(options: PluginMarketplaceManagerOptions);
/**
* Fetches full marketplace catalog.
*/
fetchMarketplacePlugins(): Promise<PluginMetadata[]>;
/**
* Lists installed plugins merged with persisted enabled state.
*/
listInstalledPlugins(): Promise<InstalledPlugin[]>;
/**
* Lists installed plugins currently enabled.
*/
listEnabledPlugins(): Promise<InstalledPlugin[]>;
/**
* Returns whether plugin is recorded as installed.
*/
isPluginInstalled(pluginId: string): Promise<boolean>;
/**
* Installs marketplace plugin via host adapter and persists state.
*/
installPlugin(plugin: PluginMetadata): Promise<InstalledPlugin>;
/**
* Removes plugin from host and clears persisted install state.
*/
uninstallPlugin(pluginId: string): Promise<void>;
/**
* Enables or disables installed plugin.
*
* Disabling triggers `registry.unregisterPlugin` when registry exists.
* Enabling only updates persisted state; host controls actual load timing.
*/
setPluginEnabled(pluginId: string, enabled: boolean): Promise<InstalledPlugin | null>;
private readStoredInstalledPlugins;
private writeStoredInstalledPlugins;
}
//# sourceMappingURL=PluginMarketplaceManager.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"PluginMarketplaceManager.d.ts","sourceRoot":"","sources":["../src/PluginMarketplaceManager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACrE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAClE,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AAIvE;;;;;GAKG;AACH,MAAM,MAAM,gCAAgC,GAAG,OAAO,CAAC,eAAe,CAAC,GAAG,IAAI,CAAC,eAAe,EAAE,IAAI,CAAC,CAAC;AAEtG;;GAEG;AACH,MAAM,WAAW,4BAA4B;IAC3C;;OAEG;IACH,oBAAoB,CAAC,IAAI,OAAO,CAAC,gCAAgC,EAAE,CAAC,CAAC;IAErE;;OAEG;IACH,aAAa,CAAC,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEtD;;OAEG;IACH,eAAe,CAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnD;AAED;;GAEG;AACH,MAAM,WAAW,+BAA+B;IAC9C,+DAA+D;IAC/D,MAAM,EAAE,uBAAuB,CAAC;IAEhC,8DAA8D;IAC9D,OAAO,CAAC,EAAE,cAAc,CAAC;IAEzB,oEAAoE;IACpE,IAAI,CAAC,EAAE,4BAA4B,CAAC;IAEpC,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,cAAc,CAAC;IAE1B,0DAA0D;IAC1D,0BAA0B,CAAC,EAAE,MAAM,CAAC;CACrC;AAED;;GAEG;AACH,qBAAa,wBAAwB;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0B;IACjD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiB;IACzC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAA+B;IACrD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAiB;IAC3C,OAAO,CAAC,QAAQ,CAAC,0BAA0B,CAAS;gBAExC,OAAO,EAAE,+BAA+B;IASpD;;OAEG;IACG,uBAAuB,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAI1D;;OAEG;IACG,oBAAoB,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC;IAaxD;;OAEG;IACG,kBAAkB,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC;IAItD;;OAEG;IACG,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI3D;;OAEG;IACG,aAAa,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC;IAerE;;OAEG;IACG,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAWtD;;;;;OAKG;IACG,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC;IAiB3F,OAAO,CAAC,0BAA0B;IAsBlC,OAAO,CAAC,2BAA2B;CAGpC"}

169
dist/PluginMarketplaceManager.js vendored Normal file
View File

@@ -0,0 +1,169 @@
import { MemoryStorage } from './interfaces.js';
const DEFAULT_INSTALLED_PLUGINS_STORAGE_KEY = 'plugin-manager:installed-plugins';
/**
* Coordinates marketplace reads with host-specific install and enable state.
*/
export class PluginMarketplaceManager {
constructor(options) {
this.client = options.client;
this.storage = options.storage ?? new MemoryStorage();
this.host = options.host;
this.registry = options.registry;
this.installedPluginsStorageKey =
options.installedPluginsStorageKey ?? DEFAULT_INSTALLED_PLUGINS_STORAGE_KEY;
}
/**
* Fetches full marketplace catalog.
*/
async fetchMarketplacePlugins() {
return this.client.fetchPlugins();
}
/**
* Lists installed plugins merged with persisted enabled state.
*/
async listInstalledPlugins() {
const storedPlugins = this.readStoredInstalledPlugins();
if (!this.host?.listInstalledPlugins) {
return storedPlugins;
}
const hostPlugins = await this.host.listInstalledPlugins();
const mergedPlugins = mergeInstalledPlugins(hostPlugins, storedPlugins);
this.writeStoredInstalledPlugins(mergedPlugins);
return mergedPlugins;
}
/**
* Lists installed plugins currently enabled.
*/
async listEnabledPlugins() {
return (await this.listInstalledPlugins()).filter((plugin) => plugin.enabled);
}
/**
* Returns whether plugin is recorded as installed.
*/
async isPluginInstalled(pluginId) {
return (await this.listInstalledPlugins()).some((plugin) => plugin.id === pluginId);
}
/**
* Installs marketplace plugin via host adapter and persists state.
*/
async installPlugin(plugin) {
await this.host?.installPlugin?.(plugin);
const installedPlugin = {
...plugin,
installedDate: new Date().toISOString(),
enabled: true,
};
const updatedPlugins = upsertInstalledPlugin(this.readStoredInstalledPlugins(), installedPlugin);
this.writeStoredInstalledPlugins(updatedPlugins);
return updatedPlugins.find((entry) => entry.id === plugin.id) ?? installedPlugin;
}
/**
* Removes plugin from host and clears persisted install state.
*/
async uninstallPlugin(pluginId) {
if (this.registry) {
await this.registry.unregisterPlugin(pluginId);
}
await this.host?.uninstallPlugin?.(pluginId);
this.writeStoredInstalledPlugins(this.readStoredInstalledPlugins().filter((plugin) => plugin.id !== pluginId));
}
/**
* Enables or disables installed plugin.
*
* Disabling triggers `registry.unregisterPlugin` when registry exists.
* Enabling only updates persisted state; host controls actual load timing.
*/
async setPluginEnabled(pluginId, enabled) {
if (!enabled && this.registry) {
await this.registry.unregisterPlugin(pluginId);
}
const installedPlugins = await this.listInstalledPlugins();
const targetPlugin = installedPlugins.find((plugin) => plugin.id === pluginId);
if (!targetPlugin) {
return null;
}
const updatedPlugin = { ...targetPlugin, enabled };
this.writeStoredInstalledPlugins(upsertInstalledPlugin(installedPlugins, updatedPlugin));
return updatedPlugin;
}
readStoredInstalledPlugins() {
const rawValue = this.storage.getItem(this.installedPluginsStorageKey);
if (!rawValue) {
return [];
}
try {
const parsedValue = JSON.parse(rawValue);
if (!Array.isArray(parsedValue)) {
return [];
}
return parsedValue
.map((entry) => normalizeInstalledPlugin(entry))
.filter((entry) => entry !== null);
}
catch {
return [];
}
}
writeStoredInstalledPlugins(plugins) {
this.storage.setItem(this.installedPluginsStorageKey, JSON.stringify(plugins));
}
}
function mergeInstalledPlugins(hostPlugins, storedPlugins) {
const storedPluginsById = new Map(storedPlugins.map((plugin) => [plugin.id, plugin]));
const discoveredAt = new Date().toISOString();
return hostPlugins.map((plugin) => {
const storedPlugin = storedPluginsById.get(plugin.id);
return {
id: plugin.id,
name: pickString(plugin.name, storedPlugin?.name),
version: pickString(plugin.version, storedPlugin?.version),
description: pickString(plugin.description, storedPlugin?.description),
author: pickString(plugin.author, storedPlugin?.author),
repository: pickString(plugin.repository, storedPlugin?.repository),
thumbnail: pickString(plugin.thumbnail, storedPlugin?.thumbnail),
downloadUrl: pickString(plugin.downloadUrl, storedPlugin?.downloadUrl),
homepage: pickString(plugin.homepage, storedPlugin?.homepage),
tags: Array.isArray(plugin.tags)
? plugin.tags.filter((entry) => typeof entry === 'string')
: storedPlugin?.tags ?? [],
addedDate: pickString(plugin.addedDate, storedPlugin?.addedDate),
installedDate: pickString(plugin.installedDate, storedPlugin?.installedDate) || discoveredAt,
enabled: typeof plugin.enabled === 'boolean' ? plugin.enabled : storedPlugin?.enabled ?? true,
};
});
}
function upsertInstalledPlugin(plugins, nextPlugin) {
const filteredPlugins = plugins.filter((plugin) => plugin.id !== nextPlugin.id);
return [...filteredPlugins, nextPlugin];
}
function normalizeInstalledPlugin(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const record = value;
const id = typeof record.id === 'string' ? record.id : '';
if (!id) {
return null;
}
return {
id,
name: typeof record.name === 'string' ? record.name : '',
version: typeof record.version === 'string' ? record.version : '',
description: typeof record.description === 'string' ? record.description : '',
author: typeof record.author === 'string' ? record.author : '',
repository: typeof record.repository === 'string' ? record.repository : '',
thumbnail: typeof record.thumbnail === 'string' ? record.thumbnail : '',
downloadUrl: typeof record.downloadUrl === 'string' ? record.downloadUrl : '',
homepage: typeof record.homepage === 'string' ? record.homepage : '',
tags: Array.isArray(record.tags)
? record.tags.filter((entry) => typeof entry === 'string')
: [],
addedDate: typeof record.addedDate === 'string' ? record.addedDate : '',
installedDate: typeof record.installedDate === 'string' ? record.installedDate : '',
enabled: typeof record.enabled === 'boolean' ? record.enabled : true,
};
}
function pickString(value, fallback = '') {
return typeof value === 'string' && value.length > 0 ? value : fallback;
}
//# sourceMappingURL=PluginMarketplaceManager.js.map

1
dist/PluginMarketplaceManager.js.map vendored Normal file

File diff suppressed because one or more lines are too long

15
dist/index.browser.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
export type { IPluginStorage, IPluginEventClient, } from './interfaces.js';
export { MemoryStorage } from './interfaces.js';
export { PluginMarketplaceClient } from './PluginMarketplaceClient.js';
export type { PluginMarketplaceClientOptions, PluginMarketplaceFetch, PluginMarketplaceResponse, } from './PluginMarketplaceClient.js';
export { PluginMarketplaceManager } from './PluginMarketplaceManager.js';
export type { PluginMarketplaceHostAdapter, PluginMarketplaceInstalledRecord, PluginMarketplaceManagerOptions, } from './PluginMarketplaceManager.js';
export type { PluginMetadata, PluginIndex, InstalledPlugin, } from './types.js';
export { PluginTab } from './types.js';
export type { ThemeColorGroup, ThemePaletteGroup, PluginThemeColors, PluginTheme, CommandArg, PluginCommand, MessageInterceptor, MessageContext, CustomRenderer, SettingDefinition, SettingsSchema, NotificationOptions, PluginContext, PluginSettingsSection, UILocation, Plugin, PluginLogEntry, } from './PluginInterfaces.js';
export { generateThemeCSS } from './theme-css.js';
export { PluginRegistry } from './PluginRegistry.js';
export type { PluginRegistryOptions } from './PluginRegistry.js';
export { createPluginContext } from './PluginContext.js';
export type { PluginContextOptions } from './PluginContext.js';
//# sourceMappingURL=index.browser.d.ts.map

1
dist/index.browser.d.ts.map vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"index.browser.d.ts","sourceRoot":"","sources":["../src/index.browser.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,cAAc,EACd,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AACvE,YAAY,EACV,8BAA8B,EAC9B,sBAAsB,EACtB,yBAAyB,GAC1B,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;AACzE,YAAY,EACV,4BAA4B,EAC5B,gCAAgC,EAChC,+BAA+B,GAChC,MAAM,+BAA+B,CAAC;AAEvC,YAAY,EACV,cAAc,EACd,WAAW,EACX,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,UAAU,EACV,aAAa,EACb,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,cAAc,EACd,mBAAmB,EACnB,aAAa,EACb,qBAAqB,EACrB,UAAU,EACV,MAAM,EACN,cAAc,GACf,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,YAAY,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC"}

8
dist/index.browser.js vendored Normal file
View File

@@ -0,0 +1,8 @@
export { MemoryStorage } from './interfaces.js';
export { PluginMarketplaceClient } from './PluginMarketplaceClient.js';
export { PluginMarketplaceManager } from './PluginMarketplaceManager.js';
export { PluginTab } from './types.js';
export { generateThemeCSS } from './theme-css.js';
export { PluginRegistry } from './PluginRegistry.js';
export { createPluginContext } from './PluginContext.js';
//# sourceMappingURL=index.browser.js.map

1
dist/index.browser.js.map vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"index.browser.js","sourceRoot":"","sources":["../src/index.browser.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AAOvE,OAAO,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;AAYzE,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAsBvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC"}

4
dist/index.d.ts vendored
View File

@@ -1,5 +1,9 @@
export type { IPluginStorage, IPluginEventClient, } from './interfaces.js';
export { MemoryStorage } from './interfaces.js';
export { PluginMarketplaceClient } from './PluginMarketplaceClient.js';
export type { PluginMarketplaceClientOptions, PluginMarketplaceFetch, PluginMarketplaceResponse, } from './PluginMarketplaceClient.js';
export { PluginMarketplaceManager } from './PluginMarketplaceManager.js';
export type { PluginMarketplaceHostAdapter, PluginMarketplaceManagerOptions, } from './PluginMarketplaceManager.js';
export type { PluginMetadata, PluginIndex, InstalledPlugin, } from './types.js';
export { PluginTab } from './types.js';
export type { ThemeColorGroup, ThemePaletteGroup, PluginThemeColors, PluginTheme, CommandArg, PluginCommand, MessageInterceptor, MessageContext, CustomRenderer, SettingDefinition, SettingsSchema, NotificationOptions, PluginContext, PluginSettingsSection, UILocation, Plugin, PluginLogEntry, } from './PluginInterfaces.js';

2
dist/index.d.ts.map vendored
View File

@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,cAAc,EACd,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,YAAY,EACV,cAAc,EACd,WAAW,EACX,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,UAAU,EACV,aAAa,EACb,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,cAAc,EACd,mBAAmB,EACnB,aAAa,EACb,qBAAqB,EACrB,UAAU,EACV,MAAM,EACN,cAAc,GACf,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,YAAY,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAE/D,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,cAAc,EACd,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AACvE,YAAY,EACV,8BAA8B,EAC9B,sBAAsB,EACtB,yBAAyB,GAC1B,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;AACzE,YAAY,EACV,4BAA4B,EAC5B,+BAA+B,GAChC,MAAM,+BAA+B,CAAC;AAEvC,YAAY,EACV,cAAc,EACd,WAAW,EACX,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,UAAU,EACV,aAAa,EACb,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,cAAc,EACd,mBAAmB,EACnB,aAAa,EACb,qBAAqB,EACrB,UAAU,EACV,MAAM,EACN,cAAc,GACf,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,YAAY,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAE/D,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC"}

2
dist/index.js vendored
View File

@@ -1,4 +1,6 @@
export { MemoryStorage } from './interfaces.js';
export { PluginMarketplaceClient } from './PluginMarketplaceClient.js';
export { PluginMarketplaceManager } from './PluginMarketplaceManager.js';
export { PluginTab } from './types.js';
export { generateThemeCSS } from './theme-css.js';
export { PluginRegistry } from './PluginRegistry.js';

2
dist/index.js.map vendored
View File

@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAOhD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAsBvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAGzD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AAOvE,OAAO,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;AAWzE,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAsBvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAGzD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC"}

View File

@@ -7,6 +7,7 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"browser": "./dist/index.browser.js",
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}

View File

@@ -0,0 +1,149 @@
import type { PluginIndex, PluginMetadata } from './types.js';
/**
* Minimal response shape required by marketplace client.
*/
export interface PluginMarketplaceResponse {
ok: boolean;
status: number;
json(): Promise<unknown>;
}
/**
* Fetch function used by marketplace client.
*/
export type PluginMarketplaceFetch = (
input: string,
init?: Record<string, unknown>
) => Promise<PluginMarketplaceResponse>;
/**
* Construction options for {@link PluginMarketplaceClient}.
*/
export interface PluginMarketplaceClientOptions {
/** URL of marketplace index JSON file. */
indexUrl: string;
/** Base URL used to resolve `${pluginId}.json` metadata files. */
baseUrl: string;
/** Optional fetch implementation. Falls back to global `fetch`. */
fetchFn?: PluginMarketplaceFetch;
}
/**
* Host-agnostic client for reading marketplace metadata.
*/
export class PluginMarketplaceClient {
private readonly indexUrl: string;
private readonly baseUrl: string;
private readonly fetchFn?: PluginMarketplaceFetch;
constructor(options: PluginMarketplaceClientOptions) {
this.indexUrl = options.indexUrl;
this.baseUrl = options.baseUrl;
this.fetchFn = options.fetchFn;
}
/**
* Fetches and validates marketplace index.
*/
async fetchIndex(): Promise<PluginIndex> {
const response = await this.getFetchFn()(this.indexUrl);
if (!response.ok) {
throw new Error(`Failed to fetch plugin index: HTTP ${response.status}`);
}
return normalizePluginIndex(await response.json());
}
/**
* Fetches and validates single plugin metadata entry.
*
* @param pluginId - Identifier referenced by marketplace index.
*/
async fetchPlugin(pluginId: string): Promise<PluginMetadata> {
const response = await this.getFetchFn()(this.buildMetadataUrl(pluginId));
if (!response.ok) {
throw new Error(`Failed to fetch plugin ${pluginId}: HTTP ${response.status}`);
}
return normalizePluginMetadata(await response.json(), pluginId);
}
/**
* Fetches full marketplace catalog.
*
* Entries that fail fetch or validation get skipped so host can still render
* rest of catalog.
*/
async fetchPlugins(): Promise<PluginMetadata[]> {
const index = await this.fetchIndex();
const results = await Promise.allSettled(index.plugins.map((pluginId) => this.fetchPlugin(pluginId)));
return results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []));
}
private buildMetadataUrl(pluginId: string): string {
const trimmedBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`;
return `${trimmedBaseUrl}${pluginId}.json`;
}
private getFetchFn(): PluginMarketplaceFetch {
if (this.fetchFn) {
return this.fetchFn;
}
if (typeof fetch !== 'function') {
throw new Error('No fetch implementation available for PluginMarketplaceClient');
}
return fetch as unknown as PluginMarketplaceFetch;
}
}
function normalizePluginIndex(value: unknown): PluginIndex {
const record = asRecord(value, 'Plugin index payload must be object');
return {
version: asString(record.version),
updatedAt: asString(record.updatedAt),
plugins: Array.isArray(record.plugins)
? record.plugins.filter((entry): entry is string => typeof entry === 'string')
: [],
};
}
function normalizePluginMetadata(value: unknown, fallbackId: string): PluginMetadata {
const record = asRecord(value, `Plugin metadata for ${fallbackId} must be object`);
return {
id: asString(record.id) || fallbackId,
name: asString(record.name),
version: asString(record.version),
description: asString(record.description),
author: asString(record.author),
repository: asString(record.repository),
thumbnail: asString(record.thumbnail),
downloadUrl: asString(record.downloadUrl),
homepage: asString(record.homepage),
tags: Array.isArray(record.tags)
? record.tags.filter((entry): entry is string => typeof entry === 'string')
: [],
addedDate: asString(record.addedDate),
};
}
function asRecord(value: unknown, message: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(message);
}
return value as Record<string, unknown>;
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : '';
}

View File

@@ -0,0 +1,262 @@
import { MemoryStorage, type IPluginStorage } from './interfaces.js';
import type { PluginRegistry } from './PluginRegistry.js';
import type { InstalledPlugin, PluginMetadata } from './types.js';
import { PluginMarketplaceClient } from './PluginMarketplaceClient.js';
const DEFAULT_INSTALLED_PLUGINS_STORAGE_KEY = 'plugin-manager:installed-plugins';
/**
* Host-visible installed-plugin metadata.
*
* Hosts may only know filesystem facts such as `id`, `name`, or `installedDate`.
* Stored marketplace metadata fills remaining fields.
*/
export type PluginMarketplaceInstalledRecord = Partial<InstalledPlugin> & Pick<InstalledPlugin, 'id'>;
/**
* Host callbacks used by {@link PluginMarketplaceManager}.
*/
export interface PluginMarketplaceHostAdapter {
/**
* Returns host-known installed plugins.
*/
listInstalledPlugins?(): Promise<PluginMarketplaceInstalledRecord[]>;
/**
* Performs host-specific install side effects.
*/
installPlugin?(plugin: PluginMetadata): Promise<void>;
/**
* Performs host-specific uninstall side effects.
*/
uninstallPlugin?(pluginId: string): Promise<void>;
}
/**
* Construction options for {@link PluginMarketplaceManager}.
*/
export interface PluginMarketplaceManagerOptions {
/** Shared marketplace client used to query remote metadata. */
client: PluginMarketplaceClient;
/** Optional persistent storage for installed-plugin state. */
storage?: IPluginStorage;
/** Optional host adapter for install and discovery side effects. */
host?: PluginMarketplaceHostAdapter;
/** Optional registry used when disabling or uninstalling loaded plugins. */
registry?: PluginRegistry;
/** Optional override for installed-plugin storage key. */
installedPluginsStorageKey?: string;
}
/**
* Coordinates marketplace reads with host-specific install and enable state.
*/
export class PluginMarketplaceManager {
private readonly client: PluginMarketplaceClient;
private readonly storage: IPluginStorage;
private readonly host?: PluginMarketplaceHostAdapter;
private readonly registry?: PluginRegistry;
private readonly installedPluginsStorageKey: string;
constructor(options: PluginMarketplaceManagerOptions) {
this.client = options.client;
this.storage = options.storage ?? new MemoryStorage();
this.host = options.host;
this.registry = options.registry;
this.installedPluginsStorageKey =
options.installedPluginsStorageKey ?? DEFAULT_INSTALLED_PLUGINS_STORAGE_KEY;
}
/**
* Fetches full marketplace catalog.
*/
async fetchMarketplacePlugins(): Promise<PluginMetadata[]> {
return this.client.fetchPlugins();
}
/**
* Lists installed plugins merged with persisted enabled state.
*/
async listInstalledPlugins(): Promise<InstalledPlugin[]> {
const storedPlugins = this.readStoredInstalledPlugins();
if (!this.host?.listInstalledPlugins) {
return storedPlugins;
}
const hostPlugins = await this.host.listInstalledPlugins();
const mergedPlugins = mergeInstalledPlugins(hostPlugins, storedPlugins);
this.writeStoredInstalledPlugins(mergedPlugins);
return mergedPlugins;
}
/**
* Lists installed plugins currently enabled.
*/
async listEnabledPlugins(): Promise<InstalledPlugin[]> {
return (await this.listInstalledPlugins()).filter((plugin) => plugin.enabled);
}
/**
* Returns whether plugin is recorded as installed.
*/
async isPluginInstalled(pluginId: string): Promise<boolean> {
return (await this.listInstalledPlugins()).some((plugin) => plugin.id === pluginId);
}
/**
* Installs marketplace plugin via host adapter and persists state.
*/
async installPlugin(plugin: PluginMetadata): Promise<InstalledPlugin> {
await this.host?.installPlugin?.(plugin);
const installedPlugin: InstalledPlugin = {
...plugin,
installedDate: new Date().toISOString(),
enabled: true,
};
const updatedPlugins = upsertInstalledPlugin(this.readStoredInstalledPlugins(), installedPlugin);
this.writeStoredInstalledPlugins(updatedPlugins);
return updatedPlugins.find((entry) => entry.id === plugin.id) ?? installedPlugin;
}
/**
* Removes plugin from host and clears persisted install state.
*/
async uninstallPlugin(pluginId: string): Promise<void> {
if (this.registry) {
await this.registry.unregisterPlugin(pluginId);
}
await this.host?.uninstallPlugin?.(pluginId);
this.writeStoredInstalledPlugins(
this.readStoredInstalledPlugins().filter((plugin) => plugin.id !== pluginId)
);
}
/**
* Enables or disables installed plugin.
*
* Disabling triggers `registry.unregisterPlugin` when registry exists.
* Enabling only updates persisted state; host controls actual load timing.
*/
async setPluginEnabled(pluginId: string, enabled: boolean): Promise<InstalledPlugin | null> {
if (!enabled && this.registry) {
await this.registry.unregisterPlugin(pluginId);
}
const installedPlugins = await this.listInstalledPlugins();
const targetPlugin = installedPlugins.find((plugin) => plugin.id === pluginId);
if (!targetPlugin) {
return null;
}
const updatedPlugin: InstalledPlugin = { ...targetPlugin, enabled };
this.writeStoredInstalledPlugins(upsertInstalledPlugin(installedPlugins, updatedPlugin));
return updatedPlugin;
}
private readStoredInstalledPlugins(): InstalledPlugin[] {
const rawValue = this.storage.getItem(this.installedPluginsStorageKey);
if (!rawValue) {
return [];
}
try {
const parsedValue = JSON.parse(rawValue) as unknown;
if (!Array.isArray(parsedValue)) {
return [];
}
return parsedValue
.map((entry) => normalizeInstalledPlugin(entry))
.filter((entry): entry is InstalledPlugin => entry !== null);
} catch {
return [];
}
}
private writeStoredInstalledPlugins(plugins: InstalledPlugin[]): void {
this.storage.setItem(this.installedPluginsStorageKey, JSON.stringify(plugins));
}
}
function mergeInstalledPlugins(
hostPlugins: PluginMarketplaceInstalledRecord[],
storedPlugins: InstalledPlugin[]
): InstalledPlugin[] {
const storedPluginsById = new Map(storedPlugins.map((plugin) => [plugin.id, plugin]));
const discoveredAt = new Date().toISOString();
return hostPlugins.map((plugin) => {
const storedPlugin = storedPluginsById.get(plugin.id);
return {
id: plugin.id,
name: pickString(plugin.name, storedPlugin?.name),
version: pickString(plugin.version, storedPlugin?.version),
description: pickString(plugin.description, storedPlugin?.description),
author: pickString(plugin.author, storedPlugin?.author),
repository: pickString(plugin.repository, storedPlugin?.repository),
thumbnail: pickString(plugin.thumbnail, storedPlugin?.thumbnail),
downloadUrl: pickString(plugin.downloadUrl, storedPlugin?.downloadUrl),
homepage: pickString(plugin.homepage, storedPlugin?.homepage),
tags: Array.isArray(plugin.tags)
? plugin.tags.filter((entry): entry is string => typeof entry === 'string')
: storedPlugin?.tags ?? [],
addedDate: pickString(plugin.addedDate, storedPlugin?.addedDate),
installedDate: pickString(plugin.installedDate, storedPlugin?.installedDate) || discoveredAt,
enabled: typeof plugin.enabled === 'boolean' ? plugin.enabled : storedPlugin?.enabled ?? true,
};
});
}
function upsertInstalledPlugin(plugins: InstalledPlugin[], nextPlugin: InstalledPlugin): InstalledPlugin[] {
const filteredPlugins = plugins.filter((plugin) => plugin.id !== nextPlugin.id);
return [...filteredPlugins, nextPlugin];
}
function normalizeInstalledPlugin(value: unknown): InstalledPlugin | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = typeof record.id === 'string' ? record.id : '';
if (!id) {
return null;
}
return {
id,
name: typeof record.name === 'string' ? record.name : '',
version: typeof record.version === 'string' ? record.version : '',
description: typeof record.description === 'string' ? record.description : '',
author: typeof record.author === 'string' ? record.author : '',
repository: typeof record.repository === 'string' ? record.repository : '',
thumbnail: typeof record.thumbnail === 'string' ? record.thumbnail : '',
downloadUrl: typeof record.downloadUrl === 'string' ? record.downloadUrl : '',
homepage: typeof record.homepage === 'string' ? record.homepage : '',
tags: Array.isArray(record.tags)
? record.tags.filter((entry): entry is string => typeof entry === 'string')
: [],
addedDate: typeof record.addedDate === 'string' ? record.addedDate : '',
installedDate: typeof record.installedDate === 'string' ? record.installedDate : '',
enabled: typeof record.enabled === 'boolean' ? record.enabled : true,
};
}
function pickString(value: unknown, fallback = ''): string {
return typeof value === 'string' && value.length > 0 ? value : fallback;
}

54
src/index.browser.ts Normal file
View File

@@ -0,0 +1,54 @@
export type {
IPluginStorage,
IPluginEventClient,
} from './interfaces.js';
export { MemoryStorage } from './interfaces.js';
export { PluginMarketplaceClient } from './PluginMarketplaceClient.js';
export type {
PluginMarketplaceClientOptions,
PluginMarketplaceFetch,
PluginMarketplaceResponse,
} from './PluginMarketplaceClient.js';
export { PluginMarketplaceManager } from './PluginMarketplaceManager.js';
export type {
PluginMarketplaceHostAdapter,
PluginMarketplaceInstalledRecord,
PluginMarketplaceManagerOptions,
} from './PluginMarketplaceManager.js';
export type {
PluginMetadata,
PluginIndex,
InstalledPlugin,
} from './types.js';
export { PluginTab } from './types.js';
export type {
ThemeColorGroup,
ThemePaletteGroup,
PluginThemeColors,
PluginTheme,
CommandArg,
PluginCommand,
MessageInterceptor,
MessageContext,
CustomRenderer,
SettingDefinition,
SettingsSchema,
NotificationOptions,
PluginContext,
PluginSettingsSection,
UILocation,
Plugin,
PluginLogEntry,
} from './PluginInterfaces.js';
export { generateThemeCSS } from './theme-css.js';
export { PluginRegistry } from './PluginRegistry.js';
export type { PluginRegistryOptions } from './PluginRegistry.js';
export { createPluginContext } from './PluginContext.js';
export type { PluginContextOptions } from './PluginContext.js';

View File

@@ -4,6 +4,19 @@ export type {
} from './interfaces.js';
export { MemoryStorage } from './interfaces.js';
export { PluginMarketplaceClient } from './PluginMarketplaceClient.js';
export type {
PluginMarketplaceClientOptions,
PluginMarketplaceFetch,
PluginMarketplaceResponse,
} from './PluginMarketplaceClient.js';
export { PluginMarketplaceManager } from './PluginMarketplaceManager.js';
export type {
PluginMarketplaceHostAdapter,
PluginMarketplaceManagerOptions,
} from './PluginMarketplaceManager.js';
export type {
PluginMetadata,
PluginIndex,

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);