From 8d0d71d9f3040156aeb616c465aaee4caed4c275 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sun, 30 Nov 2025 21:00:11 +1100 Subject: [PATCH 1/8] Update plugin templates and add create-plugin script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin Templates & Documentation: - Update templates to new declarative structure (configFields, formFields with envVar, getTestFunction) - Remove obsolete files: settings.tsx.txt, steps/action/config.tsx.txt (now auto-generated) - Restructure: steps/action/step.ts.txt → steps/action.ts.txt - Rename: index.tsx.txt → index.ts.txt - Fix template placeholders to use valid identifiers (fieldA, resultId) so generated code compiles - Update CONTRIBUTING.md with streamlined plugin development guide Create Plugin Script: - Add interactive pnpm create-plugin wizard using @inquirer/prompts - Prompts for integration name, description, action name, and action description - Auto-replaces integration and action name placeholders in generated files - Auto-runs discover-plugins after scaffolding to register the plugin - Exit with error if template files are missing - Graceful Ctrl+C handling with ExitPromptError --- CONTRIBUTING.md | 418 +++++++----------- package.json | 6 +- plugins/_template/codegen/action.ts.txt | 70 ++- plugins/_template/icon.tsx.txt | 21 +- plugins/_template/index.ts.txt | 137 ++++++ plugins/_template/index.tsx.txt | 93 ---- plugins/_template/settings.tsx.txt | 74 ---- plugins/_template/steps/action.ts.txt | 98 ++++ plugins/_template/steps/action/config.tsx.txt | 37 -- plugins/_template/steps/action/step.ts.txt | 48 -- plugins/_template/test.ts.txt | 43 +- pnpm-lock.yaml | 286 ++++++++++++ scripts/create-plugin.ts | 246 +++++++++++ 13 files changed, 1017 insertions(+), 560 deletions(-) create mode 100644 plugins/_template/index.ts.txt delete mode 100644 plugins/_template/index.tsx.txt delete mode 100644 plugins/_template/settings.tsx.txt create mode 100644 plugins/_template/steps/action.ts.txt delete mode 100644 plugins/_template/steps/action/config.tsx.txt delete mode 100644 plugins/_template/steps/action/step.ts.txt create mode 100644 scripts/create-plugin.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbb2e3f7..4c092ee3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -163,42 +163,28 @@ The Workflow Builder uses a **modular plugin-based system** that makes adding in Adding an integration requires: 1. Create a plugin directory with modular files -2. Add your integration type to the database schema -3. Import your plugin -4. Run database migrations -5. Test your integration +2. Run `pnpm discover-plugins` (auto-generates types) +3. Test your integration That's it! The system handles registration and discovery automatically. ### Quick Start ```bash -# 1. Create your plugin directory structure -mkdir -p plugins/my-integration/steps/my-action -mkdir -p plugins/my-integration/codegen - -# 2. Copy template files -cp plugins/_template/icon.tsx.txt plugins/my-integration/icon.tsx -cp plugins/_template/settings.tsx.txt plugins/my-integration/settings.tsx -cp plugins/_template/test.ts.txt plugins/my-integration/test.ts -cp plugins/_template/steps/action/step.ts.txt plugins/my-integration/steps/my-action/step.ts -cp plugins/_template/steps/action/config.tsx.txt plugins/my-integration/steps/my-action/config.tsx -cp plugins/_template/codegen/action.ts.txt plugins/my-integration/codegen/my-action.ts -cp plugins/_template/index.tsx.txt plugins/my-integration/index.tsx - -# 3. Fill in the templates with your integration details +pnpm create-plugin +``` -# 4. Add your integration type to lib/db/integrations.ts +This launches an interactive wizard that asks for: +- **Integration name** (e.g., "Stripe") +- **Integration description** (e.g., "Process payments with Stripe") +- **Action name** (e.g., "Create Payment") +- **Action description** (e.g., "Creates a new payment intent") -# 5. Generate and apply database migration -pnpm db:generate -pnpm db:push +The script creates the full plugin structure with integration and action names filled in, then registers it automatically. You'll still need to customize the generated files (API logic, input/output types, icon, etc.). -# 6. Test it! -pnpm dev -``` +Once you've built your plugin, run `pnpm dev` to test! -Now let's go through each step in detail. +Let's go through each file in detail. --- @@ -210,16 +196,13 @@ The plugin system uses a **modular file structure** where each integration is se ``` plugins/my-integration/ -├── icon.tsx # Icon component (optional if using Lucide) -├── settings.tsx # Settings UI for credential configuration -├── test.ts # Connection test function -├── steps/ # Action implementations -│ └── my-action/ -│ ├── step.ts # Server-side step function -│ └── config.tsx # Client-side UI for configuring the action -├── codegen/ # Export templates for standalone workflows -│ └── my-action.ts # Code generation template -└── index.tsx # Plugin definition (ties everything together) +├── icon.tsx # Icon component (SVG) +├── test.ts # Connection test function +├── steps/ # Action implementations +│ └── my-action.ts # Server-side step function +├── codegen/ # Export templates for standalone workflows +│ └── my-action.ts # Code generation template +└── index.ts # Plugin definition (ties everything together) ``` **Key Benefits:** @@ -228,14 +211,15 @@ plugins/my-integration/ - **Organized**: All integration code in one directory - **Scalable**: Easy to add new actions - **Self-contained**: No scattered files across the codebase -- **Discoverable**: Automatically detected by the system +- **Auto-discovered**: Automatically detected by `pnpm discover-plugins` +- **Declarative**: Action config fields defined as data, not React components ### Step-by-Step Plugin Creation #### Step 1: Create Plugin Directory Structure ```bash -mkdir -p plugins/my-integration/steps/send-message +mkdir -p plugins/my-integration/steps mkdir -p plugins/my-integration/codegen ``` @@ -247,7 +231,7 @@ mkdir -p plugins/my-integration/codegen export function MyIntegrationIcon({ className }: { className?: string }) { return ( void; - showCard?: boolean; - config?: Record; - onConfigChange?: (key: string, value: string) => void; -}) { - return ( -
-
- - onApiKeyChange(e.target.value)} - placeholder={hasKey ? "API key is configured" : "Enter your API key"} - type="password" - value={apiKey} - /> -

- Get your API key from{" "} - - My Integration Dashboard - - . -

-
-
- ); -} -``` - -#### Step 4: Create Test Function +#### Step 3: Create Test Function **File:** `plugins/my-integration/test.ts` @@ -357,9 +285,9 @@ export async function testMyIntegration(credentials: Record) { } ``` -#### Step 5: Create Step Function (Server Logic) +#### Step 4: Create Step Function (Server Logic) -**File:** `plugins/my-integration/steps/send-message/step.ts` +**File:** `plugins/my-integration/steps/send-message.ts` This runs on the server during workflow execution. Steps use the `withStepLogging` wrapper to automatically log execution for the workflow builder UI: @@ -450,59 +378,7 @@ export async function sendMessageStep( 3. **Wrap with `withStepLogging`**: The step function just wraps the logic with `withStepLogging(input, () => logic(input))` 4. **Return success/error objects**: Steps should return `{ success: true, ... }` or `{ success: false, error: "..." }` -#### Step 6: Create Config UI Component - -**File:** `plugins/my-integration/steps/send-message/config.tsx` - -This is the UI for configuring the action in the workflow builder: - -```tsx -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { TemplateBadgeInput } from "@/components/ui/template-badge-input"; - -/** - * Send Message Config Fields Component - * UI for configuring the send message action - */ -export function SendMessageConfigFields({ - config, - onUpdateConfig, - disabled, -}: { - config: Record; - onUpdateConfig: (key: string, value: unknown) => void; - disabled?: boolean; -}) { - return ( - <> -
- - onUpdateConfig("message", value)} - placeholder="Enter message or use {{NodeName.field}}" - value={(config?.message as string) || ""} - /> -
- -
- - onUpdateConfig("channel", e.target.value)} - placeholder="#general" - value={(config?.channel as string) || ""} - /> -
- - ); -} -``` - -#### Step 7: Create Codegen Template +#### Step 5: Create Codegen Template **File:** `plugins/my-integration/codegen/send-message.ts` @@ -552,44 +428,35 @@ export async function sendMessageStep(input: { }`; ``` -#### Step 8: Create Plugin Index +#### Step 6: Create Plugin Index -**File:** `plugins/my-integration/index.tsx` +**File:** `plugins/my-integration/index.ts` -This ties everything together: +This ties everything together. The plugin uses a **declarative approach** where action config fields are defined as data (not React components): -```tsx -import { MessageSquare } from "lucide-react"; +```typescript import type { IntegrationPlugin } from "../registry"; import { registerIntegration } from "../registry"; import { sendMessageCodegenTemplate } from "./codegen/send-message"; import { MyIntegrationIcon } from "./icon"; -import { MyIntegrationSettings } from "./settings"; -import { SendMessageConfigFields } from "./steps/send-message/config"; -import { testMyIntegration } from "./test"; const myIntegrationPlugin: IntegrationPlugin = { - type: "my-integration", // Must match type in lib/db/integrations.ts + type: "my-integration", label: "My Integration", description: "Send messages and create records", - icon: { - type: "svg", // or "lucide" for Lucide icons - value: "MyIntegrationIcon", - svgComponent: MyIntegrationIcon, - }, - // For Lucide icons, use: - // icon: { type: "lucide", value: "MessageSquare" }, - - settingsComponent: MyIntegrationSettings, + // Direct component reference + icon: MyIntegrationIcon, + // Form fields for integration settings (API keys, etc.) formFields: [ { - id: "myIntegrationApiKey", + id: "apiKey", label: "API Key", type: "password", placeholder: "sk_...", - configKey: "myIntegrationApiKey", + configKey: "apiKey", + envVar: "MY_INTEGRATION_API_KEY", // Maps to environment variable helpText: "Get your API key from ", helpLink: { text: "my-integration.com", @@ -598,28 +465,43 @@ const myIntegrationPlugin: IntegrationPlugin = { }, ], - credentialMapping: (config) => { - const creds: Record = {}; - if (config.myIntegrationApiKey) { - creds.MY_INTEGRATION_API_KEY = String(config.myIntegrationApiKey); - } - return creds; + // Lazy-loaded test function (avoids bundling server code in client) + testConfig: { + getTestFunction: async () => { + const { testMyIntegration } = await import("./test"); + return testMyIntegration; + }, }, - testConfig: { - testFunction: testMyIntegration, + // NPM dependencies for code export + dependencies: { + "my-integration-sdk": "^1.0.0", }, actions: [ { - id: "Send Message", + slug: "send-message", // Action ID: "my-integration/send-message" label: "Send Message", description: "Send a message to a channel", category: "My Integration", - icon: MessageSquare, stepFunction: "sendMessageStep", stepImportPath: "send-message", - configFields: SendMessageConfigFields, + // Declarative config fields (not React components) + configFields: [ + { + key: "message", + label: "Message", + type: "template-input", // Supports {{NodeName.field}} syntax + placeholder: "Enter message or use {{NodeName.field}}", + required: true, + }, + { + key: "channel", + label: "Channel", + type: "text", + placeholder: "#general", + }, + ], codegenTemplate: sendMessageCodegenTemplate, }, // Add more actions as needed @@ -632,34 +514,35 @@ registerIntegration(myIntegrationPlugin); export default myIntegrationPlugin; ``` -#### Step 9: Add Integration Type to Database Schema +**Key Points:** -**Edit:** `lib/db/integrations.ts` +1. **Icon**: Direct component reference (not an object with type/value) +2. **envVar**: Maps formField to environment variable (auto-generates credential mapping) +3. **getTestFunction**: Lazy-loads test function to avoid bundling server code +4. **dependencies**: NPM packages included when exporting workflows +5. **slug**: Action identifier (full ID becomes `my-integration/send-message`) +6. **configFields**: Declarative array defining UI fields (not React components) -```typescript -export type IntegrationType = - | "resend" - | "linear" - | "slack" - | "database" - | "ai-gateway" - | "firecrawl" - | "my-integration"; // Add this - -export type IntegrationConfig = { - // ... existing config - myIntegrationApiKey?: string; // Add this -}; -``` +**Supported configField types:** +- `template-input`: Single-line input with `{{variable}}` support +- `template-textarea`: Multi-line textarea with `{{variable}}` support +- `text`: Plain text input +- `number`: Number input (with optional `min` property) +- `select`: Dropdown (requires `options` array) +- `schema-builder`: JSON schema builder for structured output + +#### Step 7: Run Plugin Discovery -#### Step 10: Generate and Apply Migration +The `discover-plugins` script auto-generates: +- `plugins/index.ts` - Import registry +- `lib/types/integration.ts` - IntegrationType union +- `lib/step-registry.ts` - Step function mappings ```bash -pnpm db:generate -pnpm db:push +pnpm discover-plugins ``` -#### Step 11: Test Your Integration +#### Step 8: Test Your Integration ```bash pnpm type-check @@ -715,21 +598,21 @@ See `plugins/firecrawl/` for a complete, production-ready example with: - Custom SVG icon - Multiple actions (Scrape, Search) -- Separate step/config files for each action -- Full TypeScript types +- Declarative config fields +- NPM dependencies for code export +- Lazy-loaded test function ### Example 2: Using Lucide Icons +You can use a Lucide icon directly instead of creating a custom SVG: + ```typescript -// In index.tsx +// In index.ts import { Zap } from "lucide-react"; const plugin: IntegrationPlugin = { // ... - icon: { - type: "lucide", - value: "Zap", // No icon.tsx file needed - }, + icon: Zap, // Direct component reference // ... }; ``` @@ -739,25 +622,29 @@ const plugin: IntegrationPlugin = { ```typescript actions: [ { - id: "Send Message", + slug: "send-message", label: "Send Message", description: "Send a message", category: "My Integration", - icon: MessageSquare, stepFunction: "sendMessageStep", stepImportPath: "send-message", - configFields: SendMessageConfigFields, + configFields: [ + { key: "message", label: "Message", type: "template-input" }, + { key: "channel", label: "Channel", type: "text" }, + ], codegenTemplate: sendMessageCodegenTemplate, }, { - id: "Create Record", + slug: "create-record", label: "Create Record", description: "Create a new record", category: "My Integration", - icon: Plus, stepFunction: "createRecordStep", stepImportPath: "create-record", - configFields: CreateRecordConfigFields, + configFields: [ + { key: "title", label: "Title", type: "template-input", required: true }, + { key: "description", label: "Description", type: "template-textarea" }, + ], codegenTemplate: createRecordCodegenTemplate, }, ], @@ -767,35 +654,35 @@ actions: [ ## Common Patterns -### Pattern 1: Template Badge Inputs - -Use `TemplateBadgeInput` to allow users to reference outputs from other workflow nodes: - -```tsx - onUpdateConfig("message", value)} - placeholder="Enter message or use {{NodeName.field}}" -/> -``` - -### Pattern 2: Step Function Structure +### Pattern 1: Step Function Structure Steps follow a consistent structure with logging: ```typescript import "server-only"; +import { fetchCredentials } from "@/lib/credential-fetcher"; import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; type MyResult = { success: true; data: string } | { success: false; error: string }; export type MyInput = StepInput & { + integrationId?: string; field1: string; }; // 1. Logic function (no "use step" needed) async function myLogic(input: MyInput): Promise { + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + const apiKey = credentials.MY_INTEGRATION_API_KEY; + if (!apiKey) { + return { success: false, error: "API key not configured" }; + } + try { const response = await fetch(/* ... */); if (!response.ok) { @@ -818,19 +705,54 @@ export async function myStep(input: MyInput): Promise { } ``` -### Pattern 3: Credential Mapping +### Pattern 2: Declarative Config Fields + +Action config fields are defined declaratively in the plugin index: ```typescript -credentialMapping: (config) => { - const creds: Record = {}; - if (config.apiKey) { - creds.MY_INTEGRATION_API_KEY = String(config.apiKey); - } - if (config.workspaceId) { - creds.MY_INTEGRATION_WORKSPACE_ID = String(config.workspaceId); - } - return creds; -}, +configFields: [ + // Template input (supports {{NodeName.field}} syntax) + { + key: "message", + label: "Message", + type: "template-input", + placeholder: "Enter value or {{NodeName.field}}", + required: true, + }, + // Multi-line textarea + { + key: "body", + label: "Body", + type: "template-textarea", + rows: 5, + }, + // Select dropdown + { + key: "priority", + label: "Priority", + type: "select", + options: [ + { value: "low", label: "Low" }, + { value: "high", label: "High" }, + ], + defaultValue: "low", + }, + // Number input + { + key: "limit", + label: "Limit", + type: "number", + min: 1, + defaultValue: "10", + }, + // Conditional field (only shown when another field matches) + { + key: "customOption", + label: "Custom Option", + type: "text", + showWhen: { field: "priority", equals: "high" }, + }, +], ``` --- @@ -852,12 +774,16 @@ If you run into issues: **Adding an integration requires:** -1. Create plugin directory with modular files (6-8 files) -2. Update database schema (add your type) -3. Run migration -4. Test thoroughly +1. Create plugin directory with 4-5 files: + - `index.ts` - Plugin definition + - `icon.tsx` - Icon component (or use Lucide) + - `test.ts` - Connection test function + - `steps/[action].ts` - Step function(s) + - `codegen/[action].ts` - Code generation template(s) +2. Run `pnpm discover-plugins` to auto-generate types +3. Test thoroughly -Each integration is self-contained in one organized directory, making it easy to develop, test, and maintain. Happy building! 🚀 +Each integration is self-contained in one organized directory, making it easy to develop, test, and maintain. Happy building! --- diff --git a/package.json b/package.json index ec4f2a01..65473cf2 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", - "discover-plugins": "tsx scripts/discover-plugins.ts" + "discover-plugins": "tsx scripts/discover-plugins.ts", + "create-plugin": "tsx scripts/create-plugin.ts" }, "dependencies": { "@ai-sdk/provider": "^2.0.0", @@ -58,14 +59,15 @@ "zod": "^4.1.12" }, "devDependencies": { + "@inquirer/prompts": "^8.0.1", "@tailwindcss/postcss": "^4", - "tsx": "^4.19.0", "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", "drizzle-kit": "^0.31.6", "knip": "^5.70.1", "tailwindcss": "^4", + "tsx": "^4.19.0", "tw-animate-css": "^1.4.0", "typescript": "^5", "ultracite": "6.3.0" diff --git a/plugins/_template/codegen/action.ts.txt b/plugins/_template/codegen/action.ts.txt index 831dd2d8..4599aa9b 100644 --- a/plugins/_template/codegen/action.ts.txt +++ b/plugins/_template/codegen/action.ts.txt @@ -1,58 +1,44 @@ /** * CODE GENERATION TEMPLATE - * - * This template is used when users export/download their workflow as a standalone Next.js project. - * It generates code that doesn't depend on your server infrastructure. - * - * Key differences from step functions: - * - Uses environment variables (process.env) instead of integrationId - * - No fetchCredentials() - credentials come from env vars - * - Same logic and return structure as the step function - * - Should be a template string export + * + * This template generates standalone code when users export their workflow. + * The generated code uses environment variables instead of integrationId. + * + * Key differences from step.ts: + * - Uses process.env.[VAR] instead of fetchCredentials() + * - No integrationId parameter + * - Simpler error handling (throw instead of return error objects) + * - Same core logic and return structure + * + * Note: Escape backticks with backslash in template literals */ -export const [actionName]CodegenTemplate = `import { [PACKAGE_NAME] } from '[PACKAGE_IMPORT_PATH]'; +export const [actionName]CodegenTemplate = `import { [ClientName] } from '[package-name]'; export async function [actionName]Step(input: { - [parameterName]: string; - [optionalParameter]?: string; + fieldA: string; + fieldB?: string; }) { "use step"; - // Use environment variable instead of integrationId - const apiKey = process.env.[ENV_VAR_NAME]; + const apiKey = process.env.[INTEGRATION_NAME]_API_KEY; if (!apiKey) { - throw new Error('[ENV_VAR_NAME] environment variable is required'); + throw new Error('[INTEGRATION_NAME]_API_KEY environment variable is required'); } - try { - // Your integration logic here - should match the step function - const response = await fetch('[API_ENDPOINT]', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': \\\`Bearer \\\${apiKey}\\\`, - }, - body: JSON.stringify({ - [apiField]: input.[parameterName], - }), - }); + // Initialize client with API key from environment + const client = new [ClientName](apiKey); - if (!response.ok) { - throw new Error(\\\`API request failed: \\\${response.statusText}\\\`); - } + // Your API call logic here + const result = await client.doSomething({ + fieldA: input.fieldA, + fieldB: input.fieldB, + }); - const result = await response.json(); - - // Return the same structure as your step function - return { - id: result.id, - url: result.url, - // Match the same return structure as your step function - }; - } catch (error) { - throw new Error(\\\`Failed to [action description]: \\\${error instanceof Error ? error.message : String(error)}\\\`); - } + return { + success: true, + id: result.id, + // Include other relevant fields from the result + }; }`; - diff --git a/plugins/_template/icon.tsx.txt b/plugins/_template/icon.tsx.txt index 584b420d..d39f0911 100644 --- a/plugins/_template/icon.tsx.txt +++ b/plugins/_template/icon.tsx.txt @@ -1,25 +1,28 @@ /** * ICON COMPONENT TEMPLATE - * - * Define your integration's icon here. - * You can use a Lucide icon, an SVG component, or an image file. - * - * For custom SVG icons, create a component like this: + * + * Define your integration's icon here as an SVG component. + * The icon should accept an optional className prop for styling. + * + * Tips: + * - Use fill="currentColor" to inherit the text color + * - Standard viewBox is "0 0 24 24" but adjust as needed for your SVG + * - Include aria-label and title for accessibility + * - Find the SVG for the [IntegrationName] logo at https://simpleicons.org/?q=[integration-name] */ export function [IntegrationName]Icon({ className }: { className?: string }) { return ( [Integration Name] - {/* Your SVG path here */} - + {/* Replace with your SVG path(s) */} + ); } - diff --git a/plugins/_template/index.ts.txt b/plugins/_template/index.ts.txt new file mode 100644 index 00000000..60edefe6 --- /dev/null +++ b/plugins/_template/index.ts.txt @@ -0,0 +1,137 @@ +/** + * PLUGIN INDEX TEMPLATE + * + * This is the main plugin definition file. It registers your integration + * and defines all actions, configuration fields, and metadata. + * + * Instructions: + * 1. Replace all [PLACEHOLDERS] with your integration's values + * 2. Run `pnpm discover-plugins` after creating your plugin + * + * The plugin system auto-discovers plugins and generates: + * - plugins/index.ts (import registry) + * - lib/types/integration.ts (IntegrationType union) + * - lib/step-registry.ts (step function mappings) + */ + +import type { IntegrationPlugin } from "../registry"; +import { registerIntegration } from "../registry"; +import { [actionName]CodegenTemplate } from "./codegen/[action-slug]"; +import { [IntegrationName]Icon } from "./icon"; + +const [integrationName]Plugin: IntegrationPlugin = { + // Must be unique and match your folder name (e.g., "my-integration") + type: "[integration-type]", + + // Display name shown in the UI + label: "[Integration Name]", + + // Brief description of what this integration does + description: "[Brief description of the integration]", + + // Icon component - imported from ./icon.tsx + // Can be a custom SVG component or use a Lucide icon directly + icon: [IntegrationName]Icon, + + // Form fields for the integration settings dialog + // These define what credentials/config users need to provide + formFields: [ + { + id: "apiKey", + label: "API Key", + type: "password", // "password" | "text" | "url" + placeholder: "[api-key-prefix]...", + configKey: "apiKey", // Key stored in database + envVar: "[INTEGRATION_NAME]_API_KEY", // Environment variable for codegen + helpText: "Get your API key from ", + helpLink: { + text: "[integration-name].com/api-keys", + url: "https://[integration-name].com/api-keys", + }, + }, + // Add more fields as needed (e.g., workspace ID, region, etc.) + ], + + // Test function for validating credentials + // Lazy-loaded to avoid bundling server code in client + testConfig: { + getTestFunction: async () => { + const { test[IntegrationName] } = await import("./test"); + return test[IntegrationName]; + }, + }, + + // NPM dependencies required by this plugin + // These are included when exporting workflows + dependencies: { + "[package-name]": "^[version]", + }, + + // Actions provided by this integration + actions: [ + { + // Unique slug for this action (used in URLs and IDs) + // Full action ID will be: "[integration-type]/[slug]" + slug: "[action-slug]", + + // Display name and description + label: "[Action Label]", + description: "[What this action does]", + + // Category for grouping in the action picker (usually integration name) + category: "[Integration Name]", + + // Step function name and import path + // The function must be exported from plugins/[integration]/steps/[stepImportPath].ts + stepFunction: "[actionName]Step", + stepImportPath: "[action-slug]", + + // Declarative config fields for the action + // Supported types: "template-input", "template-textarea", "text", "number", "select", "schema-builder" + configFields: [ + { + key: "fieldA", + label: "Field A", + type: "template-input", // Supports {{NodeName.field}} syntax + placeholder: "Enter value or use {{NodeName.field}}", + example: "example value", // Used in AI prompt generation + required: true, + }, + { + key: "fieldB", + label: "Field B", + type: "template-textarea", + placeholder: "Multi-line content...", + rows: 5, + }, + // For select fields: + // { + // key: "option", + // label: "Option", + // type: "select", + // options: [ + // { value: "a", label: "Option A" }, + // { value: "b", label: "Option B" }, + // ], + // defaultValue: "a", + // }, + // For conditional fields: + // { + // key: "conditionalField", + // label: "Conditional Field", + // type: "text", + // showWhen: { field: "option", equals: "b" }, + // }, + ], + + // Code generation template (imported from ./codegen/[action-slug].ts) + codegenTemplate: [actionName]CodegenTemplate, + }, + // Add more actions as needed + ], +}; + +// Auto-register on import +registerIntegration([integrationName]Plugin); + +export default [integrationName]Plugin; diff --git a/plugins/_template/index.tsx.txt b/plugins/_template/index.tsx.txt deleted file mode 100644 index e16a5c5c..00000000 --- a/plugins/_template/index.tsx.txt +++ /dev/null @@ -1,93 +0,0 @@ -/** - * PLUGIN INDEX TEMPLATE - * - * This is the main plugin file that brings everything together. - * It imports all the components and defines the plugin configuration. - */ - -import { [LUCIDE_ICON_NAME] } from "lucide-react"; -import type { IntegrationPlugin } from "../registry"; -import { registerIntegration } from "../registry"; -import { [actionName]CodegenTemplate } from "./codegen/[action-file]"; -import { [IntegrationName]Icon } from "./icon"; -import { [IntegrationName]Settings } from "./settings"; -import { [ActionName]ConfigFields } from "./steps/[action-file]/config"; -import { test[IntegrationName] } from "./test"; - -// Export step functions for workflow execution -export { [actionName]Step } from "./steps/[action-file]/step"; - -const [integrationId]Plugin: IntegrationPlugin = { - type: "[integration-id]", // Must match type in lib/db/integrations.ts - label: "[Integration Name]", - description: "[Brief description of what this integration does]", - - // Icon configuration - icon: { - type: "svg", // or "lucide" for Lucide icons, or "image" for image files - value: "[IntegrationName]Icon", - svgComponent: [IntegrationName]Icon, - }, - // For Lucide icons, use: - // icon: { - // type: "lucide", - // value: "[ICON_NAME]", // e.g., "Zap", "Mail", "Send" - // }, - - // Settings component - settingsComponent: [IntegrationName]Settings, - - // Form fields for the integration dialog - formFields: [ - { - id: "[integration-id]ApiKey", - label: "API Key", - type: "password", - placeholder: "[api-key-prefix]...", - configKey: "[integration-id]ApiKey", // Key in database config - helpText: "Get your API key from ", - helpLink: { - text: "[integration-name].com", - url: "[LINK_TO_API_KEYS]", - }, - }, - // Add more fields as needed - ], - - // Map config to environment variables - credentialMapping: (config) => { - const creds: Record = {}; - if (config.[integration-id]ApiKey) { - creds.[ENV_VAR_NAME] = String(config.[integration-id]ApiKey); - } - // Map additional fields - return creds; - }, - - // Test connection function - testConfig: { - testFunction: test[IntegrationName], - }, - - // Actions provided by this integration - actions: [ - { - id: "[ACTION_ID]", // e.g., "Send Message", "Create Record" - label: "[Action Label]", - description: "[What this action does]", - category: "[Integration Name]", // Usually the integration name - icon: [LUCIDE_ICON_NAME], // Lucide icon component - stepFunction: "[actionName]Step", - stepImportPath: "[action-file]", // Relative to plugins/[plugin-name]/steps/ - configFields: [ActionName]ConfigFields, - codegenTemplate: [actionName]CodegenTemplate, // The actual template string - }, - // Add more actions as needed - ], -}; - -// Auto-register on import -registerIntegration([integrationId]Plugin); - -export default [integrationId]Plugin; - diff --git a/plugins/_template/settings.tsx.txt b/plugins/_template/settings.tsx.txt deleted file mode 100644 index 9bd4de9c..00000000 --- a/plugins/_template/settings.tsx.txt +++ /dev/null @@ -1,74 +0,0 @@ -/** - * SETTINGS COMPONENT TEMPLATE - * - * This component is displayed in the settings dialog when users configure your integration. - * It should collect the necessary credentials and configuration. - */ - -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; - -export function [IntegrationName]Settings({ - apiKey, - hasKey, - onApiKeyChange, - config, - onConfigChange, -}: { - apiKey: string; - hasKey?: boolean; - onApiKeyChange: (key: string) => void; - showCard?: boolean; - config?: Record; - onConfigChange?: (key: string, value: string) => void; -}) { - return ( -
- {/* API Key Field */} -
- - onApiKeyChange(e.target.value)} - placeholder={ - hasKey ? "API key is configured" : "Enter your [Integration Name] API key" - } - type="password" - value={apiKey} - /> -

- Get your API key from{" "} - - [Integration Name] Dashboard - - . -

-
- - {/* Add additional config fields as needed */} - {onConfigChange && ( -
- - onConfigChange("[additional-field-key]", e.target.value)} - placeholder="[Placeholder]" - value={config?.["additional-field-key"] || ""} - /> -
- )} -
- ); -} - diff --git a/plugins/_template/steps/action.ts.txt b/plugins/_template/steps/action.ts.txt new file mode 100644 index 00000000..3ec24f6b --- /dev/null +++ b/plugins/_template/steps/action.ts.txt @@ -0,0 +1,98 @@ +/** + * STEP FUNCTION TEMPLATE + * + * This file contains the server-side execution logic for your action. + * It runs when the workflow executes this step. + * + * TODO: + * 1. Update the input/result types to match your action's fields + * 2. Implement your API call logic in the main function + * 3. The step function must be exported and match the name in index.ts + */ + +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; + +// TODO: Customize result fields for your action +type [ActionName]Result = + | { success: true; resultId: string } + | { success: false; error: string }; + +// TODO: Customize input fields for your action +export type [ActionName]Input = StepInput & { + integrationId?: string; + fieldA: string; + fieldB?: string; +}; + +/** + * [Action Name] logic - separated for clarity and testability + * This function contains the actual business logic + */ +async function [actionName](input: [ActionName]Input): Promise<[ActionName]Result> { + // Fetch credentials using the integrationId + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + const apiKey = credentials.[INTEGRATION_NAME]_API_KEY; + + // Validate required credentials + if (!apiKey) { + return { + success: false, + error: + "[INTEGRATION_NAME]_API_KEY is not configured. Please add it in Project Integrations.", + }; + } + + try { + // Your API call or business logic here + // Example: + // const client = new MyClient(apiKey); + // const result = await client.doSomething(input.fieldA); + + const response = await fetch("https://api.[integration-name].com/endpoint", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + fieldA: input.fieldA, + fieldB: input.fieldB, + }), + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.statusText}`); + } + + const result = await response.json(); + + return { success: true, resultId: result.id }; + } catch (error) { + return { + success: false, + error: `Failed to execute action: ${getErrorMessage(error)}`, + }; + } +} + +/** + * [Action Name] Step + * [Brief description of what this step does] + * + * The step function wraps the logic with: + * - "use step" directive for workflow tracking + * - withStepLogging for automatic execution logging + */ +export async function [actionName]Step( + input: [ActionName]Input +): Promise<[ActionName]Result> { + "use step"; + return withStepLogging(input, () => [actionName](input)); +} diff --git a/plugins/_template/steps/action/config.tsx.txt b/plugins/_template/steps/action/config.tsx.txt deleted file mode 100644 index 28f5f907..00000000 --- a/plugins/_template/steps/action/config.tsx.txt +++ /dev/null @@ -1,37 +0,0 @@ -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { TemplateBadgeInput } from "@/components/ui/template-badge-input"; -import { TemplateBadgeTextarea } from "@/components/ui/template-badge-textarea"; - -/** - * [Action Name] Config Fields Component - * UI for configuring this action - */ -export function [ActionName]ConfigFields({ - config, - onUpdateConfig, - disabled, -}: { - config: Record; - onUpdateConfig: (key: string, value: unknown) => void; - disabled?: boolean; -}) { - return ( - <> -
- - onUpdateConfig("[fieldKey]", value)} - placeholder="Enter value or {{NodeName.field}}" - value={(config?.[fieldKey] as string) || ""} - /> -
- {/* Add more fields as needed */} - - ); -} - diff --git a/plugins/_template/steps/action/step.ts.txt b/plugins/_template/steps/action/step.ts.txt deleted file mode 100644 index c0043b90..00000000 --- a/plugins/_template/steps/action/step.ts.txt +++ /dev/null @@ -1,48 +0,0 @@ -import "server-only"; - -import { fetchCredentials } from "@/lib/credential-fetcher"; -import { getErrorMessage } from "@/lib/utils"; - -/** - * [Action Name] Step - * [Description of what this step does] - */ -export async function [actionName]Step(input: { - integrationId?: string; - // Add your input parameters here - [parameterName]: string; - [optionalParameter]?: string; -}) { - "use step"; - - // Fetch credentials from the integration - const credentials = input.integrationId - ? await fetchCredentials(input.integrationId) - : {}; - - const apiKey = credentials.[ENV_VAR_NAME]; - - if (!apiKey) { - return { - success: false, - error: "[ENV_VAR_NAME] is not configured. Please add it in Project Integrations.", - }; - } - - try { - // Your integration logic here - // Example: const client = new YourClient(apiKey); - // const result = await client.doSomething(input.parameterName); - - return { - success: true, - // Return your result data here - }; - } catch (error) { - return { - success: false, - error: `Failed to [action]: ${getErrorMessage(error)}`, - }; - } -} - diff --git a/plugins/_template/test.ts.txt b/plugins/_template/test.ts.txt index 4d66daba..2cb04e2f 100644 --- a/plugins/_template/test.ts.txt +++ b/plugins/_template/test.ts.txt @@ -1,27 +1,53 @@ /** * TEST FUNCTION TEMPLATE - * - * This function is called when users click "Test Connection" in the settings dialog. - * It should verify that the credentials are valid. + * + * This function validates credentials when users click "Test Connection". + * It should verify credentials are valid without performing destructive actions. + * + * The function is lazy-loaded via getTestFunction() in index.ts to avoid + * bundling server-side code in the client. + * + * Approaches for testing: + * 1. Format validation (e.g., check API key prefix) + * 2. Lightweight API call (e.g., fetch user info, list resources) + * 3. Dedicated test endpoint if the API provides one */ export async function test[IntegrationName](credentials: Record) { try { - // Test your API connection here - const response = await fetch("[TEST_ENDPOINT_URL]", { + const apiKey = credentials.[INTEGRATION_NAME]_API_KEY; + + // Validate API key is provided + if (!apiKey) { + return { + success: false, + error: "[INTEGRATION_NAME]_API_KEY is required", + }; + } + + // Option 1: Format validation (if API keys have a known format) + // if (!apiKey.startsWith("sk_")) { + // return { + // success: false, + // error: "Invalid API key format. API keys should start with 'sk_'", + // }; + // } + + // Option 2: Make a lightweight API call to verify the key works + const response = await fetch("https://api.[integration-name].com/v1/test", { method: "GET", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${credentials.[ENV_VAR_NAME]}`, + Authorization: `Bearer ${apiKey}`, }, }); if (response.ok) { return { success: true }; } - + const error = await response.text(); - return { success: false, error }; + return { success: false, error: error || "Invalid API key" }; } catch (error) { return { success: false, @@ -29,4 +55,3 @@ export async function test[IntegrationName](credentials: Record) }; } } - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0439195..01d512da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: specifier: ^4.1.12 version: 4.1.12 devDependencies: + '@inquirer/prompts': + specifier: ^8.0.1 + version: 8.0.1(@types/node@24.10.0) '@tailwindcss/postcss': specifier: ^4 version: 4.1.16 @@ -855,6 +858,140 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@2.0.1': + resolution: {integrity: sha512-QAZUk6BBncv/XmSEZTscd8qazzjV3E0leUMrEPjxCd51QBgCKmprUGLex5DTsNtURm7LMzv+CLcd6S86xvBfYg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/checkbox@5.0.1': + resolution: {integrity: sha512-5VPFBK8jKdsjMK3DTFOlbR0+Kkd4q0AWB7VhWQn6ppv44dr3b7PU8wSJQTC5oA0f/aGW7v/ZozQJAY9zx6PKig==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.0.1': + resolution: {integrity: sha512-wD+pM7IxLn1TdcQN12Q6wcFe5VpyCuh/I2sSmqO5KjWH2R4v+GkUToHb+PsDGobOe1MtAlXMwGNkZUPc2+L6NA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.0.1': + resolution: {integrity: sha512-Tpf49h50e4KYffVUCXzkx4gWMafUi3aDQDwfVAAGBNnVcXiwJIj4m2bKlZ7Kgyf6wjt1eyXH1wDGXcAokm4Ssw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.0.1': + resolution: {integrity: sha512-zDKobHI7Ry++4noiV9Z5VfYgSVpPZoMApviIuGwLOMciQaP+dGzCO+1fcwI441riklRiZg4yURWyEoX0Zy2zZw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.0.1': + resolution: {integrity: sha512-TBrTpAB6uZNnGQHtSEkbvJZIQ3dXZOrwqQSO9uUbwct3G2LitwBCE5YZj98MbQ5nzihzs5pRjY1K9RRLH4WgoA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@2.0.1': + resolution: {integrity: sha512-BPYWJXCAK9w6R+pb2s3WyxUz9ts9SP/LDOUwA9fu7LeuyYgojz83i0DSRwezu736BgMwz14G63Xwj70hSzHohQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.1': + resolution: {integrity: sha512-KtMxyjLCuDFqAWHmCY9qMtsZ09HnjMsm8H3OvpSIpfhHdfw3/AiGWHNrfRwbyvHPtOJpumm8wGn5fkhtvkWRsg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/input@5.0.1': + resolution: {integrity: sha512-cEhEUohCpE2BCuLKtFFZGp4Ief05SEcqeAOq9NxzN5ThOQP8Rl5N/Nt9VEDORK1bRb2Sk/zoOyQYfysPQwyQtA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.0.1': + resolution: {integrity: sha512-4//zgBGHe8Q/FfCoUXZUrUHyK/q5dyqiwsePz3oSSPSmw1Ijo35ZkjaftnxroygcUlLYfXqm+0q08lnB5hd49A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.0.1': + resolution: {integrity: sha512-UJudHpd7Ia30Q+x+ctYqI9Nh6SyEkaBscpa7J6Ts38oc1CNSws0I1hJEdxbQBlxQd65z5GEJPM4EtNf6tzfWaQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.0.1': + resolution: {integrity: sha512-MURRu/cyvLm9vchDDaVZ9u4p+ADnY0Mz3LQr0KTgihrrvuKZlqcWwlBC4lkOMvd0KKX4Wz7Ww9+uA7qEpQaqjg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.0.1': + resolution: {integrity: sha512-vVfVHKUgH6rZmMlyd0jOuGZo0Fw1jfcOqZF96lMwlgavx7g0x7MICe316bV01EEoI+c68vMdbkTTawuw3O+Fgw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.0.1': + resolution: {integrity: sha512-XwiaK5xBvr31STX6Ji8iS3HCRysBXfL/jUbTzufdWTS6LTGtvDQA50oVETt1BJgjKyQBp9vt0VU6AmU/AnOaGA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.0.1': + resolution: {integrity: sha512-gPByrgYoezGyKMq5KjV7Tuy1JU2ArIy6/sI8sprw0OpXope3VGQwP5FK1KD4eFFqEhKu470Dwe6/AyDPmGRA0Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.1': + resolution: {integrity: sha512-odO8YwoQAw/eVu/PSPsDDVPmqO77r/Mq7zcoF5VduVqIu2wSRWUgmYb5K9WH1no0SjLnOe8MDKtDL++z6mfo2g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -2610,6 +2747,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2639,6 +2779,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -3195,6 +3339,10 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3564,6 +3712,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3927,6 +4079,9 @@ packages: safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -5217,6 +5372,125 @@ snapshots: '@img/sharp-win32-x64@0.34.4': optional: true + '@inquirer/ansi@2.0.1': {} + + '@inquirer/checkbox@5.0.1(@types/node@24.10.0)': + dependencies: + '@inquirer/ansi': 2.0.1 + '@inquirer/core': 11.0.1(@types/node@24.10.0) + '@inquirer/figures': 2.0.1 + '@inquirer/type': 4.0.1(@types/node@24.10.0) + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/confirm@6.0.1(@types/node@24.10.0)': + dependencies: + '@inquirer/core': 11.0.1(@types/node@24.10.0) + '@inquirer/type': 4.0.1(@types/node@24.10.0) + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/core@11.0.1(@types/node@24.10.0)': + dependencies: + '@inquirer/ansi': 2.0.1 + '@inquirer/figures': 2.0.1 + '@inquirer/type': 4.0.1(@types/node@24.10.0) + cli-width: 4.1.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + wrap-ansi: 9.0.2 + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/editor@5.0.1(@types/node@24.10.0)': + dependencies: + '@inquirer/core': 11.0.1(@types/node@24.10.0) + '@inquirer/external-editor': 2.0.1(@types/node@24.10.0) + '@inquirer/type': 4.0.1(@types/node@24.10.0) + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/expand@5.0.1(@types/node@24.10.0)': + dependencies: + '@inquirer/core': 11.0.1(@types/node@24.10.0) + '@inquirer/type': 4.0.1(@types/node@24.10.0) + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/external-editor@2.0.1(@types/node@24.10.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.0 + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/figures@2.0.1': {} + + '@inquirer/input@5.0.1(@types/node@24.10.0)': + dependencies: + '@inquirer/core': 11.0.1(@types/node@24.10.0) + '@inquirer/type': 4.0.1(@types/node@24.10.0) + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/number@4.0.1(@types/node@24.10.0)': + dependencies: + '@inquirer/core': 11.0.1(@types/node@24.10.0) + '@inquirer/type': 4.0.1(@types/node@24.10.0) + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/password@5.0.1(@types/node@24.10.0)': + dependencies: + '@inquirer/ansi': 2.0.1 + '@inquirer/core': 11.0.1(@types/node@24.10.0) + '@inquirer/type': 4.0.1(@types/node@24.10.0) + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/prompts@8.0.1(@types/node@24.10.0)': + dependencies: + '@inquirer/checkbox': 5.0.1(@types/node@24.10.0) + '@inquirer/confirm': 6.0.1(@types/node@24.10.0) + '@inquirer/editor': 5.0.1(@types/node@24.10.0) + '@inquirer/expand': 5.0.1(@types/node@24.10.0) + '@inquirer/input': 5.0.1(@types/node@24.10.0) + '@inquirer/number': 4.0.1(@types/node@24.10.0) + '@inquirer/password': 5.0.1(@types/node@24.10.0) + '@inquirer/rawlist': 5.0.1(@types/node@24.10.0) + '@inquirer/search': 4.0.1(@types/node@24.10.0) + '@inquirer/select': 5.0.1(@types/node@24.10.0) + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/rawlist@5.0.1(@types/node@24.10.0)': + dependencies: + '@inquirer/core': 11.0.1(@types/node@24.10.0) + '@inquirer/type': 4.0.1(@types/node@24.10.0) + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/search@4.0.1(@types/node@24.10.0)': + dependencies: + '@inquirer/core': 11.0.1(@types/node@24.10.0) + '@inquirer/figures': 2.0.1 + '@inquirer/type': 4.0.1(@types/node@24.10.0) + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/select@5.0.1(@types/node@24.10.0)': + dependencies: + '@inquirer/ansi': 2.0.1 + '@inquirer/core': 11.0.1(@types/node@24.10.0) + '@inquirer/figures': 2.0.1 + '@inquirer/type': 4.0.1(@types/node@24.10.0) + optionalDependencies: + '@types/node': 24.10.0 + + '@inquirer/type@4.0.1(@types/node@24.10.0)': + optionalDependencies: + '@types/node': 24.10.0 + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -7229,6 +7503,8 @@ snapshots: chalk@5.6.2: {} + chardet@2.1.1: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -7255,6 +7531,8 @@ snapshots: cli-spinners@2.9.2: {} + cli-width@4.1.0: {} + client-only@0.0.1: {} clone@1.0.4: @@ -7742,6 +8020,10 @@ snapshots: human-signals@8.0.1: {} + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -8031,6 +8313,8 @@ snapshots: ms@2.1.3: {} + mute-stream@3.0.0: {} + nanoid@3.3.11: {} nanoid@5.1.6: {} @@ -8430,6 +8714,8 @@ snapshots: safe-buffer@5.1.2: {} + safer-buffer@2.1.2: {} + scheduler@0.27.0: {} scule@1.3.0: {} diff --git a/scripts/create-plugin.ts b/scripts/create-plugin.ts new file mode 100644 index 00000000..91f6f207 --- /dev/null +++ b/scripts/create-plugin.ts @@ -0,0 +1,246 @@ +#!/usr/bin/env tsx +/** + * Plugin Scaffolding Script + * + * Creates a new plugin from templates using interactive prompts. + * + * Usage: + * pnpm create-plugin + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { input } from "@inquirer/prompts"; + +const PLUGINS_DIR = join(process.cwd(), "plugins"); +const TEMPLATE_DIR = join(PLUGINS_DIR, "_template"); + +// Regex patterns used for case conversions (hoisted for performance) +const LEADING_UPPERCASE_REGEX = /^[A-Z]/; + +/** + * Convert a string to various case formats + */ +function toKebabCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, "$1-$2") + .replace(/[\s_]+/g, "-") + .toLowerCase(); +} + +function toCamelCase(str: string): string { + return str + .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : "")) + .replace(LEADING_UPPERCASE_REGEX, (c) => c.toLowerCase()); +} + +function toPascalCase(str: string): string { + const camel = toCamelCase(str); + return camel.charAt(0).toUpperCase() + camel.slice(1); +} + +function toUpperSnake(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/[-\s]+/g, "_") + .toUpperCase(); +} + +function toTitleCase(str: string): string { + return str + .replace(/[-_]+/g, " ") + .replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase() + ); +} + +type PluginConfig = { + integrationName: string; + integrationDescription: string; + actionName: string; + actionDescription: string; +}; + +/** + * Replace all placeholders in content + */ +function replacePlaceholders(content: string, config: PluginConfig): string { + const { + integrationName, + integrationDescription, + actionName, + actionDescription, + } = config; + + // Integration placeholders + const intKebab = toKebabCase(integrationName); + const intCamel = toCamelCase(integrationName); + const intPascal = toPascalCase(integrationName); + const intUpperSnake = toUpperSnake(integrationName); + const intTitle = toTitleCase(integrationName); + + // Action placeholders + const actKebab = toKebabCase(actionName); + const actCamel = toCamelCase(actionName); + const actPascal = toPascalCase(actionName); + const actUpperSnake = toUpperSnake(actionName); + const actTitle = toTitleCase(actionName); + + return ( + content + // Integration placeholders + .replace(/\[integration-type\]/g, intKebab) + .replace(/\[integration-name\]/g, intKebab) + .replace(/\[integrationName\]/g, intCamel) + .replace(/\[IntegrationName\]/g, intPascal) + .replace(/\[INTEGRATION_NAME\]/g, intUpperSnake) + .replace(/\[Integration Name\]/g, intTitle) + .replace( + /\[Brief description of the integration\]/g, + integrationDescription + ) + // Action placeholders + .replace(/\[action-slug\]/g, actKebab) + .replace(/\[actionName\]/g, actCamel) + .replace(/\[ActionName\]/g, actPascal) + .replace(/\[ACTION_NAME\]/g, actUpperSnake) + .replace(/\[Action Name\]/g, actTitle) + .replace(/\[Action Label\]/g, actTitle) + .replace(/\[What this action does\]/g, actionDescription) + ); +} + +/** + * Get dynamic template files based on action name + */ +function getTemplateFiles(actionSlug: string) { + return [ + { src: "index.ts.txt", dest: "index.ts" }, + { src: "icon.tsx.txt", dest: "icon.tsx" }, + { src: "test.ts.txt", dest: "test.ts" }, + { src: "steps/action.ts.txt", dest: `steps/${actionSlug}.ts` }, + { src: "codegen/action.ts.txt", dest: `codegen/${actionSlug}.ts` }, + ]; +} + +/** + * Main execution + */ +async function main(): Promise { + console.log("\n🔧 Create New Plugin\n"); + + // Check if template directory exists + if (!existsSync(TEMPLATE_DIR)) { + console.error( + "❌ Error: Template directory not found at plugins/_template/" + ); + console.error(" Make sure the template files are present.\n"); + process.exit(1); + } + + // Prompt for plugin details + const integrationName = await input({ + message: "Integration name:", + validate: (value) => { + if (!value.trim()) { + return "Integration name is required"; + } + const kebab = toKebabCase(value); + const dir = join(PLUGINS_DIR, kebab); + if (existsSync(dir)) { + return `Plugin already exists at plugins/${kebab}/`; + } + return true; + }, + }); + + const integrationDescription = await input({ + message: "Integration description (<10 words):", + required: true, + }); + + const actionName = await input({ + message: "Action name:", + required: true, + }); + + const actionDescription = await input({ + message: "Action description (<10 words):", + required: true, + }); + + const answers: PluginConfig = { + integrationName, + integrationDescription, + actionName, + actionDescription, + }; + + const pluginName = toKebabCase(integrationName); + const actionSlug = toKebabCase(actionName); + const pluginDir = join(PLUGINS_DIR, pluginName); + + console.log(`\n📁 Creating plugin: ${pluginName}`); + + // Create directories + mkdirSync(join(pluginDir, "steps"), { recursive: true }); + mkdirSync(join(pluginDir, "codegen"), { recursive: true }); + + // Copy and process template files + const createdFiles: string[] = []; + const templateFiles = getTemplateFiles(actionSlug); + + for (const { src, dest } of templateFiles) { + const srcPath = join(TEMPLATE_DIR, src); + const destPath = join(pluginDir, dest); + + if (!existsSync(srcPath)) { + console.error(`\n❌ Error: Template file not found: ${src}`); + console.error(" The template directory may be corrupted.\n"); + process.exit(1); + } + + let content = readFileSync(srcPath, "utf-8"); + content = replacePlaceholders(content, answers); + + writeFileSync(destPath, content, "utf-8"); + createdFiles.push(`plugins/${pluginName}/${dest}`); + } + + // Print created files + console.log(`\n✅ Created plugin at plugins/${pluginName}/\n`); + console.log("Files created:"); + for (const file of createdFiles) { + console.log(` - ${file}`); + } + + // Run discover-plugins to register the new plugin + console.log("\n🔍 Running plugin discovery..."); + execFileSync("pnpm", ["discover-plugins"], { stdio: "inherit" }); + + console.log( + `\n🎉 Plugin "${answers.integrationName}" has been added to the registry!\n` + ); + console.log("Next steps:"); + console.log(` 1. Review and customize the files in plugins/${pluginName}/`); + console.log(" 2. Update the icon in icon.tsx with your integration's SVG"); + console.log(" 3. Implement the API logic in steps/ and codegen/"); + console.log(" 4. Test: pnpm dev\n"); +} + +// Handle user cancellation (Ctrl+C) gracefully +process.on("uncaughtException", (error) => { + if (error instanceof Error && error.name === "ExitPromptError") { + console.log("\n👋 Come back anytime to create your plugin.\n"); + process.exit(0); + } + throw error; +}); + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error("❌ Error:", message); + process.exit(1); +}); From 6405fd328f2bd74f739ed0105516e40300b3cd87 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sun, 30 Nov 2025 21:11:35 +1100 Subject: [PATCH 2/8] Fix ExitPromptError handling in create-plugin script --- scripts/create-plugin.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/scripts/create-plugin.ts b/scripts/create-plugin.ts index 91f6f207..b96b8acf 100644 --- a/scripts/create-plugin.ts +++ b/scripts/create-plugin.ts @@ -230,16 +230,12 @@ async function main(): Promise { console.log(" 4. Test: pnpm dev\n"); } -// Handle user cancellation (Ctrl+C) gracefully -process.on("uncaughtException", (error) => { +main().catch((error: unknown) => { + // Handle user cancellation (Ctrl+C) gracefully if (error instanceof Error && error.name === "ExitPromptError") { console.log("\n👋 Come back anytime to create your plugin.\n"); process.exit(0); } - throw error; -}); - -main().catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); console.error("❌ Error:", message); process.exit(1); From 9fe1ce02c2a153e69dbe4a2ad63ed817900eb79c Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sun, 30 Nov 2025 21:37:07 +1100 Subject: [PATCH 3/8] Add JavaScript identifier validation to create-plugin script --- scripts/create-plugin.ts | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/scripts/create-plugin.ts b/scripts/create-plugin.ts index b96b8acf..381c3948 100644 --- a/scripts/create-plugin.ts +++ b/scripts/create-plugin.ts @@ -18,6 +18,7 @@ const TEMPLATE_DIR = join(PLUGINS_DIR, "_template"); // Regex patterns used for case conversions (hoisted for performance) const LEADING_UPPERCASE_REGEX = /^[A-Z]/; +const VALID_IDENTIFIER_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; /** * Convert a string to various case formats @@ -56,6 +57,13 @@ function toTitleCase(str: string): string { ); } +/** + * Check if a string is a valid JavaScript identifier + */ +function isValidIdentifier(str: string): boolean { + return VALID_IDENTIFIER_REGEX.test(str); +} + type PluginConfig = { integrationName: string; integrationDescription: string; @@ -147,6 +155,11 @@ async function main(): Promise { if (!value.trim()) { return "Integration name is required"; } + const camel = toCamelCase(value); + const pascal = toPascalCase(value); + if (!(isValidIdentifier(camel) && isValidIdentifier(pascal))) { + return `Integration name must produce valid JavaScript identifiers. "${value}" converts to "${camel}" (camelCase) and "${pascal}" (PascalCase). Use only letters, numbers, underscores, and dollar signs.`; + } const kebab = toKebabCase(value); const dir = join(PLUGINS_DIR, kebab); if (existsSync(dir)) { @@ -158,17 +171,37 @@ async function main(): Promise { const integrationDescription = await input({ message: "Integration description (<10 words):", - required: true, + validate: (value) => { + if (!value.trim()) { + return "Integration description is required"; + } + return true; + }, }); const actionName = await input({ message: "Action name:", - required: true, + validate: (value) => { + if (!value.trim()) { + return "Action name is required"; + } + const camel = toCamelCase(value); + const pascal = toPascalCase(value); + if (!(isValidIdentifier(camel) && isValidIdentifier(pascal))) { + return `Action name must produce valid JavaScript identifiers. "${value}" converts to "${camel}" (camelCase) and "${pascal}" (PascalCase). Use only letters, numbers, underscores, and dollar signs.`; + } + return true; + }, }); const actionDescription = await input({ message: "Action description (<10 words):", - required: true, + validate: (value) => { + if (!value.trim()) { + return "Action description is required"; + } + return true; + }, }); const answers: PluginConfig = { From d0a66e62e39c346838a257f7de9f03685016ebdf Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Sun, 30 Nov 2025 23:53:01 +1100 Subject: [PATCH 4/8] Fix string escaping and add path traversal protection in create-plugin --- plugins/_template/index.ts.txt | 4 +-- plugins/_template/steps/action.ts.txt | 2 +- scripts/create-plugin.ts | 44 ++++++++++++++++++++------- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/plugins/_template/index.ts.txt b/plugins/_template/index.ts.txt index 60edefe6..54c8adbd 100644 --- a/plugins/_template/index.ts.txt +++ b/plugins/_template/index.ts.txt @@ -27,7 +27,7 @@ const [integrationName]Plugin: IntegrationPlugin = { label: "[Integration Name]", // Brief description of what this integration does - description: "[Brief description of the integration]", + description: "[Integration Description]", // Icon component - imported from ./icon.tsx // Can be a custom SVG component or use a Lucide icon directly @@ -76,7 +76,7 @@ const [integrationName]Plugin: IntegrationPlugin = { // Display name and description label: "[Action Label]", - description: "[What this action does]", + description: "[Action Description]", // Category for grouping in the action picker (usually integration name) category: "[Integration Name]", diff --git a/plugins/_template/steps/action.ts.txt b/plugins/_template/steps/action.ts.txt index 3ec24f6b..68172707 100644 --- a/plugins/_template/steps/action.ts.txt +++ b/plugins/_template/steps/action.ts.txt @@ -84,7 +84,7 @@ async function [actionName](input: [ActionName]Input): Promise<[ActionName]Resul /** * [Action Name] Step - * [Brief description of what this step does] + * [Action Description] * * The step function wraps the logic with: * - "use step" directive for workflow tracking diff --git a/scripts/create-plugin.ts b/scripts/create-plugin.ts index 381c3948..6b028a24 100644 --- a/scripts/create-plugin.ts +++ b/scripts/create-plugin.ts @@ -19,6 +19,7 @@ const TEMPLATE_DIR = join(PLUGINS_DIR, "_template"); // Regex patterns used for case conversions (hoisted for performance) const LEADING_UPPERCASE_REGEX = /^[A-Z]/; const VALID_IDENTIFIER_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; +const UNSAFE_PATH_REGEX = /[/\\]|\.\./; /** * Convert a string to various case formats @@ -57,6 +58,22 @@ function toTitleCase(str: string): string { ); } +/** + * Escape special characters for use in string literals. + */ +function escapeString(str: string): string { + return str + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/'/g, "\\'") + .replace(/`/g, "\\`") + .replace(/\$/g, "\\$") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t") + .replace(/\0/g, "\\0"); +} + /** * Check if a string is a valid JavaScript identifier */ @@ -106,8 +123,8 @@ function replacePlaceholders(content: string, config: PluginConfig): string { .replace(/\[INTEGRATION_NAME\]/g, intUpperSnake) .replace(/\[Integration Name\]/g, intTitle) .replace( - /\[Brief description of the integration\]/g, - integrationDescription + /\[Integration Description\]/g, + escapeString(integrationDescription) ) // Action placeholders .replace(/\[action-slug\]/g, actKebab) @@ -116,7 +133,7 @@ function replacePlaceholders(content: string, config: PluginConfig): string { .replace(/\[ACTION_NAME\]/g, actUpperSnake) .replace(/\[Action Name\]/g, actTitle) .replace(/\[Action Label\]/g, actTitle) - .replace(/\[What this action does\]/g, actionDescription) + .replace(/\[Action Description\]/g, escapeString(actionDescription)) ); } @@ -137,7 +154,7 @@ function getTemplateFiles(actionSlug: string) { * Main execution */ async function main(): Promise { - console.log("\n🔧 Create New Plugin\n"); + console.log("\n🔧 Create New Plugin for Workflow Builder\n"); // Check if template directory exists if (!existsSync(TEMPLATE_DIR)) { @@ -150,11 +167,14 @@ async function main(): Promise { // Prompt for plugin details const integrationName = await input({ - message: "Integration name:", + message: "Integration Name:", validate: (value) => { if (!value.trim()) { return "Integration name is required"; } + if (UNSAFE_PATH_REGEX.test(value)) { + return "Name cannot contain path separators (/, \\) or '..'"; + } const camel = toCamelCase(value); const pascal = toPascalCase(value); if (!(isValidIdentifier(camel) && isValidIdentifier(pascal))) { @@ -170,7 +190,7 @@ async function main(): Promise { }); const integrationDescription = await input({ - message: "Integration description (<10 words):", + message: "Integration Description (<10 words):", validate: (value) => { if (!value.trim()) { return "Integration description is required"; @@ -180,11 +200,14 @@ async function main(): Promise { }); const actionName = await input({ - message: "Action name:", + message: "Action Name:", validate: (value) => { if (!value.trim()) { return "Action name is required"; } + if (UNSAFE_PATH_REGEX.test(value)) { + return "Name cannot contain path separators (/, \\) or '..'"; + } const camel = toCamelCase(value); const pascal = toPascalCase(value); if (!(isValidIdentifier(camel) && isValidIdentifier(pascal))) { @@ -195,7 +218,7 @@ async function main(): Promise { }); const actionDescription = await input({ - message: "Action description (<10 words):", + message: "Action Description (<10 words):", validate: (value) => { if (!value.trim()) { return "Action description is required"; @@ -215,7 +238,7 @@ async function main(): Promise { const actionSlug = toKebabCase(actionName); const pluginDir = join(PLUGINS_DIR, pluginName); - console.log(`\n📁 Creating plugin: ${pluginName}`); + console.log(`\n📁 Generating plugin: ${pluginName}`); // Create directories mkdirSync(join(pluginDir, "steps"), { recursive: true }); @@ -250,7 +273,7 @@ async function main(): Promise { } // Run discover-plugins to register the new plugin - console.log("\n🔍 Running plugin discovery..."); + console.log("\n🔍 Adding plugin to registry..."); execFileSync("pnpm", ["discover-plugins"], { stdio: "inherit" }); console.log( @@ -264,7 +287,6 @@ async function main(): Promise { } main().catch((error: unknown) => { - // Handle user cancellation (Ctrl+C) gracefully if (error instanceof Error && error.name === "ExitPromptError") { console.log("\n👋 Come back anytime to create your plugin.\n"); process.exit(0); From 06aa2765ca46b54d5450bdc2d06473c7453b2890 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Mon, 1 Dec 2025 11:56:20 +1100 Subject: [PATCH 5/8] feat: Convert HTTP Request from system action to native plugin This refactors the HTTP Request action from a hardcoded system action into a proper plugin under plugins/native/, making it extensible and consistent with the plugin architecture. ## New Plugin: Native Created plugins/native/ with: - index.ts: Plugin definition with HTTP Request action - icon.tsx: Globe icon for the Native category - steps/http-request.ts: Step function with execution logic - codegen/http-request.ts: Code generation template ## New Component: ObjectBuilder Added components/workflow/config/object-builder.tsx - a reusable component for building key-value pairs (similar to SchemaBuilder). Used for HTTP headers and body configuration instead of JSON editors. ## New Config Field Type Added 'object-builder' type to ActionConfigField in plugins/registry.ts and the corresponding renderer in action-config-renderer.tsx. ## Migration & Backward Compatibility - Added 'HTTP Request' -> 'native/http-request' legacy mapping - Updated codegen files to support both legacy and new action IDs - Existing workflows using "HTTP Request" will continue to work ## Optional Integration Support - Add requiresIntegration flag to plugin registry for plugins that don't need credentials - Add shared requiresIntegration() function to centralize integration checks - Hide Native plugin from integration setup dialog since it needs no config ## Integration Loading Optimization - Cache integrations in Jotai store for faster loading when switching actions - Prefetch integrations when workflow loads ## Cleanup Removed HTTP Request from system actions in: - lib/steps/http-request.ts (deleted) - lib/codegen-templates/http-request.ts (deleted) - lib/workflow-executor.workflow.ts - lib/steps/index.ts - components/workflow/config/action-config.tsx - components/workflow/nodes/action-node.tsx - components/workflow/config/action-grid.tsx ## Integration-Based HTTP Requests Added httpConfig to plugins for leveraging existing integrations: - New PluginHttpConfig type with baseUrl, authHeader, authPrefix, authCredentialKey - Added httpConfig to Resend, Slack, Firecrawl, and Linear plugins - New 'integration-select' field type for selecting HTTP-enabled integrations - HTTP Request step auto-injects auth headers from selected integration - Endpoint field shows baseUrl prefix when integration is selected ## JSON Editor for Request Body - New 'json-editor' field type using Monaco editor - Supports nested objects, arrays, numbers, booleans - Real-time JSON validation with error display - Replaced object-builder for HTTP body configuration ## Header Validation - Added validateKey/validateValue functions to ActionConfigField - Plugin-defined validation (validation logic lives in plugin, not renderer) - HTTP headers validated per Cloudflare rules (alphanumeric + hyphen/underscore) - Content-Type header blocked (auto-set to application/json) ## UI Improvements - TemplateBadgeInput supports optional prefix prop - Extracted PropertyRow component to reduce ObjectBuilder complexity - Horizontal scroll instead of wrap for long input values --- README.md | 1 + app/workflows/[workflowId]/page.tsx | 6 + .../settings/integration-form-dialog.tsx | 8 +- components/ui/integration-selector.tsx | 55 +++-- components/ui/template-autocomplete.tsx | 6 +- components/ui/template-badge-input.tsx | 19 +- .../config/action-config-renderer.tsx | 231 +++++++++++++++++- components/workflow/config/action-config.tsx | 100 -------- components/workflow/config/action-grid.tsx | 11 +- components/workflow/config/object-builder.tsx | 214 ++++++++++++++++ components/workflow/node-config-panel.tsx | 25 +- components/workflow/nodes/action-node.tsx | 23 +- components/workflow/utils/code-generators.ts | 2 - components/workflow/workflow-toolbar.tsx | 16 +- lib/codegen-templates/http-request.ts | 47 ---- lib/integrations-store.ts | 35 ++- lib/step-registry.ts | 20 +- lib/steps/http-request.ts | 103 -------- lib/steps/index.ts | 5 - lib/types/integration.ts | 3 +- lib/workflow-codegen-sdk.ts | 51 +++- lib/workflow-codegen-shared.ts | 4 - lib/workflow-codegen.ts | 52 +++- lib/workflow-executor.workflow.ts | 7 +- plugins/firecrawl/index.ts | 9 + plugins/index.ts | 8 +- plugins/legacy-mappings.ts | 3 + plugins/linear/index.ts | 8 + plugins/native/codegen/http-request.ts | 36 +++ plugins/native/icon.tsx | 25 ++ plugins/native/index.ts | 93 +++++++ plugins/native/steps/http-request.ts | 204 ++++++++++++++++ plugins/registry.ts | 71 +++++- plugins/resend/index.ts | 9 + plugins/slack/index.ts | 9 + scripts/discover-plugins.ts | 5 +- 36 files changed, 1150 insertions(+), 374 deletions(-) create mode 100644 components/workflow/config/object-builder.tsx delete mode 100644 lib/codegen-templates/http-request.ts delete mode 100644 lib/steps/http-request.ts create mode 100644 plugins/native/codegen/http-request.ts create mode 100644 plugins/native/icon.tsx create mode 100644 plugins/native/index.ts create mode 100644 plugins/native/steps/http-request.ts diff --git a/README.md b/README.md index b08be906..5fec60fb 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Visit [http://localhost:3000](http://localhost:3000) to get started. - **AI Gateway**: Generate Text, Generate Image - **Firecrawl**: Scrape URL, Search Web - **Linear**: Create Ticket, Find Issues +- **Native**: HTTP Request - **Resend**: Send Email - **Slack**: Send Slack Message - **v0**: Create Chat, Send Message diff --git a/app/workflows/[workflowId]/page.tsx b/app/workflows/[workflowId]/page.tsx index fa1de680..514e7f79 100644 --- a/app/workflows/[workflowId]/page.tsx +++ b/app/workflows/[workflowId]/page.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { NodeConfigPanel } from "@/components/workflow/node-config-panel"; import { useIsMobile } from "@/hooks/use-mobile"; import { api } from "@/lib/api-client"; +import { fetchIntegrationsAtom } from "@/lib/integrations-store"; import { currentWorkflowIdAtom, currentWorkflowNameAtom, @@ -55,6 +56,7 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { const setTriggerExecute = useSetAtom(triggerExecuteAtom); const setRightPanelWidth = useSetAtom(rightPanelWidthAtom); const setIsPanelAnimating = useSetAtom(isPanelAnimatingAtom); + const fetchIntegrations = useSetAtom(fetchIntegrationsAtom); const [hasSidebarBeenShown, setHasSidebarBeenShown] = useAtom( hasSidebarBeenShownAtom ); @@ -315,6 +317,9 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { const storedPrompt = sessionStorage.getItem("ai-prompt"); const storedWorkflowId = sessionStorage.getItem("generating-workflow-id"); + // Prefetch integrations in parallel with workflow loading + fetchIntegrations(); + // Check if state is already loaded for this workflow if (currentWorkflowId === workflowId && nodes.length > 0) { return; @@ -342,6 +347,7 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { nodes.length, generateWorkflowFromAI, loadExistingWorkflow, + fetchIntegrations, ]); // Keyboard shortcuts diff --git a/components/settings/integration-form-dialog.tsx b/components/settings/integration-form-dialog.tsx index 9312cc53..1e12878f 100644 --- a/components/settings/integration-form-dialog.tsx +++ b/components/settings/integration-form-dialog.tsx @@ -51,9 +51,13 @@ const SYSTEM_INTEGRATION_LABELS: Record = { database: "Database", }; -// Get all integration types (plugins + system) +// Get all integration types (plugins that require integration + system) +// Excludes plugins with requiresIntegration: false (like Native) const getIntegrationTypes = (): IntegrationType[] => [ - ...getSortedIntegrationTypes(), + ...getSortedIntegrationTypes().filter((type) => { + const plugin = getIntegration(type); + return plugin?.requiresIntegration !== false; + }), ...SYSTEM_INTEGRATION_TYPES, ]; diff --git a/components/ui/integration-selector.tsx b/components/ui/integration-selector.tsx index 46e6b853..deb39749 100644 --- a/components/ui/integration-selector.tsx +++ b/components/ui/integration-selector.tsx @@ -1,7 +1,8 @@ "use client"; +import { useAtomValue, useSetAtom } from "jotai"; import { AlertTriangle } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Select, SelectContent, @@ -10,7 +11,12 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { api, type Integration } from "@/lib/api-client"; +import { + fetchIntegrationsAtom, + integrationsAtom, + integrationsFetchedAtom, + integrationsLoadingAtom, +} from "@/lib/integrations-store"; import type { IntegrationType } from "@/lib/types/integration"; import { IntegrationFormDialog } from "@/components/settings/integration-form-dialog"; @@ -31,32 +37,31 @@ export function IntegrationSelector({ label, disabled, }: IntegrationSelectorProps) { - const [integrations, setIntegrations] = useState([]); - const [loading, setLoading] = useState(true); + const allIntegrations = useAtomValue(integrationsAtom); + const loading = useAtomValue(integrationsLoadingAtom); + const fetched = useAtomValue(integrationsFetchedAtom); + const fetchIntegrations = useSetAtom(fetchIntegrationsAtom); const [showNewDialog, setShowNewDialog] = useState(false); - const loadIntegrations = async () => { - try { - setLoading(true); - const all = await api.integration.getAll(); - const filtered = all.filter((i) => i.type === integrationType); - setIntegrations(filtered); - - // Auto-select if only one option and nothing selected yet - if (filtered.length === 1 && !value) { - onChange(filtered[0].id); - } - } catch (error) { - console.error("Failed to load integrations:", error); - } finally { - setLoading(false); + // Filter integrations by type + const integrations = useMemo( + () => allIntegrations.filter((i) => i.type === integrationType), + [allIntegrations, integrationType] + ); + + // Fetch integrations on mount if not already fetched + useEffect(() => { + if (!fetched && !loading) { + fetchIntegrations(); } - }; + }, [fetched, loading, fetchIntegrations]); + // Auto-select if only one option and nothing selected yet useEffect(() => { - loadIntegrations(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [integrationType]); + if (integrations.length === 1 && !value && fetched) { + onChange(integrations[0].id); + } + }, [integrations, value, fetched, onChange]); const handleValueChange = (newValue: string) => { if (newValue === "__new__") { @@ -69,12 +74,12 @@ export function IntegrationSelector({ }; const handleNewIntegrationCreated = async (integrationId: string) => { - await loadIntegrations(); + await fetchIntegrations(); onChange(integrationId); setShowNewDialog(false); }; - if (loading) { + if (loading || !fetched) { return ( + + + + + ); + } + + const getPluginLabel = (type: string): string => { + const plugin = getIntegration(type as Parameters[0]); + return plugin?.label || type; + }; + + return ( + + ); +} + const FIELD_RENDERERS: Record< ActionConfigField["type"], React.ComponentType @@ -118,6 +336,9 @@ const FIELD_RENDERERS: Record< number: NumberInputField, select: SelectField, "schema-builder": SchemaBuilderField, + "object-builder": ObjectBuilderField, + "integration-select": IntegrationSelectField, + "json-editor": JsonEditorField, }; type ActionConfigRendererProps = { @@ -127,10 +348,6 @@ type ActionConfigRendererProps = { disabled?: boolean; }; -/** - * Renders action config fields declaratively - * Converts ActionConfigField definitions into actual UI components - */ export function ActionConfigRenderer({ fields, config, @@ -140,7 +357,6 @@ export function ActionConfigRenderer({ return ( <> {fields.map((field) => { - // Check conditional rendering if (field.showWhen) { const dependentValue = config[field.showWhen.field]; if (dependentValue !== field.showWhen.equals) { @@ -158,6 +374,7 @@ export function ActionConfigRenderer({ {field.label} onUpdateConfig(field.key, val)} diff --git a/components/workflow/config/action-config.tsx b/components/workflow/config/action-config.tsx index 3931cf05..f0306770 100644 --- a/components/workflow/config/action-config.tsx +++ b/components/workflow/config/action-config.tsx @@ -80,97 +80,6 @@ function DatabaseQueryFields({ ); } -// HTTP Request fields component -function HttpRequestFields({ - config, - onUpdateConfig, - disabled, -}: { - config: Record; - onUpdateConfig: (key: string, value: string) => void; - disabled: boolean; -}) { - return ( - <> -
- - -
-
- - onUpdateConfig("endpoint", value)} - placeholder="https://api.example.com/endpoint or {{NodeName.url}}" - value={(config?.endpoint as string) || ""} - /> -
-
- -
- onUpdateConfig("httpHeaders", value || "{}")} - options={{ - minimap: { enabled: false }, - lineNumbers: "off", - scrollBeyondLastLine: false, - fontSize: 12, - readOnly: disabled, - wordWrap: "off", - }} - value={(config?.httpHeaders as string) || "{}"} - /> -
-
-
- -
- onUpdateConfig("httpBody", value || "{}")} - options={{ - minimap: { enabled: false }, - lineNumbers: "off", - scrollBeyondLastLine: false, - fontSize: 12, - readOnly: config?.httpMethod === "GET" || disabled, - domReadOnly: config?.httpMethod === "GET" || disabled, - wordWrap: "off", - }} - value={(config?.httpBody as string) || "{}"} - /> -
- {config?.httpMethod === "GET" && ( -

- Body is disabled for GET requests -

- )} -
- - ); -} - // Condition fields component function ConditionFields({ config, @@ -201,7 +110,6 @@ function ConditionFields({ // System actions that don't have plugins const SYSTEM_ACTIONS: Array<{ id: string; label: string }> = [ - { id: "HTTP Request", label: "HTTP Request" }, { id: "Database Query", label: "Database Query" }, { id: "Condition", label: "Condition" }, ]; @@ -365,14 +273,6 @@ export function ActionConfig({ {/* System actions - hardcoded config fields */} - {config?.actionType === "HTTP Request" && ( - - )} - {config?.actionType === "Database Query" && ( ; } - return ; + return ; } export function ActionGrid({ onSelectAction, disabled }: ActionGridProps) { diff --git a/components/workflow/config/object-builder.tsx b/components/workflow/config/object-builder.tsx new file mode 100644 index 00000000..8d89e56c --- /dev/null +++ b/components/workflow/config/object-builder.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { Plus, Trash2 } from "lucide-react"; +import { nanoid } from "nanoid"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { TemplateBadgeInput } from "@/components/ui/template-badge-input"; + +export type ObjectProperty = { + id: string; + key: string; + value: string; +}; + +type ValidateFn = ( + value: string, + property: ObjectProperty +) => string | undefined; + +type PropertyRowProps = { + prop: ObjectProperty; + index: number; + disabled?: boolean; + keyPlaceholder: string; + valuePlaceholder: string; + keyLabel: string; + valueLabel: string; + supportsTemplates: boolean; + keyError?: string; + valueError?: string; + onUpdate: (updates: Partial) => void; + onRemove: () => void; +}; + +function PropertyRow({ + prop, + index, + disabled, + keyPlaceholder, + valuePlaceholder, + keyLabel, + valueLabel, + supportsTemplates, + keyError, + valueError, + onUpdate, + onRemove, +}: PropertyRowProps) { + return ( +
+
+
+ {index === 0 && ( + + )} + onUpdate({ key: e.target.value })} + placeholder={keyPlaceholder} + value={prop.key} + /> +
+
+ {index === 0 && ( + + )} + {supportsTemplates ? ( + onUpdate({ value })} + placeholder={valuePlaceholder} + value={prop.value} + /> + ) : ( + onUpdate({ value: e.target.value })} + placeholder={valuePlaceholder} + value={prop.value} + /> + )} +
+
+ {index === 0 &&
} + +
+
+ {(keyError || valueError) && ( +

{keyError || valueError}

+ )} +
+ ); +} + +type ObjectBuilderProps = { + properties: ObjectProperty[]; + onChange: (properties: ObjectProperty[]) => void; + disabled?: boolean; + keyPlaceholder?: string; + valuePlaceholder?: string; + keyLabel?: string; + valueLabel?: string; + supportsTemplates?: boolean; + validateKey?: ValidateFn; + validateValue?: ValidateFn; +}; + +export function ObjectBuilder({ + properties, + onChange, + disabled, + keyPlaceholder = "key", + valuePlaceholder = "value", + keyLabel = "Key", + valueLabel = "Value", + supportsTemplates = true, + validateKey, + validateValue, +}: ObjectBuilderProps) { + const addProperty = () => { + onChange([...properties, { id: nanoid(), key: "", value: "" }]); + }; + + const updateProperty = (index: number, updates: Partial) => { + const newProperties = [...properties]; + newProperties[index] = { ...newProperties[index], ...updates }; + onChange(newProperties); + }; + + const removeProperty = (index: number) => { + onChange(properties.filter((_, i) => i !== index)); + }; + + return ( +
+ {properties.map((prop, index) => ( + removeProperty(index)} + onUpdate={(updates) => updateProperty(index, updates)} + prop={prop} + supportsTemplates={supportsTemplates} + valueError={validateValue?.(prop.value, prop)} + valueLabel={valueLabel} + valuePlaceholder={valuePlaceholder} + /> + ))} + + +
+ ); +} + +export function propertiesToObject( + properties: ObjectProperty[] +): Record { + const obj: Record = {}; + for (const prop of properties) { + if (prop.key.trim()) { + obj[prop.key] = prop.value; + } + } + return obj; +} + +export function objectToProperties( + obj: Record | undefined | null +): ObjectProperty[] { + if (!obj || typeof obj !== "object") { + return []; + } + return Object.entries(obj).map(([key, value]) => ({ + id: nanoid(), + key, + value: String(value), + })); +} diff --git a/components/workflow/node-config-panel.tsx b/components/workflow/node-config-panel.tsx index 6442eeb0..b89ccc4a 100644 --- a/components/workflow/node-config-panel.tsx +++ b/components/workflow/node-config-panel.tsx @@ -46,7 +46,7 @@ import { showDeleteDialogAtom, updateNodeDataAtom, } from "@/lib/workflow-store"; -import { findActionById } from "@/plugins"; +import { findActionById, requiresIntegration } from "@/plugins"; import { Panel } from "../ai-elements/panel"; import { IntegrationsDialog } from "../settings/integrations-dialog"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; @@ -756,23 +756,26 @@ export const PanelInner = () => { const actionType = selectedNode.data.config ?.actionType as string; + if (!actionType) { + return null; + } + // Database Query is special - has integration but no plugin const SYSTEM_INTEGRATION_MAP: Record = { "Database Query": "database", }; - // Get integration type dynamically - let integrationType: string | undefined; - if (actionType) { - if (SYSTEM_INTEGRATION_MAP[actionType]) { - integrationType = SYSTEM_INTEGRATION_MAP[actionType]; - } else { - // Look up from plugin registry - const action = findActionById(actionType); - integrationType = action?.integration; - } + // Check if this action requires integration + const isSystemAction = actionType in SYSTEM_INTEGRATION_MAP; + if (!(isSystemAction || requiresIntegration(actionType))) { + return null; } + // Get integration type + const integrationType = isSystemAction + ? SYSTEM_INTEGRATION_MAP[actionType] + : findActionById(actionType)?.integration; + return integrationType ? ( { @@ -68,7 +68,6 @@ const getModelDisplayName = (modelId: string): string => { // System action labels (non-plugin actions) const SYSTEM_ACTION_LABELS: Record = { - "HTTP Request": "System", "Database Query": "Database", Condition: "Condition", "Execute Code": "System", @@ -102,18 +101,8 @@ function isBase64ImageOutput(output: unknown): output is { base64: string } { ); } -// Helper to check if an action requires an integration -const requiresIntegration = (actionType: string): boolean => { - // System actions that require integration configuration - const systemActionsRequiringIntegration = ["Database Query"]; - if (systemActionsRequiringIntegration.includes(actionType)) { - return true; - } - - // Plugin actions always require integration - const action = findActionById(actionType); - return action !== undefined; -}; +// System actions that require integration (not in plugin registry) +const SYSTEM_ACTIONS_REQUIRING_INTEGRATION = ["Database Query"]; // Helper to check if integration is configured // Now checks for integrationId in node config @@ -124,8 +113,6 @@ const hasIntegrationConfigured = (config: Record): boolean => const getProviderLogo = (actionType: string) => { // Check for system actions first (non-plugin) switch (actionType) { - case "HTTP Request": - return ; case "Database Query": return ; case "Execute Code": @@ -301,7 +288,9 @@ export const ActionNode = memo(({ data, selected, id }: ActionNodeProps) => { const displayDescription = data.description || getIntegrationFromActionType(actionType); - const needsIntegration = requiresIntegration(actionType); + const needsIntegration = + SYSTEM_ACTIONS_REQUIRING_INTEGRATION.includes(actionType) || + requiresIntegration(actionType); // Don't show missing indicator if we're still checking for auto-select const isPendingIntegrationCheck = pendingIntegrationNodes.has(id); const integrationMissing = diff --git a/components/workflow/utils/code-generators.ts b/components/workflow/utils/code-generators.ts index 328d2709..327df11b 100644 --- a/components/workflow/utils/code-generators.ts +++ b/components/workflow/utils/code-generators.ts @@ -4,13 +4,11 @@ import conditionTemplate from "@/lib/codegen-templates/condition"; import databaseQueryTemplate from "@/lib/codegen-templates/database-query"; -import httpRequestTemplate from "@/lib/codegen-templates/http-request"; import { findActionById } from "@/plugins"; // System action templates (non-plugin actions) const SYSTEM_ACTION_TEMPLATES: Record = { "Database Query": databaseQueryTemplate, - "HTTP Request": httpRequestTemplate, Condition: conditionTemplate, }; diff --git a/components/workflow/workflow-toolbar.tsx b/components/workflow/workflow-toolbar.tsx index a4220f2e..bcb59079 100644 --- a/components/workflow/workflow-toolbar.tsx +++ b/components/workflow/workflow-toolbar.tsx @@ -84,7 +84,11 @@ import { type WorkflowEdge, type WorkflowNode, } from "@/lib/workflow-store"; -import { findActionById, getIntegrationLabels } from "@/plugins"; +import { + findActionById, + getIntegrationLabels, + requiresIntegration, +} from "@/plugins"; import { Panel } from "../ai-elements/panel"; import { DeployButton } from "../deploy-button"; import { GitHubStarsButton } from "../github-stars-button"; @@ -324,6 +328,7 @@ function getMissingRequiredFields( // Get missing integrations for workflow nodes // Uses the plugin registry to determine which integrations are required // Also handles built-in actions that aren't in the plugin registry +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: validation logic with multiple checks function getMissingIntegrations( nodes: WorkflowNode[], userIntegrations: Array<{ type: IntegrationType }> @@ -343,9 +348,14 @@ function getMissingIntegrations( continue; } - // Look up the integration type from the plugin registry first + // Check if this action requires integration (respects requiresIntegration flag) + const isBuiltinAction = actionType in BUILTIN_ACTION_INTEGRATIONS; + if (!(isBuiltinAction || requiresIntegration(actionType))) { + continue; + } + + // Get the integration type const action = findActionById(actionType); - // Fall back to built-in action integrations for actions not in the registry const requiredIntegrationType = action?.integration || BUILTIN_ACTION_INTEGRATIONS[actionType]; diff --git a/lib/codegen-templates/http-request.ts b/lib/codegen-templates/http-request.ts deleted file mode 100644 index d297e640..00000000 --- a/lib/codegen-templates/http-request.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Code template for HTTP Request action step - * This is a string template used for code generation - keep as string export - */ -export default `export async function httpRequestStep(input: { - endpoint: string; - httpMethod: string; - httpHeaders?: string; - httpBody?: string; -}) { - "use step"; - - let headers = {}; - if (input.httpHeaders) { - try { - headers = JSON.parse(input.httpHeaders); - } catch { - // If parsing fails, use empty headers - } - } - - let body: string | undefined; - if (input.httpMethod !== "GET" && input.httpBody) { - try { - const parsedBody = JSON.parse(input.httpBody); - if (Object.keys(parsedBody).length > 0) { - body = JSON.stringify(parsedBody); - } - } catch { - if (input.httpBody.trim() && input.httpBody.trim() !== "{}") { - body = input.httpBody; - } - } - } - - const response = await fetch(input.endpoint, { - method: input.httpMethod, - headers, - body, - }); - - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - return await response.json(); - } - return await response.text(); -}`; diff --git a/lib/integrations-store.ts b/lib/integrations-store.ts index 0bb59822..39d90e04 100644 --- a/lib/integrations-store.ts +++ b/lib/integrations-store.ts @@ -1,8 +1,41 @@ import { atom } from "jotai"; -import type { Integration } from "@/lib/api-client"; +import { api, type Integration } from "@/lib/api-client"; // Store for all user integrations export const integrationsAtom = atom([]); +// Loading state for integrations +export const integrationsLoadingAtom = atom(false); + +// Track if integrations have been fetched at least once +export const integrationsFetchedAtom = atom(false); + // Selected integration for forms/dialogs export const selectedIntegrationAtom = atom(null); + +// Fetch integrations action - returns the fetched integrations +export const fetchIntegrationsAtom = atom(null, async (get, set) => { + // Skip if already loading + if (get(integrationsLoadingAtom)) { + return get(integrationsAtom); + } + + set(integrationsLoadingAtom, true); + try { + const integrations = await api.integration.getAll(); + set(integrationsAtom, integrations); + set(integrationsFetchedAtom, true); + return integrations; + } catch (error) { + console.error("Failed to fetch integrations:", error); + return get(integrationsAtom); + } finally { + set(integrationsLoadingAtom, false); + } +}); + +// Get integrations by type (derived atom) +export const integrationsByTypeAtom = atom((get) => { + const integrations = get(integrationsAtom); + return (type: string) => integrations.filter((i) => i.type === type); +}); diff --git a/lib/step-registry.ts b/lib/step-registry.ts index 6a71649b..6f2f2074 100644 --- a/lib/step-registry.ts +++ b/lib/step-registry.ts @@ -7,7 +7,7 @@ * This registry enables dynamic step imports that are statically analyzable * by the bundler. Each action type maps to its step importer function. * - * Generated entries: 10 + * Generated entries: 11 */ import "server-only"; @@ -45,7 +45,7 @@ export const PLUGIN_STEP_IMPORTERS: Record = { importer: () => import("@/plugins/firecrawl/steps/scrape"), stepFunction: "firecrawlScrapeStep", }, - Scrape: { + "Scrape": { importer: () => import("@/plugins/firecrawl/steps/scrape"), stepFunction: "firecrawlScrapeStep", }, @@ -53,7 +53,7 @@ export const PLUGIN_STEP_IMPORTERS: Record = { importer: () => import("@/plugins/firecrawl/steps/search"), stepFunction: "firecrawlSearchStep", }, - Search: { + "Search": { importer: () => import("@/plugins/firecrawl/steps/search"), stepFunction: "firecrawlSearchStep", }, @@ -73,6 +73,14 @@ export const PLUGIN_STEP_IMPORTERS: Record = { importer: () => import("@/plugins/linear/steps/find-issues"), stepFunction: "findIssuesStep", }, + "native/http-request": { + importer: () => import("@/plugins/native/steps/http-request"), + stepFunction: "httpRequestStep", + }, + "HTTP Request": { + importer: () => import("@/plugins/native/steps/http-request"), + stepFunction: "httpRequestStep", + }, "resend/send-email": { importer: () => import("@/plugins/resend/steps/send-email"), stepFunction: "sendEmailStep", @@ -118,12 +126,14 @@ export const ACTION_LABELS: Record = { "firecrawl/search": "Search Web", "linear/create-ticket": "Create Ticket", "linear/find-issues": "Find Issues", + "native/http-request": "HTTP Request", "resend/send-email": "Send Email", "slack/send-message": "Send Slack Message", "v0/create-chat": "Create Chat", "v0/send-message": "Send Message", - Scrape: "Scrape URL", - Search: "Search Web", + "HTTP Request": "HTTP Request", + "Scrape": "Scrape URL", + "Search": "Search Web", "Generate Text": "Generate Text", "Generate Image": "Generate Image", "Send Email": "Send Email", diff --git a/lib/steps/http-request.ts b/lib/steps/http-request.ts deleted file mode 100644 index 3eddd561..00000000 --- a/lib/steps/http-request.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Executable step function for HTTP Request action - */ -import "server-only"; - -import { getErrorMessage } from "../utils"; -import { type StepInput, withStepLogging } from "./step-handler"; - -type HttpRequestResult = - | { success: true; data: unknown; status: number } - | { success: false; error: string; status?: number }; - -export type HttpRequestInput = StepInput & { - endpoint: string; - httpMethod: string; - httpHeaders?: string; - httpBody?: string; -}; - -function parseHeaders(httpHeaders?: string): Record { - if (!httpHeaders) { - return {}; - } - try { - return JSON.parse(httpHeaders); - } catch { - return {}; - } -} - -function parseBody(httpMethod: string, httpBody?: string): string | undefined { - if (httpMethod === "GET" || !httpBody) { - return; - } - try { - const parsedBody = JSON.parse(httpBody); - return Object.keys(parsedBody).length > 0 - ? JSON.stringify(parsedBody) - : undefined; - } catch { - const trimmed = httpBody.trim(); - return trimmed && trimmed !== "{}" ? httpBody : undefined; - } -} - -function parseResponse(response: Response): Promise { - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - return response.json(); - } - return response.text(); -} - -/** - * HTTP request logic - */ -async function httpRequest( - input: HttpRequestInput -): Promise { - if (!input.endpoint) { - return { - success: false, - error: "HTTP request failed: URL is required", - }; - } - - try { - const response = await fetch(input.endpoint, { - method: input.httpMethod, - headers: parseHeaders(input.httpHeaders), - body: parseBody(input.httpMethod, input.httpBody), - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => "Unknown error"); - return { - success: false, - error: `HTTP request failed with status ${response.status}: ${errorText}`, - status: response.status, - }; - } - - const data = await parseResponse(response); - return { success: true, data, status: response.status }; - } catch (error) { - return { - success: false, - error: `HTTP request failed: ${getErrorMessage(error)}`, - }; - } -} - -/** - * HTTP Request Step - * Makes an HTTP request to an endpoint - */ -// biome-ignore lint/suspicious/useAwait: workflow "use step" requires async -export async function httpRequestStep( - input: HttpRequestInput -): Promise { - "use step"; - return withStepLogging(input, () => httpRequest(input)); -} diff --git a/lib/steps/index.ts b/lib/steps/index.ts index 3ffb4d2d..bfcc3711 100644 --- a/lib/steps/index.ts +++ b/lib/steps/index.ts @@ -13,17 +13,12 @@ import type { sendEmailStep } from "../../plugins/resend/steps/send-email"; import type { sendSlackMessageStep } from "../../plugins/slack/steps/send-slack-message"; import type { conditionStep } from "./condition"; import type { databaseQueryStep } from "./database-query"; -import type { httpRequestStep } from "./http-request"; // Step function type export type StepFunction = (input: Record) => Promise; // Registry of all available steps export const stepRegistry: Record = { - "HTTP Request": async (input) => - (await import("./http-request")).httpRequestStep( - input as Parameters[0] - ), "Database Query": async (input) => (await import("./database-query")).databaseQueryStep( input as Parameters[0] diff --git a/lib/types/integration.ts b/lib/types/integration.ts index cd146253..33de9148 100644 --- a/lib/types/integration.ts +++ b/lib/types/integration.ts @@ -9,7 +9,7 @@ * 2. Add a system integration to SYSTEM_INTEGRATION_TYPES in discover-plugins.ts * 3. Run: pnpm discover-plugins * - * Generated types: ai-gateway, database, firecrawl, linear, resend, slack, v0 + * Generated types: ai-gateway, database, firecrawl, linear, native, resend, slack, v0 */ // Integration type union - plugins + system integrations @@ -18,6 +18,7 @@ export type IntegrationType = | "database" | "firecrawl" | "linear" + | "native" | "resend" | "slack" | "v0"; diff --git a/lib/workflow-codegen-sdk.ts b/lib/workflow-codegen-sdk.ts index 598bca7c..ac3cea14 100644 --- a/lib/workflow-codegen-sdk.ts +++ b/lib/workflow-codegen-sdk.ts @@ -4,12 +4,10 @@ import { findActionById } from "@/plugins"; // System action codegen templates (not in plugin registry) import conditionTemplate from "./codegen-templates/condition"; import databaseQueryTemplate from "./codegen-templates/database-query"; -import httpRequestTemplate from "./codegen-templates/http-request"; // System actions that don't have plugins const SYSTEM_CODEGEN_TEMPLATES: Record = { "Database Query": databaseQueryTemplate, - "HTTP Request": httpRequestTemplate, Condition: conditionTemplate, }; @@ -320,15 +318,47 @@ export function generateWorkflowSDKCode( } function buildHttpParams(config: Record): string[] { - const params = [ - `url: "${config.endpoint || "https://api.example.com/endpoint"}"`, - `method: "${config.httpMethod || "POST"}"`, - `headers: ${config.httpHeaders || "{}"}`, - ]; - if (config.httpBody) { - params.push(`body: ${config.httpBody}`); + const endpoint = (config.endpoint as string) || ""; + const method = (config.httpMethod as string) || "GET"; + + // Helper to format object properties + function formatProperties(props: unknown): string { + if (!props) { + return "{}"; + } + + let entries: [string, string][] = []; + + if (Array.isArray(props)) { + entries = props + .filter((p) => p.key?.trim()) + .map((p) => [p.key, String(p.value || "")]); + } else if (typeof props === "object") { + entries = Object.entries(props as Record).map( + ([k, v]) => [k, String(v)] + ); + } + + if (entries.length === 0) { + return "{}"; + } + + const propStrings = entries.map(([k, v]) => { + const key = JSON.stringify(k); + const converted = convertTemplateToJS(v); + const escaped = escapeForTemplateLiteral(converted); + return `${key}: \`${escaped}\``; + }); + + return `{ ${propStrings.join(", ")} }`; } - return params; + + return [ + `endpoint: \`${convertTemplateToJS(endpoint)}\``, + `httpMethod: "${method}"`, + `httpHeaders: ${formatProperties(config.httpHeaders)}`, + `httpBody: ${formatProperties(config.httpBody)}`, + ]; } function buildConditionParams(config: Record): string[] { @@ -405,6 +435,7 @@ export function generateWorkflowSDKCode( "Generate Image": () => buildAIImageParams(config), "Database Query": () => buildDatabaseParams(config), "HTTP Request": () => buildHttpParams(config), + "native/http-request": () => buildHttpParams(config), Condition: () => buildConditionParams(config), Scrape: () => buildFirecrawlParams(actionType, config), Search: () => buildFirecrawlParams(actionType, config), diff --git a/lib/workflow-codegen-shared.ts b/lib/workflow-codegen-shared.ts index 9ccbcc64..d3562991 100644 --- a/lib/workflow-codegen-shared.ts +++ b/lib/workflow-codegen-shared.ts @@ -294,10 +294,6 @@ const SYSTEM_STEP_INFO: Record< functionName: "databaseQueryStep", importPath: "./steps/database-query-step", }, - "HTTP Request": { - functionName: "httpRequestStep", - importPath: "./steps/http-request-step", - }, Condition: { functionName: "conditionStep", importPath: "./steps/condition-step", diff --git a/lib/workflow-codegen.ts b/lib/workflow-codegen.ts index fabac703..782891fc 100644 --- a/lib/workflow-codegen.ts +++ b/lib/workflow-codegen.ts @@ -382,15 +382,50 @@ export function generateWorkflowCode( ); const config = node.data.config || {}; - const endpoint = - (config.endpoint as string) || "https://api.example.com/endpoint"; - const method = (config.httpMethod as string) || "POST"; + const endpoint = (config.endpoint as string) || ""; + const method = (config.httpMethod as string) || "GET"; + const headers = config.httpHeaders; + const body = config.httpBody; + + // Helper to format object properties (from ObjectProperty[] or Record) + function formatProperties(props: unknown): string { + if (!props) { + return "{}"; + } + + let entries: [string, string][] = []; + + if (Array.isArray(props)) { + // Handle ObjectProperty[] + entries = props + .filter((p) => p.key?.trim()) + .map((p) => [p.key, String(p.value || "")]); + } else if (typeof props === "object") { + // Handle plain object + entries = Object.entries(props as Record).map( + ([k, v]) => [k, String(v)] + ); + } + + if (entries.length === 0) { + return "{}"; + } + + const propStrings = entries.map(([k, v]) => { + const key = JSON.stringify(k); + const val = formatTemplateValue(v); + return `${key}: ${val}`; + }); + + return `{ ${propStrings.join(", ")} }`; + } return [ `${indent}const ${varName} = await ${stepInfo.functionName}({`, - `${indent} url: '${endpoint}',`, - `${indent} method: '${method}',`, - `${indent} body: {},`, + `${indent} endpoint: ${formatTemplateValue(endpoint)},`, + `${indent} httpMethod: '${method}',`, + `${indent} httpHeaders: ${formatProperties(headers)},`, + `${indent} httpBody: ${formatProperties(body)},`, `${indent}});`, ]; } @@ -721,7 +756,10 @@ export function generateWorkflowCode( lines.push( ...wrapActionCall(generateDatabaseActionCode(node, indent, varName)) ); - } else if (actionType === "HTTP Request") { + } else if ( + actionType === "HTTP Request" || + actionType === "native/http-request" + ) { lines.push( ...wrapActionCall(generateHTTPActionCode(node, indent, varName)) ); diff --git a/lib/workflow-executor.workflow.ts b/lib/workflow-executor.workflow.ts index 8b4958bd..e4a9760e 100644 --- a/lib/workflow-executor.workflow.ts +++ b/lib/workflow-executor.workflow.ts @@ -24,11 +24,6 @@ const SYSTEM_ACTIONS: Record = { importer: () => import("./steps/database-query") as Promise, stepFunction: "databaseQueryStep", }, - "HTTP Request": { - // biome-ignore lint/suspicious/noExplicitAny: Dynamic module import - importer: () => import("./steps/http-request") as Promise, - stepFunction: "httpRequestStep", - }, Condition: { // biome-ignore lint/suspicious/noExplicitAny: Dynamic module import importer: () => import("./steps/condition") as Promise, @@ -218,7 +213,7 @@ async function executeActionStep(input: { }); } - // Check system actions first (Database Query, HTTP Request) + // Check system actions first (Database Query, Condition) const systemAction = SYSTEM_ACTIONS[actionType]; if (systemAction) { const module = await systemAction.importer(); diff --git a/plugins/firecrawl/index.ts b/plugins/firecrawl/index.ts index 100d3200..cd645b99 100644 --- a/plugins/firecrawl/index.ts +++ b/plugins/firecrawl/index.ts @@ -38,6 +38,15 @@ const firecrawlPlugin: IntegrationPlugin = { "@mendable/firecrawl-js": "^4.6.2", }, + // HTTP configuration for custom API requests + // Allows users to make direct API calls to Firecrawl via HTTP Request step + httpConfig: { + baseUrl: "https://api.firecrawl.dev/v1", + authHeader: "Authorization", + authPrefix: "Bearer ", + authCredentialKey: "FIRECRAWL_API_KEY", + }, + actions: [ { slug: "scrape", diff --git a/plugins/index.ts b/plugins/index.ts index 7a5965eb..e382eadb 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -13,17 +13,18 @@ * 1. Delete the plugin directory * 2. Run: pnpm discover-plugins (or it runs automatically on build) * - * Discovered plugins: ai-gateway, firecrawl, linear, resend, slack, v0 + * Discovered plugins: ai-gateway, firecrawl, linear, native, resend, slack, v0 */ import "./ai-gateway"; import "./firecrawl"; import "./linear"; +import "./native"; import "./resend"; import "./slack"; import "./v0"; -export type { IntegrationPlugin, PluginAction, ActionWithFullId } from "./registry"; +export type { IntegrationPlugin, PluginAction, ActionWithFullId, PluginHttpConfig } from "./registry"; // Export the registry utilities export { @@ -37,11 +38,14 @@ export { getAllIntegrations, getCredentialMapping, getDependenciesForActions, + getHttpEnabledPlugins, getIntegration, getIntegrationLabels, getIntegrationTypes, getPluginEnvVars, + getPluginHttpConfig, getSortedIntegrationTypes, parseActionId, registerIntegration, + requiresIntegration, } from "./registry"; diff --git a/plugins/legacy-mappings.ts b/plugins/legacy-mappings.ts index 47c0ee44..5738a8f2 100644 --- a/plugins/legacy-mappings.ts +++ b/plugins/legacy-mappings.ts @@ -9,6 +9,9 @@ * TODO: Remove this file once all workflows have been migrated to the new format. */ export const LEGACY_ACTION_MAPPINGS: Record = { + // Native + "HTTP Request": "native/http-request", + // Firecrawl Scrape: "firecrawl/scrape", Search: "firecrawl/search", diff --git a/plugins/linear/index.ts b/plugins/linear/index.ts index 6e2da6f4..0ae39502 100644 --- a/plugins/linear/index.ts +++ b/plugins/linear/index.ts @@ -48,6 +48,14 @@ const linearPlugin: IntegrationPlugin = { "@linear/sdk": "^63.2.0", }, + // HTTP configuration for custom API requests + // Allows users to make direct GraphQL calls to Linear via HTTP Request step + httpConfig: { + baseUrl: "https://api.linear.app", + authHeader: "Authorization", + authCredentialKey: "LINEAR_API_KEY", + }, + actions: [ { slug: "create-ticket", diff --git a/plugins/native/codegen/http-request.ts b/plugins/native/codegen/http-request.ts new file mode 100644 index 00000000..12387505 --- /dev/null +++ b/plugins/native/codegen/http-request.ts @@ -0,0 +1,36 @@ +/** + * Code generation template for HTTP Request action + * Generates standalone code when users export their workflow + */ + +export const httpRequestCodegenTemplate = `export async function httpRequestStep(input: { + endpoint: string; + httpMethod: string; + httpHeaders?: Record; + httpBody?: Record; +}) { + "use step"; + + const headers: Record = input.httpHeaders || {}; + + // Add Content-Type if body is present and header not set + let body: string | undefined; + if (input.httpMethod !== "GET" && input.httpBody && Object.keys(input.httpBody).length > 0) { + body = JSON.stringify(input.httpBody); + if (!headers["Content-Type"] && !headers["content-type"]) { + headers["Content-Type"] = "application/json"; + } + } + + const response = await fetch(input.endpoint, { + method: input.httpMethod, + headers, + body, + }); + + const contentType = response.headers.get("content-type"); + if (contentType?.includes("application/json")) { + return await response.json(); + } + return await response.text(); +}`; diff --git a/plugins/native/icon.tsx b/plugins/native/icon.tsx new file mode 100644 index 00000000..535d7288 --- /dev/null +++ b/plugins/native/icon.tsx @@ -0,0 +1,25 @@ +/** + * Native Plugin Icon + * Globe icon representing network/HTTP requests + */ + +export function NativeIcon({ className }: { className?: string }) { + return ( + + Native + + + + + ); +} diff --git a/plugins/native/index.ts b/plugins/native/index.ts new file mode 100644 index 00000000..1cdc041a --- /dev/null +++ b/plugins/native/index.ts @@ -0,0 +1,93 @@ + +import type { IntegrationPlugin } from "../registry"; +import { registerIntegration } from "../registry"; +import { httpRequestCodegenTemplate } from "./codegen/http-request"; +import { NativeIcon } from "./icon"; + +const nativePlugin: IntegrationPlugin = { + type: "native", + label: "Native", + description: "Built-in actions that don't require external integrations", + requiresIntegration: false, + icon: NativeIcon, + formFields: [], + dependencies: {}, + actions: [ + { + slug: "http-request", + label: "HTTP Request", + description: "Make an HTTP request to any API endpoint", + category: "Native", + stepFunction: "httpRequestStep", + stepImportPath: "http-request", + configFields: [ + { + key: "integrationId", + label: "Use Integration (Optional)", + type: "integration-select", + placeholder: "None - Manual Authentication", + }, + { + key: "httpMethod", + label: "HTTP Method", + type: "select", + options: [ + { value: "GET", label: "GET" }, + { value: "POST", label: "POST" }, + { value: "PUT", label: "PUT" }, + { value: "PATCH", label: "PATCH" }, + { value: "DELETE", label: "DELETE" }, + ], + defaultValue: "GET", + required: true, + }, + { + key: "endpoint", + label: "Endpoint", + type: "template-input", + placeholder: "https://api.example.com/endpoint or /path (with integration)", + example: "https://api.example.com/data", + required: true, + }, + { + key: "httpHeaders", + label: "Request Headers", + type: "object-builder", + placeholder: "Header name", + validateKey: (key, value) => { + if (!key && value) { + return "Header name is required"; + } + if (key && !/^[A-Za-z0-9_-]+$/.test(key)) { + return "Header name can only contain letters, numbers, hyphens, and underscores"; + } + if (key && key.toLowerCase() === "content-type") { + return "Content-Type is automatically set to application/json"; + } + return undefined; + }, + validateValue: (value) => { + if (value.includes("{{")) { + return undefined; + } + if (!/^[A-Za-z0-9 _:;.,\\/"'?!(){}[\]@<>=\-+*#$&`|~^%]*$/.test(value)) { + return "Header value contains invalid characters"; + } + return undefined; + }, + }, + { + key: "httpBody", + label: "Request Body", + type: "json-editor", + defaultValue: "{}", + }, + ], + codegenTemplate: httpRequestCodegenTemplate, + }, + ], +}; + +registerIntegration(nativePlugin); + +export default nativePlugin; diff --git a/plugins/native/steps/http-request.ts b/plugins/native/steps/http-request.ts new file mode 100644 index 00000000..93786358 --- /dev/null +++ b/plugins/native/steps/http-request.ts @@ -0,0 +1,204 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { getIntegrationById } from "@/lib/db/integrations"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import { getPluginHttpConfig } from "@/plugins/registry"; + +type HttpRequestResult = + | { success: true; data: unknown; status: number } + | { success: false; error: string; status?: number }; + +type ObjectProperty = { + id: string; + key: string; + value: string; +}; + +export type HttpRequestInput = StepInput & { + integrationId?: string; + endpoint: string; + httpMethod: string; + httpHeaders?: string | Record | ObjectProperty[]; + httpBody?: string | Record | ObjectProperty[]; +}; + +function propertiesToObject( + properties: ObjectProperty[] +): Record { + const obj: Record = {}; + for (const prop of properties) { + if (prop.key?.trim()) { + obj[prop.key] = prop.value; + } + } + return obj; +} + +function parseHeaders( + httpHeaders?: string | Record | ObjectProperty[] +): Record { + if (!httpHeaders) { + return {}; + } + + if (Array.isArray(httpHeaders)) { + return propertiesToObject(httpHeaders); + } + + if (typeof httpHeaders === "object") { + return httpHeaders; + } + + try { + const parsed = JSON.parse(httpHeaders); + if (Array.isArray(parsed)) { + return propertiesToObject(parsed); + } + return parsed; + } catch { + return {}; + } +} + +function parseBody( + httpMethod: string, + httpBody?: string | Record | ObjectProperty[] +): string | undefined { + + if (httpMethod === "GET" || !httpBody) { + return undefined; + } + + if (Array.isArray(httpBody)) { + const obj = propertiesToObject(httpBody); + return Object.keys(obj).length > 0 ? JSON.stringify(obj) : undefined; + } + + if (typeof httpBody === "object") { + return Object.keys(httpBody).length > 0 + ? JSON.stringify(httpBody) + : undefined; + } + + try { + const parsed = JSON.parse(httpBody); + + if (Array.isArray(parsed)) { + const obj = propertiesToObject(parsed); + return Object.keys(obj).length > 0 ? JSON.stringify(obj) : undefined; + } + return Object.keys(parsed).length > 0 ? JSON.stringify(parsed) : undefined; + } catch { + + const trimmed = httpBody.trim(); + return trimmed && trimmed !== "{}" ? httpBody : undefined; + } +} + +async function parseResponse(response: Response): Promise { + const contentType = response.headers.get("content-type"); + if (contentType?.includes("application/json")) { + return response.json(); + } + return response.text(); +} + +function buildUrl(endpoint: string, baseUrl?: string): string { + if (!baseUrl || endpoint.startsWith("http://") || endpoint.startsWith("https://")) { + return endpoint; + } + + const normalizedBase = baseUrl.replace(/\/$/, ""); + const normalizedPath = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; + + return `${normalizedBase}${normalizedPath}`; +} + +async function httpRequest( + input: HttpRequestInput +): Promise { + if (!input.endpoint) { + return { + success: false, + error: "HTTP request failed: URL is required", + }; + } + + const headers = parseHeaders(input.httpHeaders); + const body = parseBody(input.httpMethod, input.httpBody); + let finalUrl = input.endpoint; + + if (input.integrationId) { + try { + const integration = await getIntegrationById(input.integrationId); + if (!integration) { + return { + success: false, + error: `Integration not found: ${input.integrationId}`, + }; + } + + const httpConfig = getPluginHttpConfig(integration.type); + if (!httpConfig) { + return { + success: false, + error: `Integration "${integration.type}" does not support HTTP requests`, + }; + } + + finalUrl = buildUrl(input.endpoint, httpConfig.baseUrl); + + const credentials = await fetchCredentials(input.integrationId); + const authValue = credentials[httpConfig.authCredentialKey]; + + if (authValue) { + const authHeader = httpConfig.authHeader || "Authorization"; + const authPrefix = httpConfig.authPrefix ?? "Bearer "; + headers[authHeader] = `${authPrefix}${authValue}`; + } + } catch (error) { + return { + success: false, + error: `Failed to fetch integration credentials: ${getErrorMessage(error)}`, + }; + } + } + + if (body && !headers["Content-Type"] && !headers["content-type"]) { + headers["Content-Type"] = "application/json"; + } + + try { + const response = await fetch(finalUrl, { + method: input.httpMethod, + headers, + body, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + return { + success: false, + error: `HTTP request failed with status ${response.status}: ${errorText}`, + status: response.status, + }; + } + + const data = await parseResponse(response); + return { success: true, data, status: response.status }; + } catch (error) { + return { + success: false, + error: `HTTP request failed: ${getErrorMessage(error)}`, + }; + } +} + +export async function httpRequestStep( + input: HttpRequestInput +): Promise { + "use step"; + return withStepLogging(input, () => httpRequest(input)); +} diff --git a/plugins/registry.ts b/plugins/registry.ts index cfd26597..9dc2cf40 100644 --- a/plugins/registry.ts +++ b/plugins/registry.ts @@ -28,7 +28,10 @@ export type ActionConfigField = { | "text" // Regular text input | "number" // Number input | "select" // Dropdown select - | "schema-builder"; // Schema builder for structured output + | "schema-builder" // Schema builder for structured output + | "object-builder" // Object builder for key-value pairs (headers, body) + | "integration-select" // Dynamic dropdown of user's integrations with httpConfig + | "json-editor"; // JSON code editor with validation // Placeholder text placeholder?: string; @@ -56,6 +59,11 @@ export type ActionConfigField = { field: string; equals: string; }; + + // Validation for object-builder fields + // Returns error message string or undefined if valid + validateKey?: (key: string, value: string) => string | undefined; + validateValue?: (value: string, key: string) => string | undefined; }; /** @@ -87,6 +95,26 @@ export type PluginAction = { codegenTemplate: string; }; +/** + * HTTP Configuration for plugins + * Allows the HTTP Request step to use plugin credentials for custom API calls + */ +export type PluginHttpConfig = { + // Base URL for the API (e.g., "https://api.resend.com") + baseUrl: string; + + // Header name for authentication (default: "Authorization") + authHeader?: string; + + // Prefix for the auth value (default: "Bearer ") + // Use empty string for APIs that expect raw API keys + authPrefix?: string; + + // Which credential key to use for auth (e.g., "RESEND_API_KEY") + // This should match an envVar from formFields + authCredentialKey: string; +}; + /** * Integration Plugin Definition * All information needed to register a new integration in one place @@ -97,6 +125,10 @@ export type IntegrationPlugin = { label: string; description: string; + // Whether this plugin requires an integration to be configured (default: true) + // Set to false for plugins like Native/HTTP Request that don't need credentials + requiresIntegration?: boolean; + // Icon component (should be exported from plugins/[name]/icon.tsx) icon: React.ComponentType<{ className?: string }>; @@ -126,6 +158,10 @@ export type IntegrationPlugin = { // NPM dependencies required by this plugin (package name -> version) dependencies?: Record; + // HTTP configuration for custom API requests via HTTP Request step + // When defined, this plugin's integrations will appear in the HTTP Request integration dropdown + httpConfig?: PluginHttpConfig; + // Actions provided by this integration actions: PluginAction[]; }; @@ -447,3 +483,36 @@ export function generateAIActionPrompts(): string { return lines.join("\n"); } + +/** + * Check if an action requires an integration to be configured + * Returns false for plugins with requiresIntegration: false (like Native) + */ +export function requiresIntegration(actionType: string): boolean { + const action = findActionById(actionType); + if (!action) { + return false; + } + + const plugin = integrationRegistry.get(action.integration); + return plugin?.requiresIntegration !== false; +} + +/** + * Get all plugins that have HTTP configuration + * These plugins support custom API requests via the HTTP Request step + */ +export function getHttpEnabledPlugins(): IntegrationPlugin[] { + return Array.from(integrationRegistry.values()).filter( + (plugin) => plugin.httpConfig !== undefined + ); +} + +/** + * Get HTTP config for a specific plugin type + */ +export function getPluginHttpConfig( + integrationType: IntegrationType +): PluginHttpConfig | undefined { + return integrationRegistry.get(integrationType)?.httpConfig; +} diff --git a/plugins/resend/index.ts b/plugins/resend/index.ts index d69d1ddd..9792df0f 100644 --- a/plugins/resend/index.ts +++ b/plugins/resend/index.ts @@ -46,6 +46,15 @@ const resendPlugin: IntegrationPlugin = { resend: "^6.4.0", }, + // HTTP configuration for custom API requests + // Allows users to make direct API calls to Resend via HTTP Request step + httpConfig: { + baseUrl: "https://api.resend.com", + authHeader: "Authorization", + authPrefix: "Bearer ", + authCredentialKey: "RESEND_API_KEY", + }, + actions: [ { slug: "send-email", diff --git a/plugins/slack/index.ts b/plugins/slack/index.ts index d19c7d9b..46501bcd 100644 --- a/plugins/slack/index.ts +++ b/plugins/slack/index.ts @@ -37,6 +37,15 @@ const slackPlugin: IntegrationPlugin = { "@slack/web-api": "^7.12.0", }, + // HTTP configuration for custom API requests + // Allows users to make direct API calls to Slack via HTTP Request step + httpConfig: { + baseUrl: "https://slack.com/api", + authHeader: "Authorization", + authPrefix: "Bearer ", + authCredentialKey: "SLACK_API_KEY", + }, + actions: [ { slug: "send-message", diff --git a/scripts/discover-plugins.ts b/scripts/discover-plugins.ts index d6f2a0c4..26b85fb2 100644 --- a/scripts/discover-plugins.ts +++ b/scripts/discover-plugins.ts @@ -87,7 +87,7 @@ function generateIndexFile(plugins: string[]): void { ${imports || "// No plugins discovered"} -export type { IntegrationPlugin, PluginAction, ActionWithFullId } from "./registry"; +export type { IntegrationPlugin, PluginAction, ActionWithFullId, PluginHttpConfig } from "./registry"; // Export the registry utilities export { @@ -101,13 +101,16 @@ export { getAllIntegrations, getCredentialMapping, getDependenciesForActions, + getHttpEnabledPlugins, getIntegration, getIntegrationLabels, getIntegrationTypes, getPluginEnvVars, + getPluginHttpConfig, getSortedIntegrationTypes, parseActionId, registerIntegration, + requiresIntegration, } from "./registry"; `; From ae15c95f1ebb97b73be88c9c537b5536498e6b72 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Tue, 2 Dec 2025 18:16:28 +1100 Subject: [PATCH 6/8] chore: remove native plugin codegen and clean up index --- plugins/native/codegen/http-request.ts | 36 -------------------------- plugins/native/index.ts | 4 --- 2 files changed, 40 deletions(-) delete mode 100644 plugins/native/codegen/http-request.ts diff --git a/plugins/native/codegen/http-request.ts b/plugins/native/codegen/http-request.ts deleted file mode 100644 index 12387505..00000000 --- a/plugins/native/codegen/http-request.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Code generation template for HTTP Request action - * Generates standalone code when users export their workflow - */ - -export const httpRequestCodegenTemplate = `export async function httpRequestStep(input: { - endpoint: string; - httpMethod: string; - httpHeaders?: Record; - httpBody?: Record; -}) { - "use step"; - - const headers: Record = input.httpHeaders || {}; - - // Add Content-Type if body is present and header not set - let body: string | undefined; - if (input.httpMethod !== "GET" && input.httpBody && Object.keys(input.httpBody).length > 0) { - body = JSON.stringify(input.httpBody); - if (!headers["Content-Type"] && !headers["content-type"]) { - headers["Content-Type"] = "application/json"; - } - } - - const response = await fetch(input.endpoint, { - method: input.httpMethod, - headers, - body, - }); - - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - return await response.json(); - } - return await response.text(); -}`; diff --git a/plugins/native/index.ts b/plugins/native/index.ts index 1cdc041a..362a66ab 100644 --- a/plugins/native/index.ts +++ b/plugins/native/index.ts @@ -1,7 +1,5 @@ - import type { IntegrationPlugin } from "../registry"; import { registerIntegration } from "../registry"; -import { httpRequestCodegenTemplate } from "./codegen/http-request"; import { NativeIcon } from "./icon"; const nativePlugin: IntegrationPlugin = { @@ -11,7 +9,6 @@ const nativePlugin: IntegrationPlugin = { requiresIntegration: false, icon: NativeIcon, formFields: [], - dependencies: {}, actions: [ { slug: "http-request", @@ -83,7 +80,6 @@ const nativePlugin: IntegrationPlugin = { defaultValue: "{}", }, ], - codegenTemplate: httpRequestCodegenTemplate, }, ], }; From a548e66546d2bad86dacd2011c0d4e9f2b2233a9 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Tue, 2 Dec 2025 20:10:56 +1100 Subject: [PATCH 7/8] fix: resolve merge conflicts with main, add outputFields to native plugin --- components/ui/template-autocomplete.tsx | 102 ++++++------------------ lib/step-registry.ts | 2 +- lib/types/integration.ts | 2 +- plugins/native/index.ts | 4 + plugins/registry.ts | 12 +++ 5 files changed, 43 insertions(+), 79 deletions(-) diff --git a/components/ui/template-autocomplete.tsx b/components/ui/template-autocomplete.tsx index b0afb66d..4f20622f 100644 --- a/components/ui/template-autocomplete.tsx +++ b/components/ui/template-autocomplete.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; import { edgesAtom, nodesAtom, type WorkflowNode } from "@/lib/workflow-store"; +import { findActionById } from "@/plugins"; type TemplateAutocompleteProps = { isOpen: boolean; @@ -32,7 +33,14 @@ const getNodeDisplayName = (node: WorkflowNode): string => { if (node.data.type === "action") { const actionType = node.data.config?.actionType as string | undefined; - return actionType || "Action"; + if (actionType) { + // Look up human-readable label from plugin registry + const action = findActionById(actionType); + if (action?.label) { + return action.label; + } + } + return actionType || "HTTP Request"; } if (node.data.type === "trigger") { @@ -103,37 +111,16 @@ const isActionType = ( const getCommonFields = (node: WorkflowNode) => { const actionType = node.data.config?.actionType as string | undefined; - if (isActionType(actionType, "Find Issues", "linear/find-issues")) { - return [ - { field: "issues", description: "Array of issues found" }, - { field: "count", description: "Number of issues" }, - ]; - } - if (isActionType(actionType, "Send Email", "resend/send-email")) { - return [ - { field: "id", description: "Email ID" }, - { field: "status", description: "Send status" }, - ]; - } - if (isActionType(actionType, "Create Ticket", "linear/create-ticket")) { - return [ - { field: "id", description: "Ticket ID" }, - { field: "url", description: "Ticket URL" }, - { field: "number", description: "Ticket number" }, - ]; - } - if ( - isActionType(actionType, "HTTP Request", "native/http-request") - ) { + // Special handling for dynamic outputs (system actions and schema-based) + if (actionType === "HTTP Request") { return [ { field: "data", description: "Response data" }, { field: "status", description: "HTTP status code" }, ]; } + if (actionType === "Database Query") { const dbSchema = node.data.config?.dbSchema as string | undefined; - - // If schema is defined, show schema fields if (dbSchema) { try { const schema = JSON.parse(dbSchema) as SchemaField[]; @@ -144,81 +131,43 @@ const getCommonFields = (node: WorkflowNode) => { // If schema parsing fails, fall through to default fields } } - - // Default fields when no schema return [ { field: "rows", description: "Query result rows" }, { field: "count", description: "Number of rows" }, ]; } + + // AI Gateway generate-text has dynamic output based on format/schema if (isActionType(actionType, "Generate Text", "ai-gateway/generate-text")) { const aiFormat = node.data.config?.aiFormat as string | undefined; const aiSchema = node.data.config?.aiSchema as string | undefined; - // If format is object and schema is defined, show schema fields if (aiFormat === "object" && aiSchema) { try { const schema = JSON.parse(aiSchema) as SchemaField[]; if (schema.length > 0) { - return schemaToFields(schema); + return schemaToFields(schema, "object"); } } catch { // If schema parsing fails, fall through to default fields } } - - // Default fields for text format or when no schema - return [ - { field: "text", description: "Generated text" }, - { field: "model", description: "Model used" }, - ]; + return [{ field: "text", description: "Generated text" }]; } - if (isActionType(actionType, "Generate Image", "ai-gateway/generate-image")) { - return [ - { field: "base64", description: "Base64 image data" }, - { field: "model", description: "Model used" }, - ]; - } - if ( - isActionType(actionType, "Scrape", "Scrape URL", "firecrawl/scrape") - ) { - return [ - { field: "markdown", description: "Scraped content as markdown" }, - { field: "metadata.url", description: "Page URL" }, - { field: "metadata.title", description: "Page title" }, - { field: "metadata.description", description: "Page description" }, - { field: "metadata.language", description: "Page language" }, - { field: "metadata.favicon", description: "Favicon URL" }, - ]; - } - if (isActionType(actionType, "Search", "Search Web", "firecrawl/search")) { - return [{ field: "web", description: "Array of search results" }]; - } - if (isActionType(actionType, "Create Chat", "v0/create-chat")) { - return [ - { field: "chatId", description: "v0 chat ID" }, - { field: "url", description: "v0 chat URL" }, - { field: "demoUrl", description: "Demo preview URL" }, - ]; - } - if (isActionType(actionType, "Send Message", "v0/send-message")) { - return [ - { field: "chatId", description: "v0 chat ID" }, - { field: "demoUrl", description: "Demo preview URL" }, - ]; - } - if (isActionType(actionType, "Send Slack Message", "slack/send-message")) { - return [ - { field: "ok", description: "Success status" }, - { field: "ts", description: "Message timestamp" }, - { field: "channel", description: "Channel ID" }, - ]; + + // Check if the plugin defines output fields + if (actionType) { + const action = findActionById(actionType); + if (action?.outputFields && action.outputFields.length > 0) { + return action.outputFields; + } } + + // Trigger fields if (node.data.type === "trigger") { const triggerType = node.data.config?.triggerType as string | undefined; const webhookSchema = node.data.config?.webhookSchema as string | undefined; - // If it's a webhook trigger with a schema, show schema fields if (triggerType === "Webhook" && webhookSchema) { try { const schema = JSON.parse(webhookSchema) as SchemaField[]; @@ -230,7 +179,6 @@ const getCommonFields = (node: WorkflowNode) => { } } - // Default trigger fields return [ { field: "triggered", description: "Trigger status" }, { field: "timestamp", description: "Trigger timestamp" }, diff --git a/lib/step-registry.ts b/lib/step-registry.ts index 9067b1c8..24e34ef0 100644 --- a/lib/step-registry.ts +++ b/lib/step-registry.ts @@ -7,7 +7,7 @@ * This registry enables dynamic step imports that are statically analyzable * by the bundler. Each action type maps to its step importer function. * - * Generated entries: 12 + * Generated entries: 13 */ import "server-only"; diff --git a/lib/types/integration.ts b/lib/types/integration.ts index 0dfdde21..1981a899 100644 --- a/lib/types/integration.ts +++ b/lib/types/integration.ts @@ -9,7 +9,7 @@ * 2. Add a system integration to SYSTEM_INTEGRATION_TYPES in discover-plugins.ts * 3. Run: pnpm discover-plugins * - * Generated types: ai-gateway, database, firecrawl, linear, resend, slack, superagent, v0 + * Generated types: ai-gateway, database, firecrawl, linear, native, resend, slack, superagent, v0 */ // Integration type union - plugins + system integrations diff --git a/plugins/native/index.ts b/plugins/native/index.ts index 362a66ab..f681ba02 100644 --- a/plugins/native/index.ts +++ b/plugins/native/index.ts @@ -17,6 +17,10 @@ const nativePlugin: IntegrationPlugin = { category: "Native", stepFunction: "httpRequestStep", stepImportPath: "http-request", + outputFields: [ + { field: "data", description: "Response data" }, + { field: "status", description: "HTTP status code" }, + ], configFields: [ { key: "integrationId", diff --git a/plugins/registry.ts b/plugins/registry.ts index 2ac7259a..c7a8679c 100644 --- a/plugins/registry.ts +++ b/plugins/registry.ts @@ -90,6 +90,15 @@ export type ActionConfigFieldGroup = { */ export type ActionConfigField = ActionConfigFieldBase | ActionConfigFieldGroup; +/** + * Output Field Definition + * Describes an output field available for template autocomplete + */ +export type OutputField = { + field: string; + description: string; +}; + /** * Action Definition * Describes a single action provided by a plugin @@ -115,6 +124,9 @@ export type PluginAction = { // Config fields for the action (declarative definition) configFields: ActionConfigField[]; + // Output fields for template autocomplete (what this action returns) + outputFields?: OutputField[]; + // Code generation template (the actual template string, not a path) // Optional - if not provided, will fall back to auto-generated template // from steps that export _exportCore From 70bba18440a06a5b7b05124060109bf9164ec4fd Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Tue, 2 Dec 2025 20:29:44 +1100 Subject: [PATCH 8/8] fix: handle JSON string inputs in formatProperties for HTTP headers/body codegen --- lib/workflow-codegen.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/workflow-codegen.ts b/lib/workflow-codegen.ts index 782891fc..d3ae4512 100644 --- a/lib/workflow-codegen.ts +++ b/lib/workflow-codegen.ts @@ -387,22 +387,34 @@ export function generateWorkflowCode( const headers = config.httpHeaders; const body = config.httpBody; - // Helper to format object properties (from ObjectProperty[] or Record) + // Helper to format object properties (from ObjectProperty[], Record, or JSON string) + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Multiple type checks needed for different input formats function formatProperties(props: unknown): string { if (!props) { return "{}"; } + // Handle JSON string input (from ObjectBuilder serialization) + let parsedProps = props; + if (typeof props === "string") { + try { + parsedProps = JSON.parse(props); + } catch { + // If parsing fails, return empty object + return "{}"; + } + } + let entries: [string, string][] = []; - if (Array.isArray(props)) { + if (Array.isArray(parsedProps)) { // Handle ObjectProperty[] - entries = props + entries = parsedProps .filter((p) => p.key?.trim()) .map((p) => [p.key, String(p.value || "")]); - } else if (typeof props === "object") { + } else if (typeof parsedProps === "object" && parsedProps !== null) { // Handle plain object - entries = Object.entries(props as Record).map( + entries = Object.entries(parsedProps as Record).map( ([k, v]) => [k, String(v)] ); }