diff --git a/addon/exports/host-services.js b/addon/exports/host-services.js index f4420bc3..86ce460f 100644 --- a/addon/exports/host-services.js +++ b/addon/exports/host-services.js @@ -24,6 +24,7 @@ export const hostServices = [ 'universe/hook-service', 'universe/widget-service', 'universe/extension-manager', + 'universe/plugin-context', 'events', 'intl', 'abilities', diff --git a/addon/exports/services.js b/addon/exports/services.js index f5e6b264..eae21f12 100644 --- a/addon/exports/services.js +++ b/addon/exports/services.js @@ -26,6 +26,7 @@ export const services = [ 'universe/hook-service', 'universe/widget-service', 'universe/extension-manager', + 'universe/plugin-context', 'events', 'intl', 'abilities', diff --git a/addon/services/universe.js b/addon/services/universe.js index 27709e11..9f006e41 100644 --- a/addon/services/universe.js +++ b/addon/services/universe.js @@ -30,6 +30,7 @@ export default class UniverseService extends Service.extend(Evented) { @service('universe/menu-service') menuService; @service('universe/widget-service') widgetService; @service('universe/hook-service') hookService; + @service('universe/plugin-context') pluginContext; @service router; @service intl; @service urlSearchParams; @@ -64,6 +65,9 @@ export default class UniverseService extends Service.extend(Evented) { if (this.hookService) { this.hookService.setApplicationInstance(application); } + if (this.pluginContext) { + this.pluginContext.setApplicationInstance(application); + } } /** diff --git a/addon/services/universe/extension-manager.js b/addon/services/universe/extension-manager.js index 9b0156f8..139a2ef2 100644 --- a/addon/services/universe/extension-manager.js +++ b/addon/services/universe/extension-manager.js @@ -6,8 +6,9 @@ import { assert, debug, warn } from '@ember/debug'; import { next } from '@ember/runloop'; import loadInstalledExtensions from '@fleetbase/ember-core/utils/load-installed-extensions'; import mapEngines from '@fleetbase/ember-core/utils/map-engines'; +import isPluginExtension from '@fleetbase/ember-core/utils/is-plugin-extension'; import config from 'ember-get-config'; -import { getExtensionLoader } from '@fleetbase/console/extensions'; +import * as extensionLoaders from '@fleetbase/console/extensions'; import { isArray } from '@ember/array'; import RSVP from 'rsvp'; import ExtensionBootState from '../../contracts/extension-boot-state'; @@ -23,6 +24,7 @@ import ExtensionBootState from '../../contracts/extension-boot-state'; */ export default class ExtensionManagerService extends Service.extend(Evented) { @service universe; + @service('universe/plugin-context') pluginContext; /** * Reference to the root Ember Application Instance. * Used for registering components/services to the application container @@ -782,15 +784,19 @@ export default class ExtensionManagerService extends Service.extend(Evented) { this.registerExtension(extensionName, extension); } - // Phase 2: Load and execute extension.js from each enabled extension + // Phase 2: Load and execute plugin files or extension.js from each enabled package for (const extension of extensions) { // Extension is an object with name, version, etc. from package.json const extensionName = extension.name || extension; const extStartTime = performance.now(); - // Lookup the loader function from the build-time generated map - const loader = getExtensionLoader(extensionName); + if (isPluginExtension(extension)) { + await this.#setupPlugin(extension, appInstance, universe, extStartTime); + continue; + } + // Lookup the loader function from the build-time generated map + const loader = this.#getExtensionLoader(extensionName); if (!loader) { warn(`[ExtensionManager] No loader registered for ${extensionName}. Ensure addon/extension.js exists and prebuild generated the mapping.`, false, { id: 'ember-core.extension-manager.no-loader', @@ -847,6 +853,57 @@ export default class ExtensionManagerService extends Service.extend(Evented) { debug(`[ExtensionManager] All ${extensions.length} extensions setup completed in ${totalTime}ms`); } + /** + * Load and execute a lightweight Fleetbase plugin. + * + * @private + * @param {Object} plugin Plugin manifest + * @param {ApplicationInstance} appInstance The application instance + * @param {Service} universe The universe service + * @param {Number} extStartTime Start time for debug logging + * @returns {Promise} + */ + async #setupPlugin(plugin, appInstance, universe, extStartTime) { + const pluginName = plugin.name || plugin.id || plugin; + const loader = this.#getPluginLoader(pluginName); + + if (!loader) { + warn(`[ExtensionManager] No plugin loader registered for ${pluginName}. Ensure fleetbase.plugin.js is indexed by the generated plugin mapping.`, false, { + id: 'ember-core.extension-manager.no-plugin-loader', + }); + return; + } + + try { + const module = await loader(); + const registerPlugin = module.default ?? module.registerPlugin ?? module; + + if (typeof registerPlugin !== 'function') { + warn(`[ExtensionManager] ${pluginName} plugin did not export a register function.`, false, { + id: 'ember-core.extension-manager.invalid-plugin-export', + }); + return; + } + + const context = this.pluginContext.create(plugin, appInstance, universe); + await registerPlugin(appInstance, context); + + const extEndTime = performance.now(); + const totalTime = (extEndTime - extStartTime).toFixed(2); + debug(`[ExtensionManager] ${pluginName} plugin setup completed in ${totalTime}ms`); + } catch (error) { + console.error(`[ExtensionManager] Failed to load or run plugin for ${pluginName}:`, error); + } + } + + #getExtensionLoader(extensionName) { + return extensionLoaders.getExtensionLoader?.(extensionName); + } + + #getPluginLoader(pluginName) { + return extensionLoaders.getPluginLoader?.(pluginName) ?? extensionLoaders.getExtensionLoader?.(pluginName); + } + /** * Register a service into a specific engine * Allows sharing host services with engines diff --git a/addon/services/universe/plugin-context.js b/addon/services/universe/plugin-context.js new file mode 100644 index 00000000..d51c5df8 --- /dev/null +++ b/addon/services/universe/plugin-context.js @@ -0,0 +1,207 @@ +import Service, { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { getOwner } from '@ember/application'; +import { assert, warn } from '@ember/debug'; +import { dasherize } from '@ember/string'; +import hostServices from '../../exports/host-services'; + +function serviceNamesFromHostServices() { + const serviceNames = new Set(); + + for (const entry of hostServices) { + if (typeof entry === 'string') { + serviceNames.add(entry); + } else if (entry && typeof entry === 'object') { + Object.keys(entry).forEach((name) => serviceNames.add(dasherize(name))); + Object.values(entry).forEach((name) => serviceNames.add(dasherize(name))); + } + } + + return serviceNames; +} + +const ALLOWED_SERVICE_NAMES = serviceNamesFromHostServices(); + +function normalizeServiceName(name) { + const normalized = name?.startsWith('service:') ? name.slice(8) : name; + return typeof normalized === 'string' ? dasherize(normalized) : normalized; +} + +function normalizeMenuItem(definition = {}) { + const id = definition.id || definition.slug || dasherize(definition.title || definition.label || definition.text || 'plugin-menu-item'); + const title = definition.title || definition.label || definition.text || id; + + return { + ...definition, + id, + slug: definition.slug || id, + title, + label: definition.label || title, + text: definition.text || title, + onClick: definition.onClick || definition.action || null, + _plugin: definition._plugin, + }; +} + +function normalizeWidget(definition = {}) { + const id = definition.id || definition.widgetId || dasherize(definition.name || definition.title || 'plugin-widget'); + + return { + ...definition, + id, + widgetId: definition.widgetId || id, + name: definition.name || definition.title || definition.label || id, + }; +} + +/** + * PluginContextService + * + * Builds constrained runtime contexts for lightweight Fleetbase plugins. + * Plugins use this facade instead of directly reaching into Ember internals. + */ +export default class PluginContextService extends Service { + @service universe; + @service('universe/menu-service') menuService; + @service('universe/widget-service') widgetService; + @service('universe/hook-service') hookService; + @service('universe/registry-service') registryService; + + @tracked applicationInstance = null; + registeredPermissions = []; + + setApplicationInstance(application) { + this.applicationInstance = application; + } + + create(plugin = {}, appInstance = null, universe = this.universe) { + const owner = appInstance || this.applicationInstance || getOwner(this); + const pluginName = plugin.name || plugin.id || 'fleetbase-plugin'; + + return { + plugin, + owner, + lookup: (name) => this.lookup(name, owner), + registerMenuItem: (slot, definition) => this.registerMenuItem(pluginName, slot, definition), + registerWidget: (slot, definition) => this.registerWidget(pluginName, slot, definition), + registerRoute: (path, definition) => this.registerRoute(pluginName, path, definition), + registerAction: (slot, handler, options = {}) => this.registerAction(pluginName, slot, handler, options), + registerHook: (name, handler, options = {}) => this.registerHook(pluginName, name, handler, options), + registerPermission: (permission) => this.registerPermission(pluginName, permission), + on: (event, callback) => this.onEvent(universe, event, callback), + off: (event, callback) => this.offEvent(universe, event, callback), + emit: (event, ...payload) => this.emitEvent(universe, event, ...payload), + }; + } + + lookup(name, owner = null) { + const serviceName = normalizeServiceName(name); + + assert(`[PluginContext] lookup() only supports service lookups, received '${name}'`, typeof serviceName === 'string' && serviceName.length > 0); + assert(`[PluginContext] service '${serviceName}' is not available to plugins`, ALLOWED_SERVICE_NAMES.has(serviceName)); + + const container = owner || this.applicationInstance || getOwner(this); + return container?.lookup?.(`service:${serviceName}`) ?? null; + } + + registerMenuItem(pluginName, slot, definition = {}) { + assert('[PluginContext] registerMenuItem() requires a slot name', typeof slot === 'string' && slot.length > 0); + + const menuItem = normalizeMenuItem({ + ...definition, + _plugin: pluginName, + }); + + this.menuService.registerMenuItem(slot, menuItem); + return menuItem; + } + + registerWidget(pluginName, slot, definition = {}) { + assert('[PluginContext] registerWidget() requires a slot name', typeof slot === 'string' && slot.length > 0); + + const widget = normalizeWidget({ + ...definition, + _plugin: pluginName, + }); + + this.registryService.register(slot, 'widget', widget.id, widget); + + if (slot === 'dashboard' || slot.startsWith('dashboard.')) { + this.widgetService.registerWidgets(slot, widget); + } + + return widget; + } + + registerRoute(pluginName, path, definition = {}) { + assert('[PluginContext] registerRoute() requires a path', typeof path === 'string' && path.length > 0); + + const route = { + ...definition, + path, + id: definition.id || path, + _plugin: pluginName, + }; + + this.registryService.register('plugin:routes', 'route', route.id, route); + warn('[PluginContext] registerRoute() records virtual plugin routes only; runtime Ember route-map mutation is not supported in v1.', false, { + id: 'ember-core.plugin-context.route-recorded', + }); + + return route; + } + + registerAction(pluginName, slot, handler, options = {}) { + assert('[PluginContext] registerAction() requires a slot name', typeof slot === 'string' && slot.length > 0); + assert('[PluginContext] registerAction() requires a handler function', typeof handler === 'function'); + + const action = { + id: options.id || `${pluginName}:${slot}`, + slot, + handler, + _plugin: pluginName, + ...options, + }; + + this.registryService.register(slot, 'action', action.id, action); + return action; + } + + registerHook(pluginName, name, handler, options = {}) { + const hookId = options.id || `${pluginName}:${name}`; + + this.hookService.registerHook(name, handler, { + ...options, + id: hookId, + }); + + return this.hookService.getHooks(name).find((hook) => hook.id === hookId); + } + + registerPermission(pluginName, permission) { + assert('[PluginContext] registerPermission() requires a permission string or definition', typeof permission === 'string' || (permission && typeof permission === 'object')); + + const definition = typeof permission === 'string' ? { permission } : permission; + const registered = { + ...definition, + _plugin: pluginName, + }; + + this.registeredPermissions.push(registered); + this.registryService.register('plugin:permissions', 'permission', registered.permission || registered.id, registered); + return registered; + } + + onEvent(universe, event, callback) { + universe?.on?.(event, callback); + return callback; + } + + offEvent(universe, event, callback) { + universe?.off?.(event, callback); + } + + emitEvent(universe, event, ...payload) { + universe?.trigger?.(event, ...payload); + } +} diff --git a/addon/utils/is-plugin-extension.js b/addon/utils/is-plugin-extension.js new file mode 100644 index 00000000..92abd657 --- /dev/null +++ b/addon/utils/is-plugin-extension.js @@ -0,0 +1,3 @@ +export default function isPluginExtension(extension) { + return extension?.type === 'plugin' || extension?.fleetbase?.type === 'plugin' || extension?.fleetbase?.plugin === true; +} diff --git a/addon/utils/load-engines.js b/addon/utils/load-engines.js index 933612bf..c699571b 100644 --- a/addon/utils/load-engines.js +++ b/addon/utils/load-engines.js @@ -1,5 +1,6 @@ import { dasherize } from '@ember/string'; import hostServices from '../exports/host-services'; +import isPluginExtension from './is-plugin-extension'; export default async function loadEngines(appInstance, withServices = []) { return new Promise((resolve, reject) => { @@ -14,6 +15,9 @@ export default async function loadEngines(appInstance, withServices = []) { for (let i = 0; i < extensions.length; i++) { const extension = extensions[i]; + if (isPluginExtension(extension)) { + continue; + } const path = dasherize(extension.extension); externalRoutes[path] = `console.${path}`; @@ -21,6 +25,9 @@ export default async function loadEngines(appInstance, withServices = []) { for (let i = 0; i < extensions.length; i++) { const extension = extensions[i]; + if (isPluginExtension(extension)) { + continue; + } engines[extension.name] = { dependencies: { diff --git a/addon/utils/map-engines.js b/addon/utils/map-engines.js index 50d8d939..4604b5dd 100644 --- a/addon/utils/map-engines.js +++ b/addon/utils/map-engines.js @@ -1,5 +1,6 @@ import { dasherize } from '@ember/string'; import hostServices from '../exports/host-services'; +import isPluginExtension from './is-plugin-extension'; export function getExtensionMountPath(extensionName) { let extensionNameSegments = extensionName.split('/'); @@ -31,8 +32,13 @@ export default function mapEngines(extensions, withServices = []) { notifications: 'console.notifications', }; + extensions = extensions || []; + for (let i = 0; i < extensions.length; i++) { const extension = extensions[i]; + if (isPluginExtension(extension)) { + continue; + } const route = routeNameFromExtension(extension); externalRoutes[route] = `console.${route}`; @@ -40,6 +46,9 @@ export default function mapEngines(extensions, withServices = []) { for (let i = 0; i < extensions.length; i++) { const extension = extensions[i]; + if (isPluginExtension(extension)) { + continue; + } engines[extension.name] = { dependencies: { diff --git a/app/services/universe/plugin-context.js b/app/services/universe/plugin-context.js new file mode 100644 index 00000000..6c712602 --- /dev/null +++ b/app/services/universe/plugin-context.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-core/services/universe/plugin-context'; diff --git a/app/utils/is-plugin-extension.js b/app/utils/is-plugin-extension.js new file mode 100644 index 00000000..71c1d630 --- /dev/null +++ b/app/utils/is-plugin-extension.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-core/utils/is-plugin-extension'; diff --git a/tests/unit/services/universe/extension-manager-test.js b/tests/unit/services/universe/extension-manager-test.js new file mode 100644 index 00000000..56aa00ae --- /dev/null +++ b/tests/unit/services/universe/extension-manager-test.js @@ -0,0 +1,30 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Service | universe/extension-manager', function (hooks) { + setupTest(hooks); + + test('plugin manifests are registered without being treated as engines during setup', async function (assert) { + const extensionManager = this.owner.lookup('service:universe/extension-manager'); + const universe = this.owner.lookup('service:universe'); + const application = this.owner.application; + + application.extensions = [ + { + name: '@fleetbase/partner-plugin', + type: 'plugin', + main: 'fleetbase.plugin.js', + }, + { + name: '@fleetbase/fleetops-engine', + }, + ]; + + extensionManager.finishLoadingExtensions(); + + await extensionManager.setupExtensions(this.owner, universe); + + assert.true(extensionManager.isExtensionInstalled('@fleetbase/partner-plugin')); + assert.true(extensionManager.isExtensionInstalled('@fleetbase/fleetops-engine')); + }); +}); diff --git a/tests/unit/services/universe/plugin-context-test.js b/tests/unit/services/universe/plugin-context-test.js new file mode 100644 index 00000000..5210ad42 --- /dev/null +++ b/tests/unit/services/universe/plugin-context-test.js @@ -0,0 +1,85 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Service | universe/plugin-context', function (hooks) { + setupTest(hooks); + + test('lookup allows approved host services', function (assert) { + const contextService = this.owner.lookup('service:universe/plugin-context'); + const context = contextService.create({ name: '@fleetbase/test-plugin' }, this.owner); + + assert.strictEqual(context.lookup('service:fetch'), this.owner.lookup('service:fetch')); + }); + + test('lookup rejects services outside the plugin allowlist', function (assert) { + const contextService = this.owner.lookup('service:universe/plugin-context'); + const context = contextService.create({ name: '@fleetbase/test-plugin' }, this.owner); + + assert.throws(() => context.lookup('service:plugin-private'), /not available to plugins/); + }); + + test('registerMenuItem stores a normalized menu item in the requested slot', function (assert) { + const contextService = this.owner.lookup('service:universe/plugin-context'); + const registry = this.owner.lookup('service:universe/registry-service'); + const context = contextService.create({ name: '@fleetbase/test-plugin' }, this.owner); + const handler = () => {}; + + const menuItem = context.registerMenuItem('fleet-ops.order.actions', { + id: 'send-to-partner', + label: 'Send to Partner', + icon: 'paper-plane', + action: handler, + }); + + const registered = registry.lookup('fleet-ops.order.actions', 'menu-item', 'send-to-partner'); + + assert.strictEqual(menuItem.title, 'Send to Partner'); + assert.strictEqual(registered.onClick, handler); + assert.strictEqual(registered._plugin, '@fleetbase/test-plugin'); + }); + + test('registerWidget stores widgets by slot and dashboard registry', function (assert) { + const contextService = this.owner.lookup('service:universe/plugin-context'); + const registry = this.owner.lookup('service:universe/registry-service'); + const widgetService = this.owner.lookup('service:universe/widget-service'); + const context = contextService.create({ name: '@fleetbase/test-plugin' }, this.owner); + + context.registerWidget('dashboard.summary', { + id: 'partner-status', + title: 'Partner Status', + component: () => Promise.resolve({ default: null }), + }); + + assert.strictEqual(registry.lookup('dashboard.summary', 'widget', 'partner-status').name, 'Partner Status'); + assert.strictEqual(widgetService.getWidget('dashboard.summary', 'partner-status').name, 'Partner Status'); + }); + + test('registerAction, registerHook, permissions, and events delegate to universe services', function (assert) { + assert.expect(7); + + const contextService = this.owner.lookup('service:universe/plugin-context'); + const registry = this.owner.lookup('service:universe/registry-service'); + const hookService = this.owner.lookup('service:universe/hook-service'); + const universe = this.owner.lookup('service:universe'); + const context = contextService.create({ name: '@fleetbase/test-plugin' }, this.owner, universe); + const actionHandler = () => {}; + const hookHandler = () => {}; + const eventHandler = (payload) => { + assert.strictEqual(payload.status, 'ok'); + }; + + context.registerAction('fleet-ops.order.actions', actionHandler, { id: 'sync-partner' }); + context.registerHook('order.sent', hookHandler); + context.registerPermission('partner.send-order'); + context.on('partner.ready', eventHandler); + context.emit('partner.ready', { status: 'ok' }); + context.off('partner.ready', eventHandler); + + assert.strictEqual(registry.lookup('fleet-ops.order.actions', 'action', 'sync-partner').handler, actionHandler); + assert.strictEqual(hookService.getHooks('order.sent')[0].handler, hookHandler); + assert.strictEqual(registry.lookup('plugin:permissions', 'permission', 'partner.send-order').permission, 'partner.send-order'); + assert.strictEqual(contextService.registeredPermissions[0].permission, 'partner.send-order'); + assert.strictEqual(registry.lookup('fleet-ops.order.actions', 'action', 'sync-partner')._plugin, '@fleetbase/test-plugin'); + assert.strictEqual(hookService.getHooks('order.sent')[0].id, '@fleetbase/test-plugin:order.sent'); + }); +}); diff --git a/tests/unit/utils/is-plugin-extension-test.js b/tests/unit/utils/is-plugin-extension-test.js new file mode 100644 index 00000000..b029d25d --- /dev/null +++ b/tests/unit/utils/is-plugin-extension-test.js @@ -0,0 +1,12 @@ +import isPluginExtension from 'dummy/utils/is-plugin-extension'; +import { module, test } from 'qunit'; + +module('Unit | Utility | is-plugin-extension', function () { + test('detects plugin manifest shapes', function (assert) { + assert.true(isPluginExtension({ type: 'plugin' })); + assert.true(isPluginExtension({ fleetbase: { type: 'plugin' } })); + assert.true(isPluginExtension({ fleetbase: { plugin: true } })); + assert.false(isPluginExtension({ name: '@fleetbase/fleetops-engine' })); + assert.false(isPluginExtension(null)); + }); +}); diff --git a/tests/unit/utils/map-engines-test.js b/tests/unit/utils/map-engines-test.js index fa5cd20f..258f8146 100644 --- a/tests/unit/utils/map-engines-test.js +++ b/tests/unit/utils/map-engines-test.js @@ -2,9 +2,23 @@ import mapEngines from 'dummy/utils/map-engines'; import { module, test } from 'qunit'; module('Unit | Utility | map-engines', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - let result = mapEngines(); - assert.ok(result); + test('it maps engine extensions and skips plugin manifests', function (assert) { + const result = mapEngines([ + { + name: '@fleetbase/fleetops-engine', + fleetbase: { + route: 'fleet-ops', + }, + }, + { + name: '@fleetbase/partner-plugin', + type: 'plugin', + main: 'fleetbase.plugin.js', + }, + ]); + + assert.ok(result['@fleetbase/fleetops-engine']); + assert.notOk(result['@fleetbase/partner-plugin']); + assert.strictEqual(result['@fleetbase/fleetops-engine'].dependencies.externalRoutes['fleet-ops'], 'console.fleet-ops'); }); });