feat(plugin): support enterprise extension (#861)

This commit is contained in:
Haze
2026-04-16 17:15:25 +08:00
committed by GitHub
Unverified
parent 2fefbf3aba
commit b884db629e
29 changed files with 847 additions and 22 deletions

View 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();
}

View 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();
}

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

View 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';

View 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}`);
}
}
}
}

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

View 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';
}