Skip to content

Commit ce18157

Browse files
authored
Make plugin system fully dynamic with auto-generated registries (#78)
* Make plugin system fully dynamic with auto-generated registries This refactors the codebase to use the plugin registry as the single source of truth, eliminating hardcoded integration/action data throughout. New plugins now automatically work everywhere without manual updates to core files. Key changes: **Auto-generated files (via discover-plugins.ts):** - lib/types/integration.ts - IntegrationType union and IntegrationConfig types - lib/step-registry.ts - Step importers for workflow execution (statically analyzable) - plugins/index.ts - Plugin imports and registry exports - README.md - Auto-updated plugins list **Dynamic plugin registry usage:** - workflow-executor.workflow.ts - Uses step-registry for dynamic step imports - workflow-codegen-shared.ts - getStepInfo() uses findActionById() - workflow-codegen-sdk.ts - loadStepImplementation() uses plugin codegenTemplates - workflow-codegen.ts - Helper functions use getStepInfo() consistently - credential-fetcher.ts - Uses plugin credentialMapping functions - code-generators.ts - Uses getAllActions() from registry - action-node.tsx - Uses getAllActions() for action categories - integration-form-dialog.tsx - Uses plugin formFields dynamically - integrations-manager.tsx - Uses getAllIntegrations() **Removed hardcoding:** - ~200 lines of hardcoded step mappings in workflow-codegen-sdk.ts - ~70 lines of credential mappers in credential-fetcher.ts - Hardcoded action type dispatchers in workflow-executor.workflow.ts - Hardcoded IntegrationType unions (now auto-generated) **discover-plugins.ts improvements:** - Converted from .mjs to .ts with proper typing - Generates lib/types/integration.ts with dynamic types - Generates lib/step-registry.ts with static imports for bundler compatibility - Updates README.md with current plugin list * fix: use Node 22 in CI for tsx compatibility Node 20 + tsx has an issue where side-effect imports in dynamically imported modules don't execute properly. This caused discover-plugins to fail to register plugins, resulting in only database being generated in IntegrationType. Node 22 handles this correctly. --------- Co-authored-by: Benjamin Sabic <bensabic@users.noreply.github.com>
1 parent a74203b commit ce18157

28 files changed

+948
-1175
lines changed

.github/workflows/pr-checks.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
- name: Setup Node.js
1616
uses: actions/setup-node@v4
1717
with:
18-
node-version: '20'
18+
node-version: '22'
1919

2020
- name: Setup pnpm
2121
uses: pnpm/action-setup@v4

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,14 @@ Visit [http://localhost:3000](http://localhost:3000) to get started.
7878

7979
### Action Nodes
8080

81-
- **Send Email** - Send emails via Resend
82-
- **Create Ticket** - Create Linear tickets
83-
- **Database Query** - Query or update PostgreSQL
84-
- **HTTP Request** - Call external APIs
85-
- **Firecrawl** - Scrape websites and search the web
81+
<!-- PLUGINS:START - Do not remove. Auto-generated by discover-plugins -->
82+
- **AI Gateway**: Generate Text, Generate Image
83+
- **Firecrawl**: Scrape URL, Search Web
84+
- **Linear**: Create Ticket, Find Issues
85+
- **Resend**: Send Email
86+
- **Slack**: Send Slack Message
87+
- **v0**: Create Chat, Send Message
88+
<!-- PLUGINS:END -->
8689

8790
## Code Generation
8891

app/api/integrations/[integrationId]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { auth } from "@/lib/auth";
33
import {
44
deleteIntegration,
55
getIntegration,
6-
type IntegrationConfig,
76
updateIntegration,
87
} from "@/lib/db/integrations";
8+
import type { IntegrationConfig } from "@/lib/types/integration";
99

1010
export type GetIntegrationResponse = {
1111
id: string;

app/api/integrations/route.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { NextResponse } from "next/server";
22
import { auth } from "@/lib/auth";
3-
import {
4-
createIntegration,
5-
getIntegrations,
6-
type IntegrationConfig,
7-
type IntegrationType,
8-
} from "@/lib/db/integrations";
3+
import { createIntegration, getIntegrations } from "@/lib/db/integrations";
4+
import type {
5+
IntegrationConfig,
6+
IntegrationType,
7+
} from "@/lib/types/integration";
98

109
export type GetIntegrationsResponse = {
1110
id: string;

components/settings/integration-form-dialog.tsx

Lines changed: 71 additions & 204 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ import {
2222
SelectValue,
2323
} from "@/components/ui/select";
2424
import { Spinner } from "@/components/ui/spinner";
25-
import { api, type Integration, type IntegrationType } from "@/lib/api-client";
25+
import { api, type Integration } from "@/lib/api-client";
26+
import type { IntegrationType } from "@/lib/types/integration";
27+
import {
28+
getIntegration,
29+
getIntegrationLabels,
30+
getSortedIntegrationTypes,
31+
} from "@/plugins";
2632

2733
type IntegrationFormDialogProps = {
2834
open: boolean;
@@ -39,26 +45,22 @@ type IntegrationFormData = {
3945
config: Record<string, string>;
4046
};
4147

42-
const INTEGRATION_TYPES: IntegrationType[] = [
43-
"ai-gateway",
44-
"database",
45-
"firecrawl",
46-
"linear",
47-
"resend",
48-
"slack",
49-
"v0",
50-
];
51-
52-
const INTEGRATION_LABELS: Record<IntegrationType, string> = {
53-
resend: "Resend",
54-
linear: "Linear",
55-
slack: "Slack",
48+
// System integrations that don't have plugins
49+
const SYSTEM_INTEGRATION_TYPES: IntegrationType[] = ["database"];
50+
const SYSTEM_INTEGRATION_LABELS: Record<string, string> = {
5651
database: "Database",
57-
"ai-gateway": "AI Gateway",
58-
firecrawl: "Firecrawl",
59-
v0: "v0",
6052
};
6153

54+
// Get all integration types (plugins + system)
55+
const getIntegrationTypes = (): IntegrationType[] => [
56+
...getSortedIntegrationTypes(),
57+
...SYSTEM_INTEGRATION_TYPES,
58+
];
59+
60+
// Get label for any integration type
61+
const getLabel = (type: IntegrationType): string =>
62+
getIntegrationLabels()[type] || SYSTEM_INTEGRATION_LABELS[type] || type;
63+
6264
export function IntegrationFormDialog({
6365
open,
6466
onClose,
@@ -96,8 +98,7 @@ export function IntegrationFormDialog({
9698

9799
// Generate a default name if none provided
98100
const integrationName =
99-
formData.name.trim() ||
100-
`${INTEGRATION_LABELS[formData.type]} Integration`;
101+
formData.name.trim() || `${getLabel(formData.type)} Integration`;
101102

102103
if (mode === "edit" && integration) {
103104
await api.integration.update(integration.id, {
@@ -132,193 +133,59 @@ export function IntegrationFormDialog({
132133
};
133134

134135
const renderConfigFields = () => {
135-
switch (formData.type) {
136-
case "resend":
137-
return (
138-
<>
139-
<div className="space-y-2">
140-
<Label htmlFor="apiKey">API Key</Label>
141-
<Input
142-
id="apiKey"
143-
onChange={(e) => updateConfig("apiKey", e.target.value)}
144-
placeholder="re_..."
145-
type="password"
146-
value={formData.config.apiKey || ""}
147-
/>
148-
<p className="text-muted-foreground text-xs">
149-
Get your API key from{" "}
150-
<a
151-
className="underline hover:text-foreground"
152-
href="https://resend.com/api-keys"
153-
rel="noopener noreferrer"
154-
target="_blank"
155-
>
156-
resend.com/api-keys
157-
</a>
158-
</p>
159-
</div>
160-
<div className="space-y-2">
161-
<Label htmlFor="fromEmail">From Email</Label>
162-
<Input
163-
id="fromEmail"
164-
onChange={(e) => updateConfig("fromEmail", e.target.value)}
165-
placeholder="noreply@example.com"
166-
value={formData.config.fromEmail || ""}
167-
/>
168-
</div>
169-
</>
170-
);
171-
case "linear":
172-
return (
173-
<>
174-
<div className="space-y-2">
175-
<Label htmlFor="apiKey">API Key</Label>
176-
<Input
177-
id="apiKey"
178-
onChange={(e) => updateConfig("apiKey", e.target.value)}
179-
placeholder="lin_api_..."
180-
type="password"
181-
value={formData.config.apiKey || ""}
182-
/>
183-
<p className="text-muted-foreground text-xs">
184-
Get your API key from{" "}
185-
<a
186-
className="underline hover:text-foreground"
187-
href="https://linear.app/settings/account/security"
188-
rel="noopener noreferrer"
189-
target="_blank"
190-
>
191-
linear.app/settings/account/security
192-
</a>
193-
</p>
194-
</div>
195-
<div className="space-y-2">
196-
<Label htmlFor="teamId">Team ID</Label>
197-
<Input
198-
id="teamId"
199-
onChange={(e) => updateConfig("teamId", e.target.value)}
200-
placeholder="team_..."
201-
value={formData.config.teamId || ""}
202-
/>
203-
</div>
204-
</>
205-
);
206-
case "slack":
207-
return (
208-
<div className="space-y-2">
209-
<Label htmlFor="apiKey">Bot Token</Label>
210-
<Input
211-
id="apiKey"
212-
onChange={(e) => updateConfig("apiKey", e.target.value)}
213-
placeholder="xoxb-..."
214-
type="password"
215-
value={formData.config.apiKey || ""}
216-
/>
217-
<p className="text-muted-foreground text-xs">
218-
Create a Slack app and get your bot token from{" "}
219-
<a
220-
className="underline hover:text-foreground"
221-
href="https://api.slack.com/apps"
222-
rel="noopener noreferrer"
223-
target="_blank"
224-
>
225-
api.slack.com/apps
226-
</a>
227-
</p>
228-
</div>
229-
);
230-
case "database":
231-
return (
232-
<div className="space-y-2">
233-
<Label htmlFor="url">Database URL</Label>
234-
<Input
235-
id="url"
236-
onChange={(e) => updateConfig("url", e.target.value)}
237-
placeholder="postgresql://..."
238-
type="password"
239-
value={formData.config.url || ""}
240-
/>
241-
<p className="text-muted-foreground text-xs">
242-
Connection string in the format:
243-
postgresql://user:password@host:port/database
244-
</p>
245-
</div>
246-
);
247-
case "ai-gateway":
248-
return (
249-
<div className="space-y-2">
250-
<Label htmlFor="apiKey">AI Gateway API Key</Label>
251-
<Input
252-
id="apiKey"
253-
onChange={(e) => updateConfig("apiKey", e.target.value)}
254-
placeholder="API Key"
255-
type="password"
256-
value={formData.config.apiKey || ""}
257-
/>
258-
<p className="text-muted-foreground text-xs">
259-
Get your API key from{" "}
260-
<a
261-
className="underline hover:text-foreground"
262-
href="https://vercel.com/ai-gateway"
263-
rel="noopener noreferrer"
264-
target="_blank"
265-
>
266-
vercel.com/ai-gateway
267-
</a>
268-
</p>
269-
</div>
270-
);
271-
case "firecrawl":
272-
return (
273-
<div className="space-y-2">
274-
<Label htmlFor="firecrawlApiKey">API Key</Label>
275-
<Input
276-
id="firecrawlApiKey"
277-
onChange={(e) => updateConfig("firecrawlApiKey", e.target.value)}
278-
placeholder="fc-..."
279-
type="password"
280-
value={formData.config.firecrawlApiKey || ""}
281-
/>
282-
<p className="text-muted-foreground text-xs">
283-
Get your API key from{" "}
284-
<a
285-
className="underline hover:text-foreground"
286-
href="https://firecrawl.dev/app/api-keys"
287-
rel="noopener noreferrer"
288-
target="_blank"
289-
>
290-
firecrawl.dev
291-
</a>
292-
</p>
293-
</div>
294-
);
295-
case "v0":
296-
return (
297-
<div className="space-y-2">
298-
<Label htmlFor="apiKey">API Key</Label>
299-
<Input
300-
id="apiKey"
301-
onChange={(e) => updateConfig("apiKey", e.target.value)}
302-
placeholder="v0_..."
303-
type="password"
304-
value={formData.config.apiKey || ""}
305-
/>
306-
<p className="text-muted-foreground text-xs">
307-
Get your API key from{" "}
136+
// Handle system integrations with hardcoded fields
137+
if (formData.type === "database") {
138+
return (
139+
<div className="space-y-2">
140+
<Label htmlFor="url">Database URL</Label>
141+
<Input
142+
id="url"
143+
onChange={(e) => updateConfig("url", e.target.value)}
144+
placeholder="postgresql://..."
145+
type="password"
146+
value={formData.config.url || ""}
147+
/>
148+
<p className="text-muted-foreground text-xs">
149+
Connection string in the format:
150+
postgresql://user:password@host:port/database
151+
</p>
152+
</div>
153+
);
154+
}
155+
156+
// Get plugin form fields from registry
157+
const plugin = getIntegration(formData.type);
158+
if (!plugin?.formFields) {
159+
return null;
160+
}
161+
162+
return plugin.formFields.map((field) => (
163+
<div className="space-y-2" key={field.id}>
164+
<Label htmlFor={field.id}>{field.label}</Label>
165+
<Input
166+
id={field.id}
167+
onChange={(e) => updateConfig(field.configKey, e.target.value)}
168+
placeholder={field.placeholder}
169+
type={field.type}
170+
value={formData.config[field.configKey] || ""}
171+
/>
172+
{(field.helpText || field.helpLink) && (
173+
<p className="text-muted-foreground text-xs">
174+
{field.helpText}
175+
{field.helpLink && (
308176
<a
309177
className="underline hover:text-foreground"
310-
href="https://v0.dev/chat/settings/keys"
178+
href={field.helpLink.url}
311179
rel="noopener noreferrer"
312180
target="_blank"
313181
>
314-
v0.dev/chat/settings/keys
182+
{field.helpLink.text}
315183
</a>
316-
</p>
317-
</div>
318-
);
319-
default:
320-
return null;
321-
}
184+
)}
185+
</p>
186+
)}
187+
</div>
188+
));
322189
};
323190

324191
return (
@@ -354,14 +221,14 @@ export function IntegrationFormDialog({
354221
<SelectValue />
355222
</SelectTrigger>
356223
<SelectContent>
357-
{INTEGRATION_TYPES.map((type) => (
224+
{getIntegrationTypes().map((type) => (
358225
<SelectItem key={type} value={type}>
359226
<div className="flex items-center gap-2">
360227
<IntegrationIcon
361228
className="size-4"
362229
integration={type === "ai-gateway" ? "vercel" : type}
363230
/>
364-
{INTEGRATION_LABELS[type]}
231+
{getLabel(type)}
365232
</div>
366233
</SelectItem>
367234
))}
@@ -379,7 +246,7 @@ export function IntegrationFormDialog({
379246
onChange={(e) =>
380247
setFormData({ ...formData, name: e.target.value })
381248
}
382-
placeholder={`${INTEGRATION_LABELS[formData.type]} Integration`}
249+
placeholder={`${getLabel(formData.type)} Integration`}
383250
value={formData.name}
384251
/>
385252
</div>

0 commit comments

Comments
 (0)