diff --git a/.changeset/config.json b/.changeset/config.json index 1dc4983653..fe2fffd692 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -22,6 +22,7 @@ "@objectstack/plugin-msw", "@objectstack/plugin-dev", "@objectstack/plugin-security", + "@objectstack/plugin-setup", "@objectstack/express", "@objectstack/fastify", "@objectstack/hono", diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a282869b..8214fc56b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`@objectstack/plugin-setup` — Platform Setup App plugin** — New internal plugin + (`packages/plugins/plugin-setup`) that owns and finalizes the platform Setup App. + Ships four built-in Setup Areas (Administration, Platform, System, AI) as empty + skeletons. Other plugins contribute navigation items via the `setupNav` service + during their `init` phase. At `start`, SetupPlugin merges all contributions, + filters out empty areas, and registers the finalized Setup App as an internal + platform app. This establishes clear architectural separation: **spec** = protocol + only, **objectql** = data/query only, **plugins** = system feature and UI composition. + ### Documentation - **Unified API query syntax documentation with Spec canonical format** — Rewrote `content/docs/protocol/objectql/query-syntax.mdx` and diff --git a/ROADMAP.md b/ROADMAP.md index 78975587d1..00cd474f50 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,6 +53,7 @@ the ecosystem for enterprise workloads. | Authentication (better-auth) | ✅ | `@objectstack/plugin-auth` | | Auth in MSW/Mock Mode | ✅ | `@objectstack/plugin-auth` + `@objectstack/runtime` | | RBAC / RLS / FLS Security | ✅ | `@objectstack/plugin-security` | +| Platform Setup App | ✅ | `@objectstack/plugin-setup` | | CLI (16 commands) | ✅ | `@objectstack/cli` | | Dev Mode Plugin | ✅ | `@objectstack/plugin-dev` | | Next.js Adapter | ✅ | `@objectstack/nextjs` | @@ -900,6 +901,7 @@ Final polish and advanced features. | `@objectstack/driver-memory` | 3.0.8 | ✅ | ✅ Stable | 9/10 | | `@objectstack/plugin-auth` | 3.0.8 | ✅ | ✅ Stable | 9/10 | | `@objectstack/plugin-security` | 3.0.8 | — | ✅ Stable | 9/10 | +| `@objectstack/plugin-setup` | 3.3.1 | ✅ | ✅ Stable | 8/10 | | `@objectstack/plugin-dev` | 3.0.8 | — | ✅ Stable | 10/10 | | `@objectstack/plugin-hono-server` | 3.0.8 | — | ✅ Stable | 9/10 | | `@objectstack/plugin-msw` | 3.0.8 | — | ✅ Stable | 9/10 | diff --git a/packages/plugins/plugin-setup/CHANGELOG.md b/packages/plugins/plugin-setup/CHANGELOG.md new file mode 100644 index 0000000000..8a5f10fc5c --- /dev/null +++ b/packages/plugins/plugin-setup/CHANGELOG.md @@ -0,0 +1,11 @@ +# @objectstack/plugin-setup + +## 3.3.1 + +### Added + +- Initial release of the Setup Plugin. +- Defines the platform Setup App identity (name, label, icon, permissions, branding). +- Ships 4 built-in Setup Areas: Administration, Platform, System, AI. +- Provides `setupNav` service for contribution-based navigation composition. +- Auto-filters empty areas and supports custom area extensions. diff --git a/packages/plugins/plugin-setup/README.md b/packages/plugins/plugin-setup/README.md new file mode 100644 index 0000000000..b3eeb403a6 --- /dev/null +++ b/packages/plugins/plugin-setup/README.md @@ -0,0 +1,110 @@ +# @objectstack/plugin-setup + +Setup Plugin for ObjectStack — owns and composes the platform **Setup App** with area-based navigation. + +## Overview + +The Setup App is the central administration interface of the ObjectStack platform (equivalent to Salesforce Setup or ServiceNow System Administration). Rather than scattering setup definitions across `spec` and `objectql`, this plugin provides clear ownership: + +- **Spec** → protocol schemas only +- **ObjectQL** → data engine only +- **plugin-setup** → owns the Setup App identity, areas, and navigation composition + +## Features + +- **Four Built-in Areas**: Administration, Platform, System, and AI — shipped as empty skeletons. +- **Contribution Model**: Any plugin can contribute navigation items to Setup areas via the `setupNav` service. +- **Area Filtering**: Empty areas are automatically filtered out at finalization. +- **Custom Areas**: Plugins can contribute to custom area IDs beyond the four built-in ones. +- **I18n Labels**: All labels use the `I18nLabel` union type for internationalization. + +## Usage + +### Register the Plugin + +```typescript +import { ObjectKernel } from '@objectstack/core'; +import { SetupPlugin } from '@objectstack/plugin-setup'; + +const kernel = new ObjectKernel({ + plugins: [ + new SetupPlugin(), + // ... other plugins + ], +}); +``` + +### Contribute Navigation from Another Plugin + +```typescript +import type { Plugin, PluginContext } from '@objectstack/core'; +import type { SetupNavService } from '@objectstack/plugin-setup'; +import { SETUP_AREA_IDS } from '@objectstack/plugin-setup'; + +export class MyPlugin implements Plugin { + name = 'com.example.my-plugin'; + + async init(ctx: PluginContext) { + const setupNav = ctx.getService('setupNav'); + + setupNav.contribute({ + areaId: SETUP_AREA_IDS.administration, + items: [ + { id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user' }, + { id: 'nav_roles', type: 'object', label: 'Roles', objectName: 'sys_role' }, + ], + }); + } +} +``` + +### Exported Components + +```typescript +import { + SetupPlugin, + type SetupNavService, + SETUP_APP_DEFAULTS, + type SetupNavContribution, + SETUP_AREAS, + SETUP_AREA_IDS, + type SetupAreaId, +} from '@objectstack/plugin-setup'; +``` + +## Built-in Setup Areas + +| Area | ID | Icon | Order | Description | +|:-----|:---|:-----|:-----:|:------------| +| Administration | `area_administration` | shield | 10 | Users, roles, permissions, security | +| Platform | `area_platform` | layers | 20 | Objects, fields, layouts, automation | +| System | `area_system` | settings | 30 | Datasources, integrations, jobs, logs | +| AI | `area_ai` | brain | 40 | Agents, models, RAG pipelines | + +## Architecture + +``` +┌──────────────────────────────────────────┐ +│ SetupPlugin │ +│ │ +│ init(): │ +│ → registers 'setupNav' service │ +│ │ +│ start(): │ +│ → collects contributions │ +│ → merges into area skeletons │ +│ → filters empty areas │ +│ → registers finalized Setup App │ +│ │ +└──────────────────────────────────────────┘ + ▲ ▲ + │ contribute() │ contribute() + ┌────┴────┐ ┌────┴────┐ + │ plugin │ │ plugin │ + │ auth │ │security │ + └─────────┘ └─────────┘ +``` + +## License + +Apache-2.0 © ObjectStack diff --git a/packages/plugins/plugin-setup/objectstack.config.ts b/packages/plugins/plugin-setup/objectstack.config.ts new file mode 100644 index 0000000000..3e1deaf244 --- /dev/null +++ b/packages/plugins/plugin-setup/objectstack.config.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineStack } from '@objectstack/spec'; + +/** + * ObjectStack Configuration for plugin-setup + * + * This configuration defines the manifest for the platform Setup plugin. + * The Setup App itself is composed at runtime by collecting setupNav + * contributions from all registered plugins. + */ +export default defineStack({ + manifest: { + id: 'com.objectstack.plugin-setup', + namespace: 'setup', + version: '3.3.1', + type: 'plugin', + name: 'Platform Setup Plugin', + description: 'Owns and composes the platform Setup App with area-based navigation contributed by other plugins', + }, +}); diff --git a/packages/plugins/plugin-setup/package.json b/packages/plugins/plugin-setup/package.json new file mode 100644 index 0000000000..0bf69ba2ab --- /dev/null +++ b/packages/plugins/plugin-setup/package.json @@ -0,0 +1,28 @@ +{ + "name": "@objectstack/plugin-setup", + "version": "3.3.1", + "license": "Apache-2.0", + "description": "Setup Plugin for ObjectStack — Platform Setup App with area-based navigation composition", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^6.0.2", + "vitest": "^4.1.2" + } +} diff --git a/packages/plugins/plugin-setup/src/index.ts b/packages/plugins/plugin-setup/src/index.ts new file mode 100644 index 0000000000..81fdc03392 --- /dev/null +++ b/packages/plugins/plugin-setup/src/index.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/plugin-setup + * + * Setup Plugin for ObjectStack — owns and composes the platform Setup App. + * Other plugins contribute navigation items via the `setupNav` service. + */ + +export { SetupPlugin, type SetupNavService } from './setup-plugin.js'; +export { SETUP_APP_DEFAULTS, type SetupNavContribution } from './setup-app.js'; +export { SETUP_AREAS, SETUP_AREA_IDS, type SetupAreaId } from './setup-areas.js'; diff --git a/packages/plugins/plugin-setup/src/setup-app.ts b/packages/plugins/plugin-setup/src/setup-app.ts new file mode 100644 index 0000000000..e401b8420e --- /dev/null +++ b/packages/plugins/plugin-setup/src/setup-app.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { App, NavigationArea, NavigationItem } from '@objectstack/spec/ui'; + +/** + * Default Setup App definition. + * + * This is the base identity of the platform Setup application. + * At runtime the `SetupPlugin` clones this definition, injects + * the merged navigation areas contributed by other plugins, + * and registers the final app. + */ +export const SETUP_APP_DEFAULTS: Omit & { areas: NavigationArea[] } = { + name: 'setup', + label: { + key: 'setup.app.label', + defaultValue: 'Setup', + }, + description: { + key: 'setup.app.description', + defaultValue: 'Platform settings and administration', + }, + icon: 'settings', + active: true, + isDefault: false, + branding: { + primaryColor: '#475569', // Slate-600 — neutral admin palette + }, + requiredPermissions: ['setup.access'], + areas: [], +}; + +/** + * Navigation contribution that a plugin registers via the + * `setupNav` service convention during kernel init. + * + * Each contribution targets a specific area by its ID and provides + * one or more navigation items (or groups) to merge into that area. + */ +export interface SetupNavContribution { + /** Target area ID (e.g. `area_administration`). */ + areaId: string; + + /** Navigation items to contribute to the target area. */ + items: NavigationItem[]; +} diff --git a/packages/plugins/plugin-setup/src/setup-areas.ts b/packages/plugins/plugin-setup/src/setup-areas.ts new file mode 100644 index 0000000000..ac78fd89a8 --- /dev/null +++ b/packages/plugins/plugin-setup/src/setup-areas.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { NavigationArea } from '@objectstack/spec/ui'; + +/** + * Well-known Setup Area IDs. + * + * Every internal or third-party plugin that wants to contribute settings + * navigation uses one of these area IDs (or defines a custom one). + */ +export const SETUP_AREA_IDS = { + administration: 'area_administration', + platform: 'area_platform', + system: 'area_system', + ai: 'area_ai', +} as const; + +export type SetupAreaId = (typeof SETUP_AREA_IDS)[keyof typeof SETUP_AREA_IDS]; + +/** + * Built-in Setup Areas — empty skeletons. + * + * These are the four default areas that ship with the platform. + * Other plugins contribute navigation items into these areas + * via the `setupNav` service convention during kernel init. + * + * At finalization time, empty areas (no contributed navigation items) + * are automatically filtered out so the Setup App only shows + * areas that actually have content. + */ +export const SETUP_AREAS: readonly NavigationArea[] = [ + { + id: SETUP_AREA_IDS.administration, + label: { + key: 'setup.areas.administration', + defaultValue: 'Administration', + }, + icon: 'shield', + order: 10, + description: { + key: 'setup.areas.administration.description', + defaultValue: 'User management, roles, permissions, and security settings', + }, + navigation: [], + }, + { + id: SETUP_AREA_IDS.platform, + label: { + key: 'setup.areas.platform', + defaultValue: 'Platform', + }, + icon: 'layers', + order: 20, + description: { + key: 'setup.areas.platform.description', + defaultValue: 'Objects, fields, layouts, automation, and extensibility settings', + }, + navigation: [], + }, + { + id: SETUP_AREA_IDS.system, + label: { + key: 'setup.areas.system', + defaultValue: 'System', + }, + icon: 'settings', + order: 30, + description: { + key: 'setup.areas.system.description', + defaultValue: 'Datasources, integrations, jobs, logs, and environment configuration', + }, + navigation: [], + }, + { + id: SETUP_AREA_IDS.ai, + label: { + key: 'setup.areas.ai', + defaultValue: 'AI', + }, + icon: 'brain', + order: 40, + description: { + key: 'setup.areas.ai.description', + defaultValue: 'AI agents, model registry, RAG pipelines, and intelligence settings', + }, + navigation: [], + }, +] as const; diff --git a/packages/plugins/plugin-setup/src/setup-plugin.test.ts b/packages/plugins/plugin-setup/src/setup-plugin.test.ts new file mode 100644 index 0000000000..997d4f4c96 --- /dev/null +++ b/packages/plugins/plugin-setup/src/setup-plugin.test.ts @@ -0,0 +1,310 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SetupPlugin } from './setup-plugin.js'; +import { SETUP_AREAS, SETUP_AREA_IDS } from './setup-areas.js'; +import { SETUP_APP_DEFAULTS } from './setup-app.js'; +import type { SetupNavContribution } from './setup-app.js'; + +// --------------------------------------------------------------------------- +// Helper — mock PluginContext +// --------------------------------------------------------------------------- +function createMockContext() { + const services = new Map(); + return { + logger: { + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, + registerService: vi.fn((name: string, svc: any) => { + services.set(name, svc); + }), + getService: vi.fn((name: string) => services.get(name)), + services, + } as any; +} + +// --------------------------------------------------------------------------- +// Setup Areas — constants +// --------------------------------------------------------------------------- +describe('SETUP_AREAS', () => { + it('should define exactly 4 built-in areas', () => { + expect(SETUP_AREAS).toHaveLength(4); + }); + + it('should have well-known IDs', () => { + const ids = SETUP_AREAS.map((a) => a.id); + expect(ids).toContain(SETUP_AREA_IDS.administration); + expect(ids).toContain(SETUP_AREA_IDS.platform); + expect(ids).toContain(SETUP_AREA_IDS.system); + expect(ids).toContain(SETUP_AREA_IDS.ai); + }); + + it('should have empty navigation arrays by default', () => { + for (const area of SETUP_AREAS) { + expect(area.navigation).toEqual([]); + } + }); + + it('should be sorted by ascending order', () => { + const orders = SETUP_AREAS.map((a) => a.order!); + for (let i = 1; i < orders.length; i++) { + expect(orders[i]).toBeGreaterThan(orders[i - 1]); + } + }); + + it('each area should have i18n labels', () => { + for (const area of SETUP_AREAS) { + expect(typeof area.label).toBe('object'); + expect((area.label as any).key).toBeDefined(); + expect((area.label as any).defaultValue).toBeDefined(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Setup App defaults +// --------------------------------------------------------------------------- +describe('SETUP_APP_DEFAULTS', () => { + it('should have name "setup"', () => { + expect(SETUP_APP_DEFAULTS.name).toBe('setup'); + }); + + it('should have i18n label', () => { + expect(typeof SETUP_APP_DEFAULTS.label).toBe('object'); + expect((SETUP_APP_DEFAULTS.label as any).defaultValue).toBe('Setup'); + }); + + it('should require setup.access permission', () => { + expect(SETUP_APP_DEFAULTS.requiredPermissions).toContain('setup.access'); + }); + + it('should use the settings icon', () => { + expect(SETUP_APP_DEFAULTS.icon).toBe('settings'); + }); +}); + +// --------------------------------------------------------------------------- +// SetupPlugin — metadata +// --------------------------------------------------------------------------- +describe('SetupPlugin', () => { + let plugin: SetupPlugin; + let ctx: ReturnType; + + beforeEach(() => { + plugin = new SetupPlugin(); + ctx = createMockContext(); + }); + + it('should have correct metadata', () => { + expect(plugin.name).toBe('com.objectstack.setup'); + expect(plugin.type).toBe('standard'); + expect(plugin.version).toBe('1.0.0'); + }); + + // ─── init ────────────────────────────────────────────────────── + + it('should register the setupNav service during init', async () => { + await plugin.init(ctx); + + expect(ctx.registerService).toHaveBeenCalledWith( + 'setupNav', + expect.objectContaining({ contribute: expect.any(Function) }), + ); + }); + + // ─── start — no contributions ────────────────────────────────── + + it('should register the Setup App with no areas when no contributions', async () => { + await plugin.init(ctx); + await plugin.start(ctx); + + expect(ctx.registerService).toHaveBeenCalledWith( + 'app.com.objectstack.setup', + expect.objectContaining({ + id: 'com.objectstack.setup', + apps: expect.arrayContaining([ + expect.objectContaining({ name: 'setup' }), + ]), + }), + ); + + // No areas should be present — all areas are empty. + const call = ctx.registerService.mock.calls.find( + (c: any[]) => c[0] === 'app.com.objectstack.setup', + ); + const app = call[1].apps[0]; + expect(app.areas).toBeUndefined(); + }); + + // ─── start — with contributions ──────────────────────────────── + + it('should merge contributions into correct areas', async () => { + await plugin.init(ctx); + + // Simulate another plugin contributing nav items. + const setupNav = ctx.services.get('setupNav'); + setupNav.contribute({ + areaId: SETUP_AREA_IDS.administration, + items: [ + { id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user' }, + ], + }); + setupNav.contribute({ + areaId: SETUP_AREA_IDS.ai, + items: [ + { id: 'nav_agents', type: 'object', label: 'Agents', objectName: 'sys_agent' }, + ], + }); + + await plugin.start(ctx); + + const call = ctx.registerService.mock.calls.find( + (c: any[]) => c[0] === 'app.com.objectstack.setup', + ); + const app = call[1].apps[0]; + + // Only non-empty areas should be present. + expect(app.areas).toHaveLength(2); + expect(app.areas[0].id).toBe(SETUP_AREA_IDS.administration); + expect(app.areas[0].navigation).toHaveLength(1); + expect(app.areas[1].id).toBe(SETUP_AREA_IDS.ai); + expect(app.areas[1].navigation).toHaveLength(1); + }); + + it('should support custom (unknown) area contributions', async () => { + await plugin.init(ctx); + + const setupNav = ctx.services.get('setupNav'); + setupNav.contribute({ + areaId: 'area_custom_billing', + items: [ + { id: 'nav_billing', type: 'page', label: 'Billing', pageName: 'page_billing' }, + ], + }); + + await plugin.start(ctx); + + const call = ctx.registerService.mock.calls.find( + (c: any[]) => c[0] === 'app.com.objectstack.setup', + ); + const app = call[1].apps[0]; + expect(app.areas).toHaveLength(1); + expect(app.areas[0].id).toBe('area_custom_billing'); + expect(app.areas[0].navigation).toHaveLength(1); + }); + + it('should sort areas by order', async () => { + await plugin.init(ctx); + + const setupNav = ctx.services.get('setupNav'); + // Contribute to system (order 30) and administration (order 10). + setupNav.contribute({ + areaId: SETUP_AREA_IDS.system, + items: [{ id: 'nav_logs', type: 'page', label: 'Logs', pageName: 'page_logs' }], + }); + setupNav.contribute({ + areaId: SETUP_AREA_IDS.administration, + items: [{ id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user' }], + }); + + await plugin.start(ctx); + + const call = ctx.registerService.mock.calls.find( + (c: any[]) => c[0] === 'app.com.objectstack.setup', + ); + const app = call[1].apps[0]; + // Administration (order 10) should come before System (order 30). + expect(app.areas[0].id).toBe(SETUP_AREA_IDS.administration); + expect(app.areas[1].id).toBe(SETUP_AREA_IDS.system); + }); + + it('should accumulate multiple contributions to the same area', async () => { + await plugin.init(ctx); + + const setupNav = ctx.services.get('setupNav'); + setupNav.contribute({ + areaId: SETUP_AREA_IDS.platform, + items: [{ id: 'nav_objects', type: 'page', label: 'Objects', pageName: 'page_objects' }], + }); + setupNav.contribute({ + areaId: SETUP_AREA_IDS.platform, + items: [{ id: 'nav_fields', type: 'page', label: 'Fields', pageName: 'page_fields' }], + }); + + await plugin.start(ctx); + + const call = ctx.registerService.mock.calls.find( + (c: any[]) => c[0] === 'app.com.objectstack.setup', + ); + const app = call[1].apps[0]; + const platformArea = app.areas.find((a: any) => a.id === SETUP_AREA_IDS.platform); + expect(platformArea).toBeDefined(); + expect(platformArea!.navigation).toHaveLength(2); + }); + + // ─── destroy ─────────────────────────────────────────────────── + + it('should clean up contributions on destroy', async () => { + await plugin.init(ctx); + + const setupNav = ctx.services.get('setupNav'); + setupNav.contribute({ + areaId: SETUP_AREA_IDS.administration, + items: [{ id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user' }], + }); + + await plugin.destroy(); + + // After destroy + re-start, contributions should be empty. + const ctx2 = createMockContext(); + await plugin.init(ctx2); + await plugin.start(ctx2); + + const call = ctx2.registerService.mock.calls.find( + (c: any[]) => c[0] === 'app.com.objectstack.setup', + ); + const app = call[1].apps[0]; + expect(app.areas).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// mergeAreas — unit tests +// --------------------------------------------------------------------------- +describe('SetupPlugin.mergeAreas', () => { + it('should return empty array when no contributions', () => { + const plugin = new SetupPlugin(); + expect(plugin.mergeAreas([])).toEqual([]); + }); + + it('should not mutate the SETUP_AREAS constant', () => { + const plugin = new SetupPlugin(); + const navBefore = SETUP_AREAS.map((a) => [...a.navigation]); + + plugin.mergeAreas([ + { + areaId: SETUP_AREA_IDS.administration, + items: [{ id: 'nav_x', type: 'page', label: 'X', pageName: 'x' }], + }, + ]); + + const navAfter = SETUP_AREAS.map((a) => [...a.navigation]); + expect(navAfter).toEqual(navBefore); + }); + + it('should filter out empty areas', () => { + const plugin = new SetupPlugin(); + const result = plugin.mergeAreas([ + { + areaId: SETUP_AREA_IDS.platform, + items: [{ id: 'nav_obj', type: 'page', label: 'Obj', pageName: 'p' }], + }, + ]); + // Only platform area should be returned. + expect(result).toHaveLength(1); + expect(result[0].id).toBe(SETUP_AREA_IDS.platform); + }); +}); diff --git a/packages/plugins/plugin-setup/src/setup-plugin.ts b/packages/plugins/plugin-setup/src/setup-plugin.ts new file mode 100644 index 0000000000..47834acd4e --- /dev/null +++ b/packages/plugins/plugin-setup/src/setup-plugin.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import type { App, NavigationArea } from '@objectstack/spec/ui'; +import { SETUP_AREAS } from './setup-areas.js'; +import { SETUP_APP_DEFAULTS, type SetupNavContribution } from './setup-app.js'; + +/** + * SetupPlugin + * + * Owns and finalizes the platform **Setup App**. + * + * ### Lifecycle + * + * 1. **init** — Registers the `setupNav` service (a contribution collector) + * so that other plugins can call `ctx.getService('setupNav').contribute(…)` + * to add navigation items to the Setup App. + * + * 2. **start** — Collects all contributions, merges them into the four + * built-in Setup Areas, filters out empty areas, and registers the + * finalized Setup App as an internal platform app via the + * `app.com.objectstack.setup` service convention. + * + * ### Extension Model + * + * Any plugin that wants to appear in the Setup sidebar should: + * ```ts + * async init(ctx: PluginContext) { + * const setupNav = ctx.getService('setupNav'); + * setupNav.contribute({ + * areaId: 'area_administration', + * items: [ + * { id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user' }, + * ], + * }); + * } + * ``` + */ +export class SetupPlugin implements Plugin { + name = 'com.objectstack.setup'; + type = 'standard'; + version = '1.0.0'; + + /** Accumulated contributions from other plugins. */ + private contributions: SetupNavContribution[] = []; + + // ─── Plugin Lifecycle ──────────────────────────────────────────── + + async init(ctx: PluginContext): Promise { + ctx.logger.info('Initializing Setup Plugin...'); + + // Expose the contribution API as a service so other plugins + // can add navigation items during their own init phase. + ctx.registerService('setupNav', { + /** + * Register navigation items for a Setup area. + */ + contribute: (contribution: SetupNavContribution): void => { + this.contributions.push(contribution); + }, + }); + + ctx.logger.info('Setup Plugin initialized — setupNav service registered'); + } + + async start(ctx: PluginContext): Promise { + ctx.logger.info('Starting Setup Plugin — finalizing Setup App...'); + + // Merge contributions into area skeletons. + const areas = this.mergeAreas(this.contributions); + + // Build the final Setup App. + const setupApp: App = { + ...SETUP_APP_DEFAULTS, + areas: areas.length > 0 ? areas : undefined, + }; + + // Register the finalized Setup App as an internal platform app + // following the `app.` service convention used by ObjectQLPlugin. + ctx.registerService('app.com.objectstack.setup', { + id: 'com.objectstack.setup', + name: 'Setup', + version: '1.0.0', + type: 'plugin', + namespace: 'sys', + objects: [], + apps: [setupApp], + }); + + ctx.logger.info( + `Setup App registered with ${areas.length} area(s) and ` + + `${this.contributions.length} contribution(s)`, + ); + } + + async destroy(): Promise { + this.contributions = []; + } + + // ─── Internal Helpers ──────────────────────────────────────────── + + /** + * Merge all navigation contributions into the built-in Setup Area skeletons. + * + * - Contributions targeting a known area are appended to that area's + * navigation array. + * - Contributions targeting an unknown area ID create a new custom area. + * - Areas that remain empty after merging are filtered out. + * - The resulting array is sorted by `order` (ascending). + */ + mergeAreas(contributions: SetupNavContribution[]): NavigationArea[] { + // Clone area skeletons so we never mutate the constant. + const areaMap = new Map( + SETUP_AREAS.map((a) => [a.id, { ...a, navigation: [...a.navigation] }]), + ); + + for (const c of contributions) { + const existing = areaMap.get(c.areaId); + if (existing) { + existing.navigation.push(...c.items); + } else { + // Custom area contributed by a third-party plugin. + areaMap.set(c.areaId, { + id: c.areaId, + label: c.areaId, + order: 100, + navigation: [...c.items], + }); + } + } + + // Filter out empty areas and sort by order. + return Array.from(areaMap.values()) + .filter((a) => a.navigation.length > 0) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + } +} + +/** + * Public type for the `setupNav` service exposed by SetupPlugin. + */ +export interface SetupNavService { + contribute(contribution: SetupNavContribution): void; +} diff --git a/packages/plugins/plugin-setup/tsconfig.json b/packages/plugins/plugin-setup/tsconfig.json new file mode 100644 index 0000000000..f6a1e8bad5 --- /dev/null +++ b/packages/plugins/plugin-setup/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "dist", + "node_modules", + "**/*.test.ts" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b1ce7c045..2eb4ceca5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -962,6 +962,25 @@ importers: specifier: ^4.1.2 version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + packages/plugins/plugin-setup: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../../core + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@types/node': + specifier: ^25.5.0 + version: 25.5.0 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vitest: + specifier: ^4.1.2 + version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + packages/rest: dependencies: '@objectstack/core':