@forgeportal/plugin-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/react.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * React hooks and context providers for use inside plugin UI components
3
+ * and the ForgePortal UI app shell.
4
+ * Import from: @forgeportal/plugin-sdk/react
5
+ */
6
+ import { useCallback } from 'react';
7
+ import { useQuery, type UseQueryOptions, type UseQueryResult } from '@tanstack/react-query';
8
+ import { useEntityContext, usePluginConfigContext } from './context.js';
9
+ import type { Entity } from './types.js';
10
+
11
+ // ─── Re-export React context providers for the app shell ─────────────────────
12
+ // These are used by EntityDetailPage and EntityOverviewTab to wrap plugin components.
13
+ export {
14
+ EntityProvider,
15
+ EntityContext,
16
+ PluginConfigProvider,
17
+ PluginConfigContext,
18
+ } from './context.js';
19
+
20
+ /**
21
+ * Returns the current entity from the entity detail page context.
22
+ * Must be used inside a component rendered as an EntityTab or EntityCard.
23
+ *
24
+ * @throws if called outside of an EntityProvider.
25
+ */
26
+ export function useEntity(): { entity: Entity } {
27
+ const ctx = useEntityContext();
28
+ if (!ctx) {
29
+ throw new Error(
30
+ '[ForgePortal SDK] useEntity() must be called inside an EntityTab or EntityCard component. ' +
31
+ 'Ensure this component is registered via sdk.registerEntityTab() or sdk.registerEntityCard().',
32
+ );
33
+ }
34
+ return ctx;
35
+ }
36
+
37
+ /**
38
+ * Returns the plugin-scoped config value for the given key.
39
+ * Config is sourced from `forgeportal.yaml` -> `plugins.<pluginId>.config`.
40
+ *
41
+ * @example
42
+ * const apiUrl = useConfig<string>('apiEndpoint');
43
+ */
44
+ export function useConfig<T = unknown>(key: string): T | undefined {
45
+ const config = usePluginConfigContext();
46
+ return config.get<T>(key);
47
+ }
48
+
49
+ /**
50
+ * Typed API fetcher backed by TanStack Query.
51
+ * Automatically includes credentials for session-based auth.
52
+ *
53
+ * @param path - Absolute API path, e.g. '/api/v1/entities'
54
+ * @param options - Optional TanStack Query options to override defaults
55
+ *
56
+ * @example
57
+ * const { data, isPending } = useApi<MyResponse[]>('/api/v1/my-plugin/data');
58
+ */
59
+ export function useApi<TData = unknown>(
60
+ path: string,
61
+ options?: Omit<UseQueryOptions<TData>, 'queryKey' | 'queryFn'>,
62
+ ): UseQueryResult<TData, Error> {
63
+ const queryFn = useCallback(async (): Promise<TData> => {
64
+ const res = await fetch(path, {
65
+ headers: { 'Accept': 'application/json' },
66
+ credentials: 'include',
67
+ });
68
+ if (!res.ok) {
69
+ const text = await res.text().catch(() => res.statusText);
70
+ throw new Error(`[ForgePortal SDK] API ${path} returned ${res.status}: ${text}`);
71
+ }
72
+ return res.json() as Promise<TData>;
73
+ }, [path]);
74
+
75
+ return useQuery<TData, Error>({
76
+ queryKey: ['forge-plugin-api', path],
77
+ queryFn,
78
+ ...options,
79
+ });
80
+ }
@@ -0,0 +1,103 @@
1
+ import type {
2
+ ForgePluginSDK,
3
+ EntityTab,
4
+ EntityCard,
5
+ Route,
6
+ ActionProvider,
7
+ CatalogProvider,
8
+ } from './types.js';
9
+
10
+ /**
11
+ * In-memory registry — the canonical implementation of ForgePluginSDK.
12
+ * Instantiated once per app (API or UI) at startup.
13
+ * Plugins receive this as the `sdk` argument in `registerPlugin(sdk)`.
14
+ */
15
+ export class PluginRegistry implements ForgePluginSDK {
16
+ private readonly _entityTabs = new Map<string, EntityTab>();
17
+ private readonly _entityCards = new Map<string, EntityCard>();
18
+ private readonly _routes = new Map<string, Route>();
19
+ private readonly _actionProviders = new Map<string, ActionProvider>();
20
+ private readonly _catalogProviders = new Map<string, CatalogProvider>();
21
+
22
+ registerEntityTab(tab: EntityTab): void {
23
+ if (this._entityTabs.has(tab.id)) {
24
+ console.warn(`[ForgePortal SDK] EntityTab "${tab.id}" already registered — skipping duplicate.`);
25
+ return;
26
+ }
27
+ this._entityTabs.set(tab.id, tab);
28
+ }
29
+
30
+ registerEntityCard(card: EntityCard): void {
31
+ if (this._entityCards.has(card.id)) {
32
+ console.warn(`[ForgePortal SDK] EntityCard "${card.id}" already registered — skipping duplicate.`);
33
+ return;
34
+ }
35
+ this._entityCards.set(card.id, card);
36
+ }
37
+
38
+ registerRoute(route: Route): void {
39
+ if (this._routes.has(route.path)) {
40
+ console.warn(`[ForgePortal SDK] Route "${route.path}" already registered — skipping duplicate.`);
41
+ return;
42
+ }
43
+ this._routes.set(route.path, route);
44
+ }
45
+
46
+ registerActionProvider(provider: ActionProvider): void {
47
+ const key = `${provider.id}@${provider.version}`;
48
+ if (this._actionProviders.has(key)) {
49
+ console.warn(`[ForgePortal SDK] ActionProvider "${key}" already registered — skipping duplicate.`);
50
+ return;
51
+ }
52
+ this._actionProviders.set(key, provider);
53
+ }
54
+
55
+ registerCatalogProvider(provider: CatalogProvider): void {
56
+ if (this._catalogProviders.has(provider.id)) {
57
+ console.warn(`[ForgePortal SDK] CatalogProvider "${provider.id}" already registered — skipping duplicate.`);
58
+ return;
59
+ }
60
+ this._catalogProviders.set(provider.id, provider);
61
+ }
62
+
63
+ // ─── Getters (used by the app shell to read registered capabilities) ────────
64
+
65
+ /**
66
+ * Returns entity tabs, optionally filtered by entity kind.
67
+ * Tabs with no `appliesTo.kinds` constraint match all kinds.
68
+ */
69
+ getEntityTabs(entityKind?: string): EntityTab[] {
70
+ const all = Array.from(this._entityTabs.values());
71
+ if (!entityKind) return all;
72
+ return all.filter(t =>
73
+ !t.appliesTo?.kinds || t.appliesTo.kinds.includes(entityKind),
74
+ );
75
+ }
76
+
77
+ getEntityCards(entityKind?: string): EntityCard[] {
78
+ const all = Array.from(this._entityCards.values());
79
+ if (!entityKind) return all;
80
+ return all.filter(c =>
81
+ !c.appliesTo?.kinds || c.appliesTo.kinds.includes(entityKind),
82
+ );
83
+ }
84
+
85
+ getRoutes(): Route[] {
86
+ return Array.from(this._routes.values());
87
+ }
88
+
89
+ getActionProviders(): ActionProvider[] {
90
+ return Array.from(this._actionProviders.values());
91
+ }
92
+
93
+ getActionProvider(id: string, version: string): ActionProvider | undefined {
94
+ return this._actionProviders.get(`${id}@${version}`);
95
+ }
96
+
97
+ getCatalogProviders(): CatalogProvider[] {
98
+ return Array.from(this._catalogProviders.values());
99
+ }
100
+ }
101
+
102
+ /** Global singleton — used by the app shell (API and UI). */
103
+ export const globalRegistry = new PluginRegistry();
package/src/types.ts ADDED
@@ -0,0 +1,221 @@
1
+ import type React from 'react';
2
+
3
+ // ─── Entity (subset of the catalog entity) ──────────────────────────────────
4
+
5
+ export interface Entity {
6
+ id: string;
7
+ kind: string;
8
+ namespace: string;
9
+ name: string;
10
+ title?: string;
11
+ description?: string;
12
+ tags?: string[];
13
+ links?: Array<{ title: string; url: string }>;
14
+ owner_ref?: string;
15
+ lifecycle?: string;
16
+ spec?: Record<string, unknown>;
17
+ }
18
+
19
+ export interface EntityDraft {
20
+ kind: string;
21
+ namespace: string;
22
+ name: string;
23
+ title?: string;
24
+ description?: string;
25
+ tags?: string[];
26
+ links?: Array<{ title: string; url: string }>;
27
+ owner_ref?: string;
28
+ lifecycle?: string;
29
+ spec?: Record<string, unknown>;
30
+ relations?: Array<{ type: string; targetRef: string }>;
31
+ sources?: Array<{ kind: string; url: string; ref?: string }>;
32
+ }
33
+
34
+ // ─── Capability types ────────────────────────────────────────────────────────
35
+
36
+ export interface EntityTabAppliesTo {
37
+ kinds?: string[]; // e.g. ['service', 'library'] — undefined = all kinds
38
+ lifecycle?: string[]; // e.g. ['production'] — undefined = all lifecycles
39
+ }
40
+
41
+ export interface EntityTab {
42
+ id: string;
43
+ title: string;
44
+ /** Rendered inside the entity detail page tab panel. Receives the current entity. */
45
+ component: React.ComponentType<{ entity: Entity }>;
46
+ appliesTo?: EntityTabAppliesTo;
47
+ }
48
+
49
+ export interface EntityCard {
50
+ id: string;
51
+ title: string;
52
+ /** Rendered as a card on the entity overview tab. */
53
+ component: React.ComponentType<{ entity: Entity }>;
54
+ appliesTo?: { kinds?: string[] };
55
+ }
56
+
57
+ export interface Route {
58
+ /** URL path, e.g. '/pagerduty'. Must be globally unique. */
59
+ path: string;
60
+ component: React.ComponentType;
61
+ /** If provided, appears in the sidebar navigation. */
62
+ navLabel?: string;
63
+ /** Optional icon name or emoji character. */
64
+ icon?: string;
65
+ }
66
+
67
+ // ─── JSON Schema (minimal subset for action input/output) ───────────────────
68
+
69
+ export type JsonSchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array';
70
+
71
+ export interface JsonSchema {
72
+ type: JsonSchemaType | JsonSchemaType[];
73
+ title?: string;
74
+ description?: string;
75
+ properties?: Record<string, JsonSchema>;
76
+ required?: string[];
77
+ items?: JsonSchema;
78
+ enum?: unknown[];
79
+ default?: unknown;
80
+ /** Set true to mark as secret — redacted in audit logs. */
81
+ 'x-secret'?: boolean;
82
+ }
83
+
84
+ // ─── Action Provider ─────────────────────────────────────────────────────────
85
+
86
+ export interface ActionResult {
87
+ status: 'success' | 'failed';
88
+ outputs: Record<string, unknown>;
89
+ links?: Array<{ title: string; url: string }>;
90
+ warnings?: string[];
91
+ error?: { code: string; message: string };
92
+ }
93
+
94
+ export interface ActionLogger {
95
+ info(message: string, meta?: Record<string, unknown>): void;
96
+ warn(message: string, meta?: Record<string, unknown>): void;
97
+ error(message: string, meta?: Record<string, unknown>): void;
98
+ }
99
+
100
+ export interface ActionScmAccessor {
101
+ /** Returns file content as UTF-8 string, or null if not found. */
102
+ getFile(repoUrl: string, path: string, ref?: string): Promise<string | null>;
103
+ /** Lists file paths under prefix (streaming). */
104
+ listFiles(repoUrl: string, prefix?: string): AsyncIterable<string>;
105
+ }
106
+
107
+ export interface ActionDbAccessor {
108
+ /** Execute a read-only SQL query. Throws if the query mutates data. */
109
+ query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
110
+ }
111
+
112
+ export interface ActionConfigAccessor {
113
+ /** Get a plugin-specific config value. Returns undefined if not set. */
114
+ get<T = unknown>(key: string): T | undefined;
115
+ }
116
+
117
+ /**
118
+ * Provided to every action handler at execution time.
119
+ * Exposes safe, scoped access to infrastructure services.
120
+ */
121
+ export interface ActionContext {
122
+ /** Plugin-scoped config from forgeportal.yaml plugins.<pluginId>.config */
123
+ config: ActionConfigAccessor;
124
+ /** Structured logger — writes to action run logs visible in the UI. */
125
+ logger: ActionLogger;
126
+ /** SCM operations (getFile, listFiles). */
127
+ scm: ActionScmAccessor;
128
+ /** Read-only database access. */
129
+ db: ActionDbAccessor;
130
+ /**
131
+ * Acquires an advisory lock on a repository URL.
132
+ * Prevents concurrent SCM writes to the same repo within this action run.
133
+ * Automatically released when the action run completes.
134
+ */
135
+ acquireRepoLock(repoUrl: string): Promise<void>;
136
+ /**
137
+ * Append a log line to the current action run log (persisted in DB,
138
+ * visible in the Actions UI).
139
+ */
140
+ log(level: 'debug' | 'info' | 'warn' | 'error', message: string): Promise<void>;
141
+ }
142
+
143
+ export interface ActionProvider {
144
+ /** Globally unique action ID, e.g. "slack.sendMessage". */
145
+ id: string;
146
+ /** Semantic version string, e.g. "v1". */
147
+ version: string;
148
+ schema: {
149
+ input: JsonSchema;
150
+ output?: JsonSchema;
151
+ };
152
+ handler(ctx: ActionContext, input: Record<string, unknown>): Promise<ActionResult>;
153
+ }
154
+
155
+ // ─── Catalog Provider ────────────────────────────────────────────────────────
156
+
157
+ export interface CatalogProviderContext {
158
+ logger: ActionLogger;
159
+ config: ActionConfigAccessor;
160
+ }
161
+
162
+ export interface CatalogProvider {
163
+ /** Globally unique provider ID, e.g. "pagerduty-catalog". */
164
+ id: string;
165
+ /** Called periodically by the worker to ingest entities from an external system. */
166
+ ingest(ctx: CatalogProviderContext): AsyncIterable<EntityDraft>;
167
+ }
168
+
169
+ // ─── Main SDK interface ──────────────────────────────────────────────────────
170
+
171
+ /**
172
+ * The main SDK object passed to every plugin's `registerPlugin` function.
173
+ * Plugins call `sdk.registerXxx(...)` to expose their capabilities to ForgePortal.
174
+ */
175
+ export interface ForgePluginSDK {
176
+ registerEntityTab(tab: EntityTab): void;
177
+ registerEntityCard(card: EntityCard): void;
178
+ registerRoute(route: Route): void;
179
+ registerActionProvider(provider: ActionProvider): void;
180
+ registerCatalogProvider(provider: CatalogProvider): void;
181
+ }
182
+
183
+ // ─── Plugin Manifest (forgeportal-plugin.json) ───────────────────────────────
184
+
185
+ export interface PluginConfigFieldSchema {
186
+ type: 'string' | 'number' | 'boolean';
187
+ description?: string;
188
+ required?: boolean;
189
+ /** If true, the value must come from an env var and is never logged. */
190
+ secret?: boolean;
191
+ default?: string | number | boolean;
192
+ }
193
+
194
+ export interface PluginCapabilities {
195
+ ui?: {
196
+ entityTabs?: string[];
197
+ entityCards?: string[];
198
+ routes?: string[];
199
+ };
200
+ backend?: {
201
+ routes?: string[]; // relative path prefixes
202
+ actionProviders?: string[]; // action IDs
203
+ catalogProviders?: string[]; // provider IDs
204
+ };
205
+ }
206
+
207
+ export interface PluginManifest {
208
+ /** npm package name, e.g. "@myorg/forge-plugin-pagerduty" */
209
+ name: string;
210
+ version: string;
211
+ forgeportal: {
212
+ /** semver range against @forgeportal/plugin-sdk version, e.g. "^1.0.0" */
213
+ engineVersion: string;
214
+ type: 'ui' | 'backend' | 'fullstack';
215
+ capabilities: PluginCapabilities;
216
+ /** Required RBAC permissions, e.g. ["action:run"]. */
217
+ permissions?: string[];
218
+ /** Config field schemas declared by the plugin. */
219
+ config?: Record<string, PluginConfigFieldSchema>;
220
+ };
221
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "jsx": "react-jsx",
7
+ "declaration": true,
8
+ "declarationMap": true
9
+ },
10
+ "include": ["src"]
11
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ globals: true,
7
+ include: ['__tests__/**/*.test.ts', '__tests__/**/*.test-d.ts'],
8
+ },
9
+ });