Skip to content

Commit a1fbed4

Browse files
author
Lasim
committed
feat: implement plugin support for global settings, allowing plugins to define and manage their own settings and groups
1 parent 4f163de commit a1fbed4

File tree

7 files changed

+411
-10
lines changed

7 files changed

+411
-10
lines changed

services/backend/GLOBAL_SETTINGS.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -501,9 +501,9 @@ const isSmtpReady = await GlobalSettingsInitService.isSmtpConfigured();
501501
const isGitHubReady = await GlobalSettingsInitService.isGitHubOAuthConfigured();
502502
```
503503

504-
### Adding New Setting Groups
504+
### Adding New Setting Groups (Core)
505505

506-
To add a new setting group:
506+
To add a new core setting group (managed directly by the application):
507507

508508
1. **Create Setting File**: Add a new `.ts` file in `src/global-settings/`
509509

@@ -691,6 +691,17 @@ The new group-based system replaces the old category-based approach. The migrati
691691
2. **Auto-Initialization**: Groups are created automatically from setting definitions
692692
3. **Setting Linking**: Existing settings are linked to appropriate groups
693693

694+
## Plugin-Contributed Global Settings
695+
696+
In addition to core global settings, plugins can also define and register their own global settings and setting groups. These are managed through the same system and are subject to the same access controls (i.e., editable by `global_admin`).
697+
698+
Key points for plugin-contributed settings:
699+
700+
- **Declaration**: Plugins declare global settings via a `globalSettingsExtension` property in their main class.
701+
- **Initialization**: The `PluginManager` processes these definitions at startup, creating new groups and settings if they don't already exist.
702+
- **Precedence**: Core global settings always take precedence. If a plugin tries to define a setting with a key that already exists (either from core or another plugin), the plugin's definition for that specific key is ignored.
703+
- **Documentation**: For details on how plugins can define global settings, refer to the [PLUGINS.MD](PLUGINS.MD) document.
704+
694705
## API Reference Summary
695706

696707
| Endpoint | Method | Permission | Description |

services/backend/PLUGINS.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ Plugins receive access to:
210210
- **Fastify instance** (`app`) - For registering routes, hooks, and decorations
211211
- **Database instance** (`db`) - For database operations
212212
- **Configuration** - Through the plugin manager (if provided)
213+
- **Global Settings** - Plugins can define their own global settings
213214

214215
## Plugin Lifecycle
215216

@@ -308,6 +309,112 @@ See the `plugins/example-plugin` directory for a working example.
308309

309310
The complete Plugin interface is defined in `src/plugin-system/types.ts`.
310311

312+
## Defining Global Settings via Plugins
313+
314+
Plugins can contribute their own global settings to the DeployStack system. These settings will be managed alongside core global settings and will be editable by users with the `global_admin` role.
315+
316+
### How it Works
317+
318+
1. **Define `globalSettingsExtension`**: In your plugin class, add an optional property `globalSettingsExtension`.
319+
2. **Structure**: This property should be an object implementing the `GlobalSettingsExtension` interface (defined in `src/plugin-system/types.ts`). It can contain:
320+
321+
- `groups`: An optional array of `GlobalSettingGroupForPlugin` objects to define new setting groups.
322+
- `settings`: A mandatory array of `GlobalSettingDefinitionForPlugin` objects to define individual settings.
323+
324+
3. **Initialization**: During server startup, the `PluginManager` will:
325+
326+
- Collect all group and setting definitions from active plugins.
327+
- Create any new groups defined by plugins if they don't already exist. If a group ID already exists, the plugin's group definition is ignored for that specific group, and the existing group is used.
328+
- Initialize the plugin's global settings with their default values, but only if a setting with the same key doesn't already exist (either from core settings or another plugin). Core settings always take precedence.
329+
330+
4. **Access Control**: All plugin-defined global settings are subject to the same access control as core settings (i.e., manageable by `global_admin`).
331+
332+
5. **Security**:
333+
334+
- **Core Precedence**: Core global settings (defined in `services/backend/src/global-settings/`) cannot be overridden by plugins.
335+
- **Duplicate Keys**: If a plugin attempts to register a setting with a key that already exists (from core or another plugin), the plugin's setting will be ignored, and a warning will be logged.
336+
337+
### Example: Defining Global Settings in a Plugin
338+
339+
```typescript
340+
// In your plugin's index.ts
341+
342+
import {
343+
type Plugin,
344+
type GlobalSettingsExtension,
345+
// ... other imports
346+
} from '../../plugin-system/types';
347+
348+
class MyAwesomePlugin implements Plugin {
349+
meta = {
350+
id: 'my-awesome-plugin',
351+
name: 'My Awesome Plugin',
352+
version: '1.0.0',
353+
// ... other metadata
354+
};
355+
356+
globalSettingsExtension: GlobalSettingsExtension = {
357+
groups: [
358+
{
359+
id: 'my_awesome_plugin_group', // Unique ID for the group
360+
name: 'My Awesome Plugin Config',
361+
description: 'Settings specific to My Awesome Plugin.',
362+
icon: 'settings-2', // Example: Lucide icon name
363+
sort_order: 150, // Controls tab order in UI
364+
}
365+
],
366+
settings: [
367+
{
368+
key: 'myAwesomePlugin.features.enableSuperFeature',
369+
defaultValue: 'true',
370+
description: 'Enables the super feature of this plugin.',
371+
encrypted: false,
372+
required: false,
373+
groupId: 'my_awesome_plugin_group', // Link to the group defined above
374+
},
375+
{
376+
key: 'myAwesomePlugin.credentials.externalApiKey',
377+
defaultValue: '',
378+
description: 'API key for an external service used by this plugin.',
379+
encrypted: true, // Sensitive value, will be encrypted
380+
required: true,
381+
groupId: 'my_awesome_plugin_group',
382+
},
383+
{
384+
// Example of a setting not belonging to a new custom group
385+
// It might appear in a default group or ungrouped in the UI,
386+
// or you can assign it to an existing core group ID if appropriate.
387+
key: 'myAwesomePlugin.performance.cacheDurationSeconds',
388+
defaultValue: '3600',
389+
description: 'Cache duration in seconds for plugin data.',
390+
encrypted: false,
391+
required: false,
392+
// groupId: 'system', // Example: if you want to add to an existing core group
393+
}
394+
]
395+
};
396+
397+
// ... rest of your plugin implementation (databaseExtension, initialize, etc.)
398+
async initialize(app: FastifyInstance, db: AnyDatabase | null) {
399+
console.log(`[${this.meta.id}] Initializing...`);
400+
401+
// You can try to access your plugin's settings here if needed during init,
402+
// using GlobalSettingsService.get('myAwesomePlugin.features.enableSuperFeature')
403+
// Note: Ensure GlobalSettingsService is available or handle potential errors.
404+
}
405+
}
406+
407+
export default MyAwesomePlugin;
408+
```
409+
410+
### Important Considerations
411+
412+
- **Key Uniqueness**: Ensure your setting keys are unique, preferably prefixed with your plugin ID (e.g., `yourPluginId.category.settingName`) to avoid conflicts.
413+
- **Group IDs**: If defining new groups, ensure their IDs are unique.
414+
- **Default Values**: Provide sensible default values.
415+
- **Encryption**: Mark sensitive settings (API keys, passwords) with `encrypted: true`.
416+
- **Documentation**: Document any global settings your plugin introduces in its own README or documentation.
417+
311418
---
312419

313420
For additional questions or support, please contact the DeployStack team or open an issue on GitHub.

services/backend/src/plugin-system/plugin-manager.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import {
99
type Plugin,
1010
type PluginPackage,
1111
type PluginConfiguration,
12-
type PluginOptions
12+
type PluginOptions,
13+
type GlobalSettingsExtension,
14+
type GlobalSettingDefinitionForPlugin,
15+
type GlobalSettingGroupForPlugin
1316
} from './types';
1417
import {
1518
PluginLoadError,
@@ -18,6 +21,7 @@ import {
1821
PluginDuplicateError,
1922
PluginNotFoundError
2023
} from './errors';
24+
import { GlobalSettingsService } from '../services/globalSettingsService';
2125

2226
/**
2327
* Plugin manager class responsible for loading and managing plugins
@@ -262,6 +266,106 @@ export class PluginManager {
262266
return this.getAllPlugins().filter(plugin => plugin.databaseExtension);
263267
}
264268

269+
/**
270+
* Get all global setting groups defined by plugins
271+
*/
272+
getPluginGlobalSettingGroups(): GlobalSettingGroupForPlugin[] {
273+
const allGroups: GlobalSettingGroupForPlugin[] = [];
274+
const groupIds = new Set<string>();
275+
276+
for (const plugin of this.plugins.values()) {
277+
if (plugin.globalSettingsExtension?.groups) {
278+
for (const group of plugin.globalSettingsExtension.groups) {
279+
if (!groupIds.has(group.id)) {
280+
allGroups.push(group);
281+
groupIds.add(group.id);
282+
} else {
283+
console.warn(`[PluginManager] Duplicate group ID '${group.id}' defined by plugin '${plugin.meta.id}'. Ignoring subsequent definition.`);
284+
}
285+
}
286+
}
287+
}
288+
return allGroups;
289+
}
290+
291+
/**
292+
* Get all global setting definitions from plugins
293+
*/
294+
getPluginGlobalSettingDefinitions(): { pluginId: string, definition: GlobalSettingDefinitionForPlugin }[] {
295+
const allDefinitions: { pluginId: string, definition: GlobalSettingDefinitionForPlugin }[] = [];
296+
for (const plugin of this.plugins.values()) {
297+
if (plugin.globalSettingsExtension?.settings) {
298+
plugin.globalSettingsExtension.settings.forEach(definition => {
299+
allDefinitions.push({ pluginId: plugin.meta.id, definition });
300+
});
301+
}
302+
}
303+
return allDefinitions;
304+
}
305+
306+
/**
307+
* Initialize global settings defined by plugins.
308+
* This should be called after core settings are initialized.
309+
*/
310+
async initializePluginGlobalSettings(): Promise<void> {
311+
console.log('[PluginManager] Initializing global settings from plugins...');
312+
const pluginGroups = this.getPluginGlobalSettingGroups();
313+
const pluginSettings = this.getPluginGlobalSettingDefinitions();
314+
315+
// Initialize groups first
316+
for (const group of pluginGroups) {
317+
try {
318+
// Check if group exists (this logic might need to be in GlobalSettingsService or InitService)
319+
// For now, we assume GlobalSettingsService.set will handle linking to existing group or we manage group creation here.
320+
// Let's try to create/ensure group exists. This is a simplified version.
321+
// A more robust solution would use a method like GlobalSettingsInitService.createGroup
322+
const existingGroup = await GlobalSettingsService.getGroup(group.id); // Assuming getGroup exists
323+
if (!existingGroup) {
324+
// Attempt to create the group if it doesn't exist.
325+
await GlobalSettingsService.createGroup(group);
326+
console.log(`[PluginManager] Created global setting group ID '${group.id}' (Name: "${group.name}") as defined by a plugin.`);
327+
} else {
328+
// Group ID already exists. Log that the plugin's definition for this group ID (name, description, etc.) is ignored.
329+
console.warn(`[PluginManager] Global setting group ID '${group.id}' already exists (Existing Name: "${existingGroup.name}"). Plugin's attempt to define a group with this ID (Plugin's proposed Name: "${group.name}") will use the existing group. Plugin-specific metadata for this group ID (name, description, icon, sort_order) is ignored.`);
330+
}
331+
} catch (error) {
332+
console.error(`[PluginManager] Error processing plugin-defined group '${group.id}' (Plugin's proposed Name: "${group.name}"):`, error);
333+
}
334+
}
335+
336+
// Initialize settings
337+
const initializedKeys = new Set<string>();
338+
// First, get all existing core setting keys to ensure precedence
339+
try {
340+
const coreSettings = await GlobalSettingsService.getAll();
341+
coreSettings.forEach(cs => initializedKeys.add(cs.key));
342+
} catch (error) {
343+
console.error('[PluginManager] Failed to get all core settings for precedence check:', error);
344+
// If this fails, we might risk overwriting, but proceed with caution.
345+
}
346+
347+
348+
for (const { pluginId, definition } of pluginSettings) {
349+
if (initializedKeys.has(definition.key)) {
350+
console.warn(`[PluginManager] Global setting key '${definition.key}' from plugin '${pluginId}' already exists (core or another plugin). Skipping.`);
351+
continue;
352+
}
353+
354+
try {
355+
await GlobalSettingsService.set(definition.key, definition.defaultValue, {
356+
description: definition.description,
357+
encrypted: definition.encrypted,
358+
group_id: definition.groupId,
359+
});
360+
initializedKeys.add(definition.key); // Add to set after successful initialization
361+
console.log(`[PluginManager] Initialized global setting '${definition.key}' from plugin '${pluginId}'.`);
362+
} catch (error) {
363+
console.error(`[PluginManager] Failed to initialize global setting '${definition.key}' from plugin '${pluginId}':`, error);
364+
}
365+
}
366+
console.log('[PluginManager] Plugin global settings initialization complete.');
367+
}
368+
265369
/**
266370
* Initialize all loaded plugins
267371
*/

services/backend/src/plugin-system/types.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,40 @@ import { type FastifyInstance } from 'fastify';
33
// import { type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; // Replaced by AnyDatabase
44
import { type AnyDatabase } from '../db'; // Import AnyDatabase
55

6+
/**
7+
* Definition for a global setting that can be provided by a plugin.
8+
* Mirrors parts of GlobalSettingDefinition from global-settings/types.ts
9+
* but is defined here to avoid circular dependencies.
10+
*/
11+
export interface GlobalSettingDefinitionForPlugin {
12+
key: string;
13+
defaultValue: string;
14+
description: string;
15+
encrypted: boolean;
16+
required?: boolean; // Optional: if the setting must have a value
17+
groupId?: string; // Optional: if this setting belongs to a specific group
18+
}
19+
20+
/**
21+
* Definition for a global setting group that can be provided by a plugin.
22+
* Mirrors GlobalSettingGroup from global-settings/types.ts
23+
*/
24+
export interface GlobalSettingGroupForPlugin {
25+
id: string;
26+
name: string;
27+
description?: string;
28+
icon?: string;
29+
sort_order?: number;
30+
}
31+
32+
/**
33+
* Interface for plugins to declare global settings and groups.
34+
*/
35+
export interface GlobalSettingsExtension {
36+
groups?: GlobalSettingGroupForPlugin[];
37+
settings: GlobalSettingDefinitionForPlugin[];
38+
}
39+
640
/**
741
* Plugin metadata interface
842
*/
@@ -49,6 +83,11 @@ export interface Plugin {
4983
* Optional database extension
5084
*/
5185
databaseExtension?: DatabaseExtension;
86+
87+
/**
88+
* Optional global settings extension
89+
*/
90+
globalSettingsExtension?: GlobalSettingsExtension;
5291

5392
/**
5493
* Initialize the plugin

services/backend/src/plugins/example-plugin/index.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
import { type Plugin, type DatabaseExtension } from '../../plugin-system/types';
2+
import {
3+
type Plugin,
4+
type DatabaseExtension,
5+
type GlobalSettingsExtension,
6+
type GlobalSettingDefinitionForPlugin,
7+
type GlobalSettingGroupForPlugin
8+
} from '../../plugin-system/types';
39
import { type FastifyInstance } from 'fastify';
410
import { type AnyDatabase, getSchema } from '../../db'; // Import getSchema
511
import { type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; // For type guard
@@ -35,6 +41,44 @@ class ExamplePlugin implements Plugin {
3541
description: 'An example plugin for DeployStack',
3642
author: 'DeployStack Team',
3743
};
44+
45+
// Define global settings provided by this plugin
46+
globalSettingsExtension: GlobalSettingsExtension = {
47+
groups: [
48+
{
49+
id: 'example_plugin_settings',
50+
name: 'Example Plugin Settings',
51+
description: 'Configuration for the Example Plugin.',
52+
icon: 'puzzle', // Example icon (Lucide icon name)
53+
sort_order: 100, // Example sort order
54+
},
55+
],
56+
settings: [
57+
{
58+
key: 'examplePlugin.config.featureEnabled',
59+
defaultValue: 'false',
60+
description: 'Enable or disable a specific feature in the example plugin.',
61+
encrypted: false,
62+
required: false,
63+
groupId: 'example_plugin_settings',
64+
},
65+
{
66+
key: 'examplePlugin.secret.apiKey',
67+
defaultValue: '',
68+
description: 'API Key for an external service used by the example plugin.',
69+
encrypted: true,
70+
required: false,
71+
groupId: 'example_plugin_settings',
72+
},
73+
{ // Example of a setting not in a custom group (will go to default or no group)
74+
key: 'examplePlugin.general.logLevel',
75+
defaultValue: 'info',
76+
description: 'Logging level for the example plugin.',
77+
encrypted: false,
78+
required: false,
79+
}
80+
],
81+
};
3882

3983
// Database extension
4084
databaseExtension: DatabaseExtension = {
@@ -43,7 +87,7 @@ class ExamplePlugin implements Plugin {
4387
// Optional initialization function
4488
// Use arrow function to correctly capture 'this' for access to this.meta.id
4589
onDatabaseInit: async (db: AnyDatabase) => {
46-
console.log('Initializing example plugin database...');
90+
console.log(`[${this.meta.id}] Initializing example plugin database...`);
4791

4892
const currentSchema = getSchema();
4993
// 'this' here refers to the ExamplePlugin instance because of the arrow function

0 commit comments

Comments
 (0)