feat(plugin): support enterprise extension (#861)
This commit is contained in:
43
electron/extensions/builtin/clawhub-marketplace.ts
Normal file
43
electron/extensions/builtin/clawhub-marketplace.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
MarketplaceProviderExtension,
|
||||
MarketplaceCapability,
|
||||
} from '../types';
|
||||
import type {
|
||||
ClawHubSearchParams,
|
||||
ClawHubInstallParams,
|
||||
ClawHubSkillResult,
|
||||
} from '../../gateway/clawhub';
|
||||
|
||||
class ClawHubMarketplaceExtension implements MarketplaceProviderExtension {
|
||||
readonly id = 'builtin/clawhub-marketplace';
|
||||
|
||||
setup(_ctx: ExtensionContext): void {
|
||||
// No setup needed -- search/install delegates to the ClawHubService CLI runner
|
||||
}
|
||||
|
||||
async getCapability(): Promise<MarketplaceCapability> {
|
||||
return {
|
||||
mode: 'clawhub',
|
||||
canSearch: true,
|
||||
canInstall: true,
|
||||
};
|
||||
}
|
||||
|
||||
async search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]> {
|
||||
const { ClawHubService } = await import('../../gateway/clawhub');
|
||||
const svc = new ClawHubService();
|
||||
return svc.search(params);
|
||||
}
|
||||
|
||||
async install(params: ClawHubInstallParams): Promise<void> {
|
||||
const { ClawHubService } = await import('../../gateway/clawhub');
|
||||
const svc = new ClawHubService();
|
||||
return svc.install(params);
|
||||
}
|
||||
}
|
||||
|
||||
export function createClawHubMarketplaceExtension(): Extension {
|
||||
return new ClawHubMarketplaceExtension();
|
||||
}
|
||||
25
electron/extensions/builtin/diagnostics.ts
Normal file
25
electron/extensions/builtin/diagnostics.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiRouteExtension,
|
||||
RouteHandler,
|
||||
} from '../types';
|
||||
|
||||
class DiagnosticsExtension implements HostApiRouteExtension {
|
||||
readonly id = 'builtin/diagnostics';
|
||||
|
||||
setup(_ctx: ExtensionContext): void {
|
||||
// Diagnostics routes are stateless; no setup needed.
|
||||
}
|
||||
|
||||
getRouteHandler(): RouteHandler {
|
||||
return async (req, res, url, ctx) => {
|
||||
const { handleDiagnosticsRoutes } = await import('../../api/routes/diagnostics');
|
||||
return handleDiagnosticsRoutes(req, res, url, ctx);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiagnosticsExtension(): Extension {
|
||||
return new DiagnosticsExtension();
|
||||
}
|
||||
8
electron/extensions/builtin/index.ts
Normal file
8
electron/extensions/builtin/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerBuiltinExtension } from '../loader';
|
||||
import { createClawHubMarketplaceExtension } from './clawhub-marketplace';
|
||||
import { createDiagnosticsExtension } from './diagnostics';
|
||||
|
||||
export function registerAllBuiltinExtensions(): void {
|
||||
registerBuiltinExtension('builtin/clawhub-marketplace', createClawHubMarketplaceExtension);
|
||||
registerBuiltinExtension('builtin/diagnostics', createDiagnosticsExtension);
|
||||
}
|
||||
17
electron/extensions/index.ts
Normal file
17
electron/extensions/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { extensionRegistry } from './registry';
|
||||
export { registerBuiltinExtension, loadExtensionsFromManifest } from './loader';
|
||||
export type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiRouteExtension,
|
||||
MarketplaceProviderExtension,
|
||||
MarketplaceCapability,
|
||||
AuthProviderExtension,
|
||||
AuthStatus,
|
||||
RouteHandler,
|
||||
} from './types';
|
||||
export {
|
||||
isHostApiRouteExtension,
|
||||
isMarketplaceProviderExtension,
|
||||
isAuthProviderExtension,
|
||||
} from './types';
|
||||
76
electron/extensions/loader.ts
Normal file
76
electron/extensions/loader.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { app } from 'electron';
|
||||
import { logger } from '../utils/logger';
|
||||
import { extensionRegistry } from './registry';
|
||||
import type { Extension } from './types';
|
||||
|
||||
interface ExtensionManifest {
|
||||
extensions?: {
|
||||
main?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const builtinModules = new Map<string, () => Extension>();
|
||||
|
||||
export function registerBuiltinExtension(id: string, factory: () => Extension): void {
|
||||
builtinModules.set(id, factory);
|
||||
}
|
||||
|
||||
function resolveManifestPath(): string {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, 'clawx-extensions.json');
|
||||
}
|
||||
return join(app.getAppPath(), 'clawx-extensions.json');
|
||||
}
|
||||
|
||||
export async function loadExtensionsFromManifest(): Promise<void> {
|
||||
const manifestPath = resolveManifestPath();
|
||||
let manifest: ExtensionManifest = {};
|
||||
|
||||
if (existsSync(manifestPath)) {
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as ExtensionManifest;
|
||||
logger.info(`[extensions] Loaded manifest from ${manifestPath}`);
|
||||
} catch (err) {
|
||||
logger.warn(`[extensions] Failed to parse ${manifestPath}, using defaults:`, err);
|
||||
}
|
||||
} else {
|
||||
logger.debug('[extensions] No clawx-extensions.json found, loading all builtin extensions');
|
||||
}
|
||||
|
||||
const mainExtensions = manifest.extensions?.main;
|
||||
|
||||
if (!mainExtensions || mainExtensions.length === 0) {
|
||||
for (const [id, factory] of builtinModules) {
|
||||
extensionRegistry.register(factory());
|
||||
logger.debug(`[extensions] Auto-registered builtin extension "${id}"`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extensionId of mainExtensions) {
|
||||
if (builtinModules.has(extensionId)) {
|
||||
extensionRegistry.register(builtinModules.get(extensionId)!());
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require(extensionId) as { default?: Extension; extension?: Extension };
|
||||
const ext = mod.default ?? mod.extension;
|
||||
if (ext && typeof ext.setup === 'function') {
|
||||
extensionRegistry.register(ext);
|
||||
} else {
|
||||
logger.warn(`[extensions] Module "${extensionId}" does not export a valid Extension`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('Cannot find module')) {
|
||||
logger.debug(`[extensions] "${extensionId}" not loadable at runtime (expected when using ext-bridge)`);
|
||||
} else {
|
||||
logger.warn(`[extensions] Failed to load extension "${extensionId}": ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
electron/extensions/registry.ts
Normal file
76
electron/extensions/registry.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiRouteExtension,
|
||||
MarketplaceProviderExtension,
|
||||
RouteHandler,
|
||||
} from './types';
|
||||
import {
|
||||
isHostApiRouteExtension,
|
||||
isMarketplaceProviderExtension,
|
||||
} from './types';
|
||||
|
||||
class ExtensionRegistry {
|
||||
private extensions = new Map<string, Extension>();
|
||||
private ctx: ExtensionContext | null = null;
|
||||
|
||||
async initialize(ctx: ExtensionContext): Promise<void> {
|
||||
this.ctx = ctx;
|
||||
for (const ext of this.extensions.values()) {
|
||||
try {
|
||||
await ext.setup(ctx);
|
||||
logger.info(`[extensions] Extension "${ext.id}" initialized`);
|
||||
} catch (err) {
|
||||
logger.error(`[extensions] Extension "${ext.id}" failed to initialize:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register(extension: Extension): void {
|
||||
if (this.extensions.has(extension.id)) {
|
||||
logger.warn(`[extensions] Extension "${extension.id}" is already registered; skipping duplicate`);
|
||||
return;
|
||||
}
|
||||
this.extensions.set(extension.id, extension);
|
||||
logger.debug(`[extensions] Registered extension "${extension.id}"`);
|
||||
|
||||
if (this.ctx) {
|
||||
void Promise.resolve(extension.setup(this.ctx)).catch((err) => {
|
||||
logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string): Extension | undefined {
|
||||
return this.extensions.get(id);
|
||||
}
|
||||
|
||||
getAll(): Extension[] {
|
||||
return [...this.extensions.values()];
|
||||
}
|
||||
|
||||
getRouteHandlers(): RouteHandler[] {
|
||||
return this.getAll()
|
||||
.filter(isHostApiRouteExtension)
|
||||
.map((ext: HostApiRouteExtension) => ext.getRouteHandler());
|
||||
}
|
||||
|
||||
getMarketplaceProvider(): MarketplaceProviderExtension | undefined {
|
||||
return this.getAll().find(isMarketplaceProviderExtension) as MarketplaceProviderExtension | undefined;
|
||||
}
|
||||
|
||||
async teardownAll(): Promise<void> {
|
||||
for (const ext of this.extensions.values()) {
|
||||
try {
|
||||
await ext.teardown?.();
|
||||
} catch (err) {
|
||||
logger.warn(`[extensions] Extension "${ext.id}" teardown failed:`, err);
|
||||
}
|
||||
}
|
||||
this.extensions.clear();
|
||||
this.ctx = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionRegistry = new ExtensionRegistry();
|
||||
69
electron/extensions/types.ts
Normal file
69
electron/extensions/types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { HostEventBus } from '../api/event-bus';
|
||||
import type { HostApiContext } from '../api/context';
|
||||
import type {
|
||||
ClawHubSearchParams,
|
||||
ClawHubInstallParams,
|
||||
ClawHubSkillResult,
|
||||
} from '../gateway/clawhub';
|
||||
|
||||
export type RouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
) => Promise<boolean>;
|
||||
|
||||
export interface ExtensionContext {
|
||||
gatewayManager: GatewayManager;
|
||||
eventBus: HostEventBus;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
id: string;
|
||||
setup(ctx: ExtensionContext): void | Promise<void>;
|
||||
teardown?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface HostApiRouteExtension extends Extension {
|
||||
getRouteHandler(): RouteHandler;
|
||||
}
|
||||
|
||||
export interface MarketplaceCapability {
|
||||
mode: string;
|
||||
canSearch: boolean;
|
||||
canInstall: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceProviderExtension extends Extension {
|
||||
getCapability(): Promise<MarketplaceCapability>;
|
||||
search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]>;
|
||||
install(params: ClawHubInstallParams): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
authenticated: boolean;
|
||||
expired: boolean;
|
||||
user: { username: string; displayName: string; email: string } | null;
|
||||
}
|
||||
|
||||
export interface AuthProviderExtension extends Extension {
|
||||
getAuthStatus(): Promise<AuthStatus>;
|
||||
onStartup?(mainWindow: BrowserWindow): Promise<void>;
|
||||
}
|
||||
|
||||
export function isHostApiRouteExtension(ext: Extension): ext is HostApiRouteExtension {
|
||||
return 'getRouteHandler' in ext && typeof (ext as HostApiRouteExtension).getRouteHandler === 'function';
|
||||
}
|
||||
|
||||
export function isMarketplaceProviderExtension(ext: Extension): ext is MarketplaceProviderExtension {
|
||||
return 'getCapability' in ext && 'search' in ext && 'install' in ext;
|
||||
}
|
||||
|
||||
export function isAuthProviderExtension(ext: Extension): ext is AuthProviderExtension {
|
||||
return 'getAuthStatus' in ext && typeof (ext as AuthProviderExtension).getAuthStatus === 'function';
|
||||
}
|
||||
Reference in New Issue
Block a user