From 81ace7e6c98e025068ed3322f2a21113abd42940 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 08:29:10 -0700 Subject: [PATCH 01/23] :sparkles: feat(plugin): add manifest types and global cache --- pkg/integrations/plugin/manifest.go | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 pkg/integrations/plugin/manifest.go diff --git a/pkg/integrations/plugin/manifest.go b/pkg/integrations/plugin/manifest.go new file mode 100644 index 0000000000..54c444d6ec --- /dev/null +++ b/pkg/integrations/plugin/manifest.go @@ -0,0 +1,52 @@ +package plugin + +import ( + "sync" +) + +type Manifest struct { + Name string `json:"name"` + Label string `json:"label"` + Icon string `json:"icon"` + Description string `json:"description"` + Actions []ActionManifest `json:"actions"` +} + +type ActionManifest struct { + Name string `json:"name"` + Label string `json:"label"` + Description string `json:"description"` + Fields []FieldManifest `json:"fields"` +} + +type FieldManifest struct { + Name string `json:"name"` + Label string `json:"label"` + Type string `json:"type"` + Description string `json:"description"` + Required bool `json:"required"` + Default any `json:"default,omitempty"` + Options []OptionManifest `json:"options,omitempty"` +} + +type OptionManifest struct { + Label string `json:"label"` + Value string `json:"value"` +} + +var ( + cachedManifest *Manifest + cachedManifestMu sync.RWMutex +) + +func getCachedManifest() *Manifest { + cachedManifestMu.RLock() + defer cachedManifestMu.RUnlock() + return cachedManifest +} + +func setCachedManifest(m *Manifest) { + cachedManifestMu.Lock() + defer cachedManifestMu.Unlock() + cachedManifest = m +} From 720b231e006fcac5cabce0ed96125c13bcdec5e4 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 08:29:20 -0700 Subject: [PATCH 02/23] :sparkles: feat(plugin): add HTTP client for plugin servers --- pkg/integrations/plugin/client.go | 136 ++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 pkg/integrations/plugin/client.go diff --git a/pkg/integrations/plugin/client.go b/pkg/integrations/plugin/client.go new file mode 100644 index 0000000000..831d7dff25 --- /dev/null +++ b/pkg/integrations/plugin/client.go @@ -0,0 +1,136 @@ +package plugin + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/superplanehq/superplane/pkg/core" +) + +type Client struct { + serverURL string + authToken string + httpDo func(*http.Request) (*http.Response, error) +} + +func NewClient(integration core.IntegrationContext) (*Client, error) { + serverURL, err := integration.GetConfig("serverUrl") + if err != nil || serverURL == nil { + return nil, fmt.Errorf("serverUrl is required") + } + + var authToken string + token, err := integration.GetConfig("authToken") + if err == nil && token != nil { + authToken = string(token) + } + + return &Client{ + serverURL: string(serverURL), + authToken: authToken, + httpDo: http.DefaultClient.Do, + }, nil +} + +func NewClientWithHTTP(integration core.IntegrationContext, httpCtx core.HTTPContext) (*Client, error) { + client, err := NewClient(integration) + if err != nil { + return nil, err + } + client.httpDo = httpCtx.Do + return client, nil +} + +type ExecuteRequest struct { + Parameters map[string]any `json:"parameters"` + Input any `json:"input,omitempty"` +} + +type ExecuteResponse struct { + Success bool `json:"success"` + Data map[string]any `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +func (c *Client) FetchManifest() (*Manifest, error) { + req, err := http.NewRequest("GET", c.serverURL+"/manifest", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + c.setAuth(req) + + resp, err := c.httpDo(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch manifest: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("manifest returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var manifest Manifest + if err := json.Unmarshal(body, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse manifest: %w", err) + } + + return &manifest, nil +} + +func (c *Client) ExecuteAction(actionName string, params map[string]any, input any) (*ExecuteResponse, error) { + reqBody := ExecuteRequest{ + Parameters: params, + Input: input, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("POST", c.serverURL+"/actions/"+actionName+"/execute", bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + c.setAuth(req) + + resp, err := c.httpDo(req) + if err != nil { + return nil, fmt.Errorf("failed to execute action: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return &ExecuteResponse{ + Success: false, + Error: fmt.Sprintf("plugin server returned status %d: %s", resp.StatusCode, string(respBody)), + }, nil + } + + var result ExecuteResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func (c *Client) setAuth(req *http.Request) { + if c.authToken != "" { + req.Header.Set("Authorization", "Bearer "+c.authToken) + } +} From 59206a53cc595640ae71965003768842198a9ba0 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 08:29:23 -0700 Subject: [PATCH 03/23] :sparkles: feat(plugin): add RunAction with dynamic config from manifest --- pkg/integrations/plugin/example.go | 18 ++ pkg/integrations/plugin/example_output.json | 10 + pkg/integrations/plugin/run_action.go | 263 ++++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 pkg/integrations/plugin/example.go create mode 100644 pkg/integrations/plugin/example_output.json create mode 100644 pkg/integrations/plugin/run_action.go diff --git a/pkg/integrations/plugin/example.go b/pkg/integrations/plugin/example.go new file mode 100644 index 0000000000..033a27703b --- /dev/null +++ b/pkg/integrations/plugin/example.go @@ -0,0 +1,18 @@ +package plugin + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_output.json +var exampleOutputBytes []byte + +var exampleOutputOnce sync.Once +var exampleOutput map[string]any + +func (r *RunAction) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputOnce, exampleOutputBytes, &exampleOutput) +} diff --git a/pkg/integrations/plugin/example_output.json b/pkg/integrations/plugin/example_output.json new file mode 100644 index 0000000000..a191862aa3 --- /dev/null +++ b/pkg/integrations/plugin/example_output.json @@ -0,0 +1,10 @@ +{ + "type": "plugin.action.success", + "data": { + "action": "example-action", + "result": { + "message": "Action completed successfully" + } + }, + "timestamp": "2026-05-30T12:00:00Z" +} diff --git a/pkg/integrations/plugin/run_action.go b/pkg/integrations/plugin/run_action.go new file mode 100644 index 0000000000..3bbd195003 --- /dev/null +++ b/pkg/integrations/plugin/run_action.go @@ -0,0 +1,263 @@ +package plugin + +import ( + "fmt" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/models" +) + +const ( + SuccessChannel = "success" + FailureChannel = "failure" + SuccessPayloadType = "plugin.action.success" + FailurePayloadType = "plugin.action.failed" +) + +type RunAction struct{} + +type RunActionConfiguration struct { + ActionName string `json:"actionName" mapstructure:"actionName"` +} + +func (r *RunAction) Name() string { + return "plugin.runAction" +} + +func (r *RunAction) Label() string { + return "Run Plugin Action" +} + +func (r *RunAction) Description() string { + manifest := getCachedManifest() + if manifest == nil { + return "Execute an action on a connected plugin server" + } + return fmt.Sprintf("Execute an action on %s", manifest.Label) +} + +func (r *RunAction) Documentation() string { + return `Run a remote action on a plugin server connected via the Plugin integration. + +## Use Cases + +- Execute custom business logic hosted on your own server +- Integrate with internal services via the Plugin SDK +- Run any action defined in the plugin server's manifest + +## Configuration + +- **Action**: Select which action to run from the plugin server's manifest +- Additional fields appear dynamically based on the selected action + +## Output Channels + +- **Success**: Emitted when the plugin action succeeds, contains the action's response data +- **Failure**: Emitted when the action fails or the plugin server returns an error` +} + +func (r *RunAction) Icon() string { + return "puzzle" +} + +func (r *RunAction) Color() string { + return "gray" +} + +func (r *RunAction) OutputChannels(cfg any) []core.OutputChannel { + return []core.OutputChannel{ + {Name: SuccessChannel, Label: "Success"}, + {Name: FailureChannel, Label: "Failure"}, + } +} + +func (r *RunAction) Configuration() []configuration.Field { + fields := []configuration.Field{ + { + Name: "actionName", + Label: "Action", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "action", + }, + }, + Description: "The plugin action to execute", + }, + } + + manifest := getCachedManifest() + if manifest != nil { + for _, action := range manifest.Actions { + fields = append(fields, manifestFieldsToConfig(action.Fields, action.Name)...) + } + } + + return fields +} + +func manifestFieldsToConfig(mFields []FieldManifest, actionName string) []configuration.Field { + fields := make([]configuration.Field, 0, len(mFields)) + for _, f := range mFields { + field := configuration.Field{ + Name: fmt.Sprintf("param_%s_%s", actionName, f.Name), + Label: f.Label, + Description: f.Description, + Required: f.Required, + Default: f.Default, + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "actionName", Values: []string{actionName}}, + }, + } + + switch f.Type { + case "string": + field.Type = configuration.FieldTypeString + case "text": + field.Type = configuration.FieldTypeText + case "number": + field.Type = configuration.FieldTypeNumber + case "bool": + field.Type = configuration.FieldTypeBool + case "select": + field.Type = configuration.FieldTypeSelect + opts := make([]configuration.FieldOption, 0, len(f.Options)) + for _, o := range f.Options { + opts = append(opts, configuration.FieldOption{Label: o.Label, Value: o.Value}) + } + field.TypeOptions = &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{Options: opts}, + } + case "object": + field.Type = configuration.FieldTypeObject + default: + field.Type = configuration.FieldTypeString + } + + fields = append(fields, field) + } + return fields +} + +func (r *RunAction) Setup(ctx core.SetupContext) error { + var config RunActionConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.ActionName == "" { + return fmt.Errorf("actionName is required") + } + + client, err := NewClientWithHTTP(ctx.Integration, ctx.HTTP) + if err != nil { + return fmt.Errorf("failed to create plugin client: %w", err) + } + + manifest, err := client.FetchManifest() + if err != nil { + return fmt.Errorf("failed to fetch manifest: %w", err) + } + + found := false + for _, action := range manifest.Actions { + if action.Name == config.ActionName { + found = true + break + } + } + + if !found { + return fmt.Errorf("action %q not found in plugin manifest", config.ActionName) + } + + return nil +} + +func (r *RunAction) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (r *RunAction) Execute(ctx core.ExecutionContext) error { + var config RunActionConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return r.emitFailure(ctx, fmt.Sprintf("failed to decode configuration: %v", err)) + } + + if config.ActionName == "" { + return r.emitFailure(ctx, "actionName is required") + } + + client, err := NewClientWithHTTP(ctx.Integration, ctx.HTTP) + if err != nil { + return r.emitFailure(ctx, fmt.Sprintf("failed to create plugin client: %v", err)) + } + + params := extractActionParams(ctx.Configuration, config.ActionName) + + result, err := client.ExecuteAction(config.ActionName, params, ctx.Data) + if err != nil { + return r.emitFailure(ctx, fmt.Sprintf("failed to execute action: %v", err)) + } + + if !result.Success { + return r.emitFailure(ctx, result.Error) + } + + payload := map[string]any{ + "action": config.ActionName, + "result": result.Data, + } + + return ctx.ExecutionState.Emit(SuccessChannel, SuccessPayloadType, []any{payload}) +} + +func extractActionParams(rawConfig any, actionName string) map[string]any { + configMap, ok := rawConfig.(map[string]any) + if !ok { + return map[string]any{} + } + + params := map[string]any{} + prefix := fmt.Sprintf("param_%s_", actionName) + for key, value := range configMap { + if len(key) > len(prefix) && key[:len(prefix)] == prefix { + paramName := key[len(prefix):] + params[paramName] = value + } + } + + return params +} + +func (r *RunAction) emitFailure(ctx core.ExecutionContext, errMsg string) error { + payload := map[string]any{"error": errMsg} + if err := ctx.ExecutionState.Emit(FailureChannel, FailurePayloadType, []any{payload}); err != nil { + return err + } + return ctx.ExecutionState.Fail(models.CanvasNodeExecutionResultReasonError, errMsg) +} + +func (r *RunAction) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return 200, nil, nil +} + +func (r *RunAction) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (r *RunAction) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (r *RunAction) Hooks() []core.Hook { + return []core.Hook{} +} + +func (r *RunAction) HandleHook(ctx core.ActionHookContext) error { + return nil +} From 8ea8fbd53aba0f20d9d3be33ab2e2d6363980e6d Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 08:29:26 -0700 Subject: [PATCH 04/23] :sparkles: feat(plugin): add OnEvent trigger for plugin server events --- pkg/integrations/plugin/on_event.go | 132 ++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 pkg/integrations/plugin/on_event.go diff --git a/pkg/integrations/plugin/on_event.go b/pkg/integrations/plugin/on_event.go new file mode 100644 index 0000000000..f3d24edd53 --- /dev/null +++ b/pkg/integrations/plugin/on_event.go @@ -0,0 +1,132 @@ +package plugin + +import ( + "fmt" + "net/http" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type OnEvent struct{} + +type OnEventConfiguration struct { + EventType string `json:"eventType" mapstructure:"eventType"` +} + +type OnEventMetadata struct { + SubscriptionID *string `json:"subscriptionID,omitempty" mapstructure:"subscriptionID,omitempty"` +} + +func (t *OnEvent) Name() string { + return "plugin.onEvent" +} + +func (t *OnEvent) Label() string { + return "On Plugin Event" +} + +func (t *OnEvent) Description() string { + return "Listen for events from a connected plugin server" +} + +func (t *OnEvent) Documentation() string { + return `Triggers a workflow when a plugin server emits an event. + +## Use Cases + +- React to events from custom services +- Process webhooks from internal tools +- Listen for state changes in external systems + +## Configuration + +- **Event Type**: Optional filter — only trigger on events matching this type. Leave empty to receive all events. + +## Event Data + +The event payload depends on what the plugin server sends. It is passed through as-is.` +} + +func (t *OnEvent) Icon() string { + return "puzzle" +} + +func (t *OnEvent) Color() string { + return "gray" +} + +func (t *OnEvent) ExampleData() map[string]any { + return map[string]any{ + "type": "plugin.event", + "data": map[string]any{ + "eventType": "example.event", + "payload": map[string]any{ + "message": "Something happened", + }, + }, + "timestamp": "2026-05-30T12:00:00Z", + } +} + +func (t *OnEvent) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "eventType", + Label: "Event Type", + Type: configuration.FieldTypeString, + Required: false, + Description: "Optional: filter to only trigger on events of this type. Leave empty for all events.", + }, + } +} + +func (t *OnEvent) Setup(ctx core.TriggerContext) error { + var metadata OnEventMetadata + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return fmt.Errorf("failed to decode metadata: %w", err) + } + + if metadata.SubscriptionID != nil { + return nil + } + + var config OnEventConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + subscriptionConfig := map[string]any{ + "type": "plugin_event", + } + if config.EventType != "" { + subscriptionConfig["eventType"] = config.EventType + } + + subscriptionID, err := ctx.Integration.Subscribe(subscriptionConfig) + if err != nil { + return fmt.Errorf("failed to subscribe: %w", err) + } + + s := subscriptionID.String() + return ctx.Metadata.Set(OnEventMetadata{ + SubscriptionID: &s, + }) +} + +func (t *OnEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (t *OnEvent) Hooks() []core.Hook { + return []core.Hook{} +} + +func (t *OnEvent) HandleHook(ctx core.TriggerHookContext) (map[string]any, error) { + return nil, nil +} + +func (t *OnEvent) Cleanup(ctx core.TriggerContext) error { + return nil +} From 99f750f4939e387449806beadeb6e3148671d822 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 08:29:31 -0700 Subject: [PATCH 05/23] :sparkles: feat(plugin): add main integration with registration --- pkg/integrations/plugin/plugin.go | 214 +++++++++++++++++++++++++ pkg/registryimports/registryimports.go | 1 + 2 files changed, 215 insertions(+) create mode 100644 pkg/integrations/plugin/plugin.go diff --git a/pkg/integrations/plugin/plugin.go b/pkg/integrations/plugin/plugin.go new file mode 100644 index 0000000000..c3a73199c8 --- /dev/null +++ b/pkg/integrations/plugin/plugin.go @@ -0,0 +1,214 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +func init() { + registry.RegisterIntegration("plugin", &Plugin{}) +} + +type Plugin struct{} + +type PluginConfiguration struct { + ServerURL string `json:"serverUrl" mapstructure:"serverUrl"` + AuthToken string `json:"authToken" mapstructure:"authToken"` +} + +type PluginMetadata struct { + ManifestName string `json:"manifestName" mapstructure:"manifestName"` + ManifestLabel string `json:"manifestLabel" mapstructure:"manifestLabel"` +} + +func (p *Plugin) Name() string { + return "plugin" +} + +func (p *Plugin) Label() string { + return "Plugin" +} + +func (p *Plugin) Icon() string { + return "puzzle" +} + +func (p *Plugin) Description() string { + return "Connect to custom plugin servers built with the SuperPlane Plugin SDK" +} + +func (p *Plugin) Instructions() string { + return `### Setup + +1. Build a plugin server using the SuperPlane Plugin SDK +2. Deploy your plugin server so SuperPlane can reach it +3. Enter the server URL below +4. Optionally add an auth token if your server requires authentication + +The plugin server exposes a manifest at ` + "`GET /manifest`" + ` describing available actions and their configuration fields.` +} + +func (p *Plugin) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "serverUrl", + Label: "Server URL", + Type: configuration.FieldTypeString, + Required: true, + Description: "URL of the plugin server (e.g. https://my-plugin.example.com)", + }, + { + Name: "authToken", + Label: "Auth Token", + Type: configuration.FieldTypeString, + Required: false, + Sensitive: true, + Description: "Optional bearer token for authenticating with the plugin server", + }, + } +} + +func (p *Plugin) Actions() []core.Action { + return []core.Action{ + &RunAction{}, + } +} + +func (p *Plugin) Triggers() []core.Trigger { + return []core.Trigger{ + &OnEvent{}, + } +} + +func (p *Plugin) Sync(ctx core.SyncContext) error { + config := PluginConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.ServerURL == "" { + return fmt.Errorf("serverUrl is required") + } + + client := &Client{ + serverURL: config.ServerURL, + authToken: config.AuthToken, + httpDo: ctx.HTTP.Do, + } + + manifest, err := client.FetchManifest() + if err != nil { + return fmt.Errorf("failed to connect to plugin server: %w", err) + } + + if manifest.Name == "" { + return fmt.Errorf("plugin manifest is missing 'name' field") + } + + setCachedManifest(manifest) + + ctx.Integration.SetMetadata(PluginMetadata{ + ManifestName: manifest.Name, + ManifestLabel: manifest.Label, + }) + + ctx.Integration.Ready() + return nil +} + +func (p *Plugin) Cleanup(ctx core.IntegrationCleanupContext) error { + setCachedManifest(nil) + return nil +} + +func (p *Plugin) HandleRequest(ctx core.HTTPRequestContext) { + body, err := io.ReadAll(ctx.Request.Body) + if err != nil { + ctx.Logger.Errorf("failed to read request body: %v", err) + ctx.Response.WriteHeader(http.StatusBadRequest) + return + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + ctx.Logger.Errorf("failed to parse event payload: %v", err) + ctx.Response.WriteHeader(http.StatusBadRequest) + return + } + + eventType, _ := payload["eventType"].(string) + + subscriptions, err := ctx.Integration.ListSubscriptions() + if err != nil { + ctx.Logger.Errorf("failed to list subscriptions: %v", err) + ctx.Response.WriteHeader(http.StatusInternalServerError) + return + } + + for _, sub := range subscriptions { + config, ok := sub.Configuration().(map[string]any) + if !ok { + continue + } + + subType, _ := config["type"].(string) + if subType != "plugin_event" { + continue + } + + filterType, _ := config["eventType"].(string) + if filterType != "" && filterType != eventType { + continue + } + + if err := sub.SendMessage(payload); err != nil { + ctx.Logger.Errorf("failed to send message to subscription: %v", err) + } + } + + ctx.Response.WriteHeader(http.StatusOK) +} + +func (p *Plugin) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + if resourceType != "action" { + return []core.IntegrationResource{}, nil + } + + client, err := NewClientWithHTTP(ctx.Integration, ctx.HTTP) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + manifest, err := client.FetchManifest() + if err != nil { + return nil, fmt.Errorf("failed to fetch manifest: %w", err) + } + + setCachedManifest(manifest) + + resources := make([]core.IntegrationResource, 0, len(manifest.Actions)) + for _, action := range manifest.Actions { + resources = append(resources, core.IntegrationResource{ + Type: "action", + ID: action.Name, + Name: action.Label, + }) + } + + return resources, nil +} + +func (p *Plugin) Hooks() []core.Hook { + return []core.Hook{} +} + +func (p *Plugin) HandleHook(ctx core.IntegrationHookContext) error { + return nil +} diff --git a/pkg/registryimports/registryimports.go b/pkg/registryimports/registryimports.go index 946ff2b885..4290be6cf0 100644 --- a/pkg/registryimports/registryimports.go +++ b/pkg/registryimports/registryimports.go @@ -55,6 +55,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/openai" _ "github.com/superplanehq/superplane/pkg/integrations/pagerduty" _ "github.com/superplanehq/superplane/pkg/integrations/perplexity" + _ "github.com/superplanehq/superplane/pkg/integrations/plugin" _ "github.com/superplanehq/superplane/pkg/integrations/prometheus" _ "github.com/superplanehq/superplane/pkg/integrations/render" _ "github.com/superplanehq/superplane/pkg/integrations/rootly" From 558921520d0b8c2fc79568d7669c28e2c8061223 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 08:29:35 -0700 Subject: [PATCH 06/23] :sparkles: feat(sdk): add TypeScript Plugin SDK --- sdk/package.json | 20 +++++++++ sdk/src/index.ts | 33 ++++++++++++++ sdk/src/server.ts | 112 ++++++++++++++++++++++++++++++++++++++++++++++ sdk/src/types.ts | 69 ++++++++++++++++++++++++++++ sdk/tsconfig.json | 14 ++++++ 5 files changed, 248 insertions(+) create mode 100644 sdk/package.json create mode 100644 sdk/src/index.ts create mode 100644 sdk/src/server.ts create mode 100644 sdk/src/types.ts create mode 100644 sdk/tsconfig.json diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000000..a13503b0ec --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,20 @@ +{ + "name": "@superplane/plugin-sdk", + "version": "0.1.0", + "description": "SDK for building SuperPlane plugin integrations", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "express": "^5.1.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "typescript": "^5.8.0" + } +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts new file mode 100644 index 0000000000..e2f4b2b3ad --- /dev/null +++ b/sdk/src/index.ts @@ -0,0 +1,33 @@ +import { PluginServer } from "./server.js"; +import type { + ActionDefinition, + PluginOptions, + FieldDefinition, + FieldOption, +} from "./types.js"; + +export function createPlugin(options: PluginOptions): PluginBuilder { + return new PluginBuilder(options); +} + +class PluginBuilder { + private server: PluginServer; + + constructor(options: PluginOptions) { + this.server = new PluginServer(options); + } + + action>( + name: string, + definition: ActionDefinition, + ): this { + this.server.addAction(name, definition as ActionDefinition); + return this; + } + + listen(port: number, callback?: () => void): void { + this.server.listen(port, callback); + } +} + +export type { ActionDefinition, PluginOptions, FieldDefinition, FieldOption }; diff --git a/sdk/src/server.ts b/sdk/src/server.ts new file mode 100644 index 0000000000..bbbd9c102a --- /dev/null +++ b/sdk/src/server.ts @@ -0,0 +1,112 @@ +import express, { type Express, type Request, type Response } from "express"; +import type { + ActionDefinition, + ActionManifest, + ExecuteRequest, + ExecuteResponse, + Manifest, + PluginOptions, +} from "./types.js"; + +export class PluginServer { + private app: Express; + private actions: Map = new Map(); + private options: PluginOptions; + + constructor(options: PluginOptions) { + this.options = options; + this.app = express(); + this.app.use(express.json()); + this.setupRoutes(); + } + + addAction(name: string, definition: ActionDefinition): void { + this.actions.set(name, definition); + } + + listen(port: number, callback?: () => void): void { + this.app.listen( + port, + callback ?? + (() => { + console.log( + `Plugin server "${this.options.name}" listening on port ${port}`, + ); + }), + ); + } + + getManifest(): Manifest { + const actions: ActionManifest[] = []; + + for (const [name, def] of this.actions) { + const fields = Object.entries(def.fields).map(([fieldName, field]) => ({ + name: fieldName, + label: field.label, + type: field.type, + description: field.description ?? "", + required: field.required ?? false, + default: field.default, + options: field.options, + })); + + actions.push({ + name, + label: def.label, + description: def.description ?? "", + fields, + }); + } + + return { + name: this.options.name, + label: this.options.label ?? this.options.name, + icon: this.options.icon ?? "puzzle", + description: this.options.description ?? "", + actions, + }; + } + + private setupRoutes(): void { + this.app.get("/manifest", (_req: Request, res: Response) => { + res.json(this.getManifest()); + }); + + this.app.post( + "/actions/:name/execute", + async (req: Request, res: Response) => { + const name = req.params.name as string; + const action = this.actions.get(name); + + if (!action) { + const response: ExecuteResponse = { + success: false, + error: `Action "${name}" not found`, + }; + res.status(404).json(response); + return; + } + + const body = req.body as ExecuteRequest; + + try { + const result = await action.execute(body.parameters ?? {}, { + input: body.input, + }); + + const response: ExecuteResponse = { + success: true, + data: result, + }; + res.json(response); + } catch (err) { + const response: ExecuteResponse = { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + res.status(500).json(response); + } + }, + ); + } +} diff --git a/sdk/src/types.ts b/sdk/src/types.ts new file mode 100644 index 0000000000..9246e4e032 --- /dev/null +++ b/sdk/src/types.ts @@ -0,0 +1,69 @@ +export interface FieldOption { + label: string; + value: string; +} + +export interface FieldDefinition { + name: string; + label: string; + type: "string" | "text" | "number" | "bool" | "select" | "object"; + description?: string; + required?: boolean; + default?: unknown; + options?: FieldOption[]; +} + +export interface ActionDefinition> { + label: string; + description?: string; + fields: Record>; + execute: ( + params: TParams, + ctx: ExecutionContext, + ) => Promise>; +} + +export interface ExecutionContext { + input: unknown; +} + +export interface ActionManifest { + name: string; + label: string; + description: string; + fields: Array<{ + name: string; + label: string; + type: string; + description: string; + required: boolean; + default?: unknown; + options?: FieldOption[]; + }>; +} + +export interface Manifest { + name: string; + label: string; + icon: string; + description: string; + actions: ActionManifest[]; +} + +export interface PluginOptions { + name: string; + label?: string; + icon?: string; + description?: string; +} + +export interface ExecuteRequest { + parameters: Record; + input?: unknown; +} + +export interface ExecuteResponse { + success: boolean; + data?: Record; + error?: string; +} diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json new file mode 100644 index 0000000000..8c5e8c251d --- /dev/null +++ b/sdk/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} From 9400336cff2e8ad03d07037465743a0dde251423 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 08:29:38 -0700 Subject: [PATCH 07/23] :sparkles: feat(sdk): add example quotes plugin --- sdk/example/index.ts | 95 ++++++++++++++++++++++++++++++++++++++++ sdk/example/package.json | 12 +++++ 2 files changed, 107 insertions(+) create mode 100644 sdk/example/index.ts create mode 100644 sdk/example/package.json diff --git a/sdk/example/index.ts b/sdk/example/index.ts new file mode 100644 index 0000000000..8c054a2fb3 --- /dev/null +++ b/sdk/example/index.ts @@ -0,0 +1,95 @@ +import { createPlugin } from "@superplane/plugin-sdk"; + +const plugin = createPlugin({ + name: "quotes", + label: "Random Quotes", + icon: "quote", + description: "Get random quotes and generate greetings", +}); + +const quotes = [ + { + text: "The only way to do great work is to love what you do.", + author: "Steve Jobs", + }, + { + text: "Innovation distinguishes between a leader and a follower.", + author: "Steve Jobs", + }, + { text: "Stay hungry, stay foolish.", author: "Steve Jobs" }, + { text: "Move fast and break things.", author: "Mark Zuckerberg" }, + { + text: "The best way to predict the future is to invent it.", + author: "Alan Kay", + }, + { text: "Talk is cheap. Show me the code.", author: "Linus Torvalds" }, +]; + +plugin.action("get-quote", { + label: "Get Random Quote", + description: "Returns a random inspirational quote", + fields: { + category: { + label: "Category", + type: "select", + description: "Filter quotes by category", + required: false, + options: [ + { label: "All", value: "all" }, + { label: "Innovation", value: "innovation" }, + { label: "Motivation", value: "motivation" }, + ], + }, + }, + execute: async () => { + const idx = Math.floor(Math.random() * quotes.length); + const quote = quotes[idx]; + return { + quote: quote.text, + author: quote.author, + index: idx, + }; + }, +}); + +plugin.action("greet", { + label: "Generate Greeting", + description: "Generate a personalized greeting message", + fields: { + name: { + label: "Name", + type: "string", + description: "Name of the person to greet", + required: true, + }, + style: { + label: "Style", + type: "select", + description: "Greeting style", + required: true, + options: [ + { label: "Formal", value: "formal" }, + { label: "Casual", value: "casual" }, + { label: "Enthusiastic", value: "enthusiastic" }, + ], + }, + }, + execute: async (params) => { + const name = params.name as string; + const style = params.style as string; + + const greetings: Record = { + formal: `Good day, ${name}. It is a pleasure to make your acquaintance.`, + casual: `Hey ${name}, what's up?`, + enthusiastic: `OMG ${name}!!! SO GREAT to see you!`, + }; + + return { + greeting: greetings[style] ?? greetings.casual, + style, + recipient: name, + }; + }, +}); + +plugin.listen(3001); diff --git a/sdk/example/package.json b/sdk/example/package.json new file mode 100644 index 0000000000..44f783cab4 --- /dev/null +++ b/sdk/example/package.json @@ -0,0 +1,12 @@ +{ + "name": "plugin-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "bun run index.ts" + }, + "dependencies": { + "@superplane/plugin-sdk": "file:../" + } +} From ea8af9be26ae6828e47aa76621c2e2ceeed59e4f Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 08:29:45 -0700 Subject: [PATCH 08/23] :memo: docs: add Plugin SDK documentation --- PLUGIN-DOCS.md | 281 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 PLUGIN-DOCS.md diff --git a/PLUGIN-DOCS.md b/PLUGIN-DOCS.md new file mode 100644 index 0000000000..6d84d4ceaf --- /dev/null +++ b/PLUGIN-DOCS.md @@ -0,0 +1,281 @@ +# SuperPlane Plugin SDK + +Build custom SuperPlane integrations without modifying the SuperPlane codebase. Write a plugin server with the TypeScript SDK, point SuperPlane at it, and your actions appear natively in the canvas UI. + +## Architecture + +``` +┌──────────────┐ ┌──────────────────┐ ┌────────────────┐ +│ SuperPlane │──GET───▶│ Plugin Server │ │ Your Code │ +│ (Plugin │ /manifest│ (SDK-powered) │◀────────│ (actions, │ +│ Integration)│ │ │ │ logic) │ +│ │──POST──▶│ /actions/:name/ │ │ │ +│ │ │ execute │ │ │ +│ │◀──POST──│ POST events to │ │ │ +│ │ │ SuperPlane │ │ │ +└──────────────┘ └──────────────────┘ └────────────────┘ +``` + +1. You build a plugin server using the SDK +2. SuperPlane's Plugin integration connects to your server +3. On setup, it fetches your manifest to discover available actions +4. When a canvas node runs your action, SuperPlane proxies the execution to your server +5. Your server can push events back to SuperPlane to trigger workflows + +## Quick Start + +### 1. Create a new project + +```bash +mkdir my-plugin && cd my-plugin +bun init -y +bun add @superplane/plugin-sdk +``` + +### 2. Write your plugin + +```typescript +// index.ts +import { createPlugin } from "@superplane/plugin-sdk"; + +const plugin = createPlugin({ + name: "my-plugin", + label: "My Plugin", + description: "Does useful things", +}); + +plugin.action("hello", { + label: "Say Hello", + description: "Generates a greeting", + fields: { + name: { + label: "Name", + type: "string", + required: true, + description: "Who to greet", + }, + }, + execute: async (params) => { + return { message: `Hello, ${params.name}!` }; + }, +}); + +plugin.listen(3001); +``` + +### 3. Run it + +```bash +bun run index.ts +# Plugin server "my-plugin" listening on port 3001 +``` + +### 4. Connect to SuperPlane + +1. In SuperPlane, add a new **Plugin** integration +2. Set **Server URL** to your plugin server's address (e.g. `https://my-plugin.example.com`) +3. Optionally set an **Auth Token** +4. Save — SuperPlane fetches your manifest and the integration goes ready + +### 5. Use in a canvas + +Add a **Run Plugin Action** node to your canvas. Select your action from the dropdown. Fill in the fields. Done. + +## Protocol Reference + +The SDK handles all of this for you, but if you want to build a plugin server in another language, here's the protocol. + +### `GET /manifest` + +Returns the plugin's metadata and available actions. + +**Response:** + +```json +{ + "name": "my-plugin", + "label": "My Plugin", + "icon": "puzzle", + "description": "Does useful things", + "actions": [ + { + "name": "hello", + "label": "Say Hello", + "description": "Generates a greeting", + "fields": [ + { + "name": "name", + "label": "Name", + "type": "string", + "description": "Who to greet", + "required": true + } + ] + } + ] +} +``` + +### `POST /actions/{name}/execute` + +Executes an action. + +**Request:** + +```json +{ + "parameters": { + "name": "World" + }, + "input": { ... } +} +``` + +- `parameters`: the field values configured by the user +- `input`: data from the upstream node in the canvas (may be `null`) + +**Success response (200):** + +```json +{ + "success": true, + "data": { + "message": "Hello, World!" + } +} +``` + +**Error response (4xx/5xx):** + +```json +{ + "success": false, + "error": "Something went wrong" +} +``` + +### Events (Triggers) + +To push events from your plugin server into SuperPlane, POST to: + +``` +POST /api/v1/integrations/{integration_id}/events +Content-Type: application/json + +{ + "eventType": "my.event.type", + "payload": { ... } +} +``` + +Use the **On Plugin Event** trigger in your canvas to listen for these events. You can filter by `eventType`. + +## SDK API Reference + +### `createPlugin(options)` + +Creates a new plugin builder. + +```typescript +const plugin = createPlugin({ + name: "my-plugin", // required, unique identifier + label: "My Plugin", // optional, display name (defaults to name) + icon: "puzzle", // optional, icon identifier + description: "...", // optional +}); +``` + +### `.action(name, definition)` + +Registers an action. + +```typescript +plugin.action("do-thing", { + label: "Do Thing", // required, display name + description: "Does a thing", // optional + fields: { // required, config fields + myField: { + label: "My Field", + type: "string", + required: true, + description: "What it does", + }, + }, + execute: async (params, ctx) => { // required, execution handler + // params = { myField: "value" } + // ctx.input = data from upstream node + return { result: "ok" }; // must return an object + }, +}); +``` + +Returns `this` for chaining. + +### `.listen(port, callback?)` + +Starts the HTTP server. + +```typescript +plugin.listen(3001); +plugin.listen(3001, () => console.log("Ready!")); +``` + +## Field Types + +| Type | Description | Extra options | +|------|-------------|---------------| +| `string` | Single-line text input | — | +| `text` | Multi-line text input | — | +| `number` | Numeric input | — | +| `bool` | Boolean checkbox | — | +| `select` | Dropdown selection | `options: [{label, value}]` | +| `object` | JSON object editor | — | + +### Field definition + +```typescript +{ + label: "Display Name", // required + type: "string", // required + description: "Help text", // optional + required: true, // optional, default false + default: "value", // optional + options: [ // required for "select" type + { label: "Option A", value: "a" }, + { label: "Option B", value: "b" }, + ], +} +``` + +## Authentication + +If your plugin server requires authentication, set the **Auth Token** in the SuperPlane Plugin integration config. The token is sent as a `Bearer` token in the `Authorization` header on every request from SuperPlane to your server. + +To validate it server-side, add middleware to your Express app (the SDK doesn't enforce auth by default): + +```typescript +// Example: add auth middleware before creating the plugin +// This is outside the SDK — use standard Express patterns +``` + +## Deploying + +Your plugin server is a standard HTTP server. Deploy it anywhere SuperPlane can reach: + +- **Local development**: `localhost` or tunnel (ngrok, cloudflared) +- **Cloud**: any container platform (Railway, Fly.io, Cloud Run, ECS) +- **Self-hosted**: any server with a public or VPN-accessible URL + +The server must be reachable from SuperPlane at the configured URL. HTTPS is recommended for production. + +## Example + +See [`sdk/example/`](sdk/example/) for a complete example plugin with two actions (random quotes and greetings). Run it: + +```bash +cd sdk/example +bun install +bun run index.ts +``` + +Then connect SuperPlane to `http://localhost:3001`. From ac0515ab4bca6ab0fe103dcbbde89baaabd152b1 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 08:32:47 -0700 Subject: [PATCH 09/23] :wrench: chore: add plugin-example service to docker-compose --- docker-compose.dev.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9e54e5776c..5db94074d7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -215,6 +215,16 @@ services: timeout: 3s retries: 5 + plugin-example: + image: oven/bun:1 + profiles: [plugin] + working_dir: /app/sdk/example + command: ["sh", "-c", "bun install && bun run index.ts"] + volumes: + - .:/app + ports: + - ${PLUGIN_EXAMPLE_PORT:-3001}:3001 + volumes: supergit-data: driver: local From b4a3385a4fbcca99826aa8c252c93af1aae7ca8e Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 09:39:21 -0700 Subject: [PATCH 10/23] :bug: fix(plugin-example): build SDK before running example in Docker --- docker-compose.dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5db94074d7..4345d62437 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -219,7 +219,7 @@ services: image: oven/bun:1 profiles: [plugin] working_dir: /app/sdk/example - command: ["sh", "-c", "bun install && bun run index.ts"] + command: ["sh", "-c", "cd /app/sdk && bun install && bun run build && cd /app/sdk/example && bun install && bun run index.ts"] volumes: - .:/app ports: From ecdeec5987ad750790f87e1f3b0fdec01e0255b1 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 09:45:31 -0700 Subject: [PATCH 11/23] :bug: fix(plugin-example): clear node_modules and skip cache on install --- docker-compose.dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 4345d62437..df33d31713 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -219,7 +219,7 @@ services: image: oven/bun:1 profiles: [plugin] working_dir: /app/sdk/example - command: ["sh", "-c", "cd /app/sdk && bun install && bun run build && cd /app/sdk/example && bun install && bun run index.ts"] + command: ["sh", "-c", "cd /app/sdk && bun install && bun run build && cd /app/sdk/example && rm -rf node_modules && bun install --no-cache && bun run index.ts"] volumes: - .:/app ports: From c0d6c705af39474cd40c9873b20f2629a6402ae2 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 10:34:53 -0700 Subject: [PATCH 12/23] Update PLUGIN-DOCS.md --- PLUGIN-DOCS.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/PLUGIN-DOCS.md b/PLUGIN-DOCS.md index 6d84d4ceaf..865559e903 100644 --- a/PLUGIN-DOCS.md +++ b/PLUGIN-DOCS.md @@ -5,15 +5,15 @@ Build custom SuperPlane integrations without modifying the SuperPlane codebase. ## Architecture ``` -┌──────────────┐ ┌──────────────────┐ ┌────────────────┐ -│ SuperPlane │──GET───▶│ Plugin Server │ │ Your Code │ -│ (Plugin │ /manifest│ (SDK-powered) │◀────────│ (actions, │ -│ Integration)│ │ │ │ logic) │ -│ │──POST──▶│ /actions/:name/ │ │ │ -│ │ │ execute │ │ │ -│ │◀──POST──│ POST events to │ │ │ -│ │ │ SuperPlane │ │ │ -└──────────────┘ └──────────────────┘ └────────────────┘ +┌──────────────┐ ┌──────────────────┐ ┌────────────────┐ +│ SuperPlane │ ──GET───▶ │ Plugin Server │ │ Your Code │ +│ (Plugin │ /manifest │ (SDK-powered) │◀────────│ (actions, │ +│ Integration)│ │ │ │ logic) │ +│ │ ──POST──▶ │ /actions/:name/ │ │ │ +│ │ │ execute │ │ │ +│ │ ◀──POST── │ POST events to │ │ │ +│ │ │ SuperPlane │ │ │ +└──────────────┘ └──────────────────┘ └────────────────┘ ``` 1. You build a plugin server using the SDK From d32ca8c2bc1c191df54b18ee70b4eebb924394a4 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 11:19:44 -0700 Subject: [PATCH 13/23] :wrench: fix(dev): make compose stack run under Podman PODMAN-SPECIFIC FIX. The dev compose stack failed to start under Podman: `links` is unsupported ("link is not supported"), and the `/tmp:/tmp` bind-mount inherited Podman rootless UID mapping so container root could not write to /tmp, breaking `go build`. Drop the deprecated/redundant `links` (compose DNS already resolves services by name), drop the /tmp bind-mount (container gets its own writable /tmp), and add `init: true` so tini reaps orphaned watcher processes. All harmless under Docker. --- docker-compose.dev.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index df33d31713..475b2d9154 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -6,6 +6,7 @@ services: cache_from: - ghcr.io/superplanehq/superplane-dev-base:app-latest tty: true + init: true # Idle by default so `make dev.up` does not run installs or the API/UI stack. # Run `make dev.server` to start air + Vite inside this container. command: ["sleep", "infinity"] @@ -129,10 +130,6 @@ services: - ${PUBLIC_API_PORT:-8000}:${PUBLIC_API_PORT:-8000} - ${INTERNAL_API_PORT:-50051}:${INTERNAL_API_PORT:-50051} - links: - - db:db - - rabbitmq:rabbitmq - depends_on: db: condition: service_healthy @@ -144,7 +141,6 @@ services: volumes: - go-pkg-cache:/go - .:/app - - /tmp:/tmp supergit: image: ghcr.io/superplanehq/supergit:0.1.1 @@ -194,8 +190,6 @@ services: DATABASE_URL: "postgres://postgres:the-cake-is-a-lie@db:5432/superplane_dev?sslmode=disable" ports: - ${PGWEB_PORT:-8081}:${PGWEB_PORT:-8081} - links: - - db:db depends_on: db: condition: service_healthy From eace1bfe664a56695e8f3d68e8e89c968f82a17a Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 11:19:52 -0700 Subject: [PATCH 14/23] :wrench: fix(dev): keep watchers alive under Podman detached exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PODMAN-SPECIFIC FIX. `make dev.server` launches the entrypoint via `compose exec -d`. Under Podman, when the detached exec session leader exits, the whole exec process group is SIGKILLed — so air, vite, and the Go server they spawn die the moment the script returns, leaving the app unreachable (502 from the Go proxy to a dead Vite). Run each watcher under `setsid` so it lives in its own session and survives the exec teardown. Redirect output to /app/tmp logs since the detached session has no terminal. Harmless under Docker. --- docker-entrypoint.dev.sh | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docker-entrypoint.dev.sh b/docker-entrypoint.dev.sh index f423acd834..76b44ab8d6 100644 --- a/docker-entrypoint.dev.sh +++ b/docker-entrypoint.dev.sh @@ -20,11 +20,16 @@ stop_watchers() { } stop_watchers -air & +# `make dev.server` launches this via `compose exec -d`. Under Podman, when the detached +# exec session leader exits, the whole exec process group is SIGKILLed — so backgrounded +# watchers (air, vite) and the Go server they spawn get reaped the moment this script returns. +# `setsid` moves each watcher into its own session so it survives the exec teardown. +# (Docker tolerated the old `air & ; npm run dev & ; wait -n`; Podman does not.) +LOG_DIR=/app/tmp +mkdir -p "$LOG_DIR" + +setsid air >"$LOG_DIR/air.log" 2>&1 & cd web_src -npm run dev & +setsid npm run dev >"$LOG_DIR/vite.log" 2>&1 & cd .. - -wait -n -exit $? From 7aa3be06f2e78fbb1cbd267c43a08d12f9cd8257 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 11:19:57 -0700 Subject: [PATCH 15/23] :wrench: fix(dev): exclude sdk from air watcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK example installs a copy of the plugin-sdk into sdk/example/node_modules, which itself contains example/node_modules — an infinite recursive directory loop. Air walked it forever and never built the server. The existing `node_modules` exclude only matches the top level, not this nested path; exclude `sdk` outright since it is TypeScript, not Go. --- .air.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.air.toml b/.air.toml index d83566290b..707b5578e4 100644 --- a/.air.toml +++ b/.air.toml @@ -7,7 +7,7 @@ bin = "tmp/superplane" # Debounce rebuilds when many files change at once (avoids racing builds / missing binary). delay = 500 include_ext = ["go", "yaml", "yml"] -exclude_dir = ["web_src", "tmp", "build", "api/swagger", "node_modules"] +exclude_dir = ["web_src", "tmp", "build", "api/swagger", "node_modules", "sdk"] stop_on_error = true send_interrupt = true kill_delay = "3s" From b5b57e29d2b67ae0ee0847a3fd1c6b3b56947d77 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 11:40:37 -0700 Subject: [PATCH 16/23] :truck: refactor(integrations): rename plugin integration to planelet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin concept is branded "Planelets" in the canvas UI. Align the backend so identifiers match the product name: package, directory, registry key, integration type name, and the node IDs/event types emitted on the wire (plugin.onEvent -> planelet.onEvent, etc.). Safe to change the wire identifiers now since nothing uses the integration yet — no existing canvases reference the old IDs. --- .../{plugin => planelet}/client.go | 4 +- .../{plugin => planelet}/example.go | 2 +- .../{plugin => planelet}/example_output.json | 2 +- .../{plugin => planelet}/manifest.go | 2 +- .../{plugin => planelet}/on_event.go | 16 ++--- .../plugin.go => planelet/planelet.go} | 64 +++++++++---------- .../{plugin => planelet}/run_action.go | 32 +++++----- pkg/registryimports/registryimports.go | 2 +- 8 files changed, 62 insertions(+), 62 deletions(-) rename pkg/integrations/{plugin => planelet}/client.go (96%) rename pkg/integrations/{plugin => planelet}/example.go (95%) rename pkg/integrations/{plugin => planelet}/example_output.json (81%) rename pkg/integrations/{plugin => planelet}/manifest.go (98%) rename pkg/integrations/{plugin => planelet}/on_event.go (88%) rename pkg/integrations/{plugin/plugin.go => planelet/planelet.go} (68%) rename pkg/integrations/{plugin => planelet}/run_action.go (86%) diff --git a/pkg/integrations/plugin/client.go b/pkg/integrations/planelet/client.go similarity index 96% rename from pkg/integrations/plugin/client.go rename to pkg/integrations/planelet/client.go index 831d7dff25..ed17c28a08 100644 --- a/pkg/integrations/plugin/client.go +++ b/pkg/integrations/planelet/client.go @@ -1,4 +1,4 @@ -package plugin +package planelet import ( "bytes" @@ -117,7 +117,7 @@ func (c *Client) ExecuteAction(actionName string, params map[string]any, input a if resp.StatusCode >= 400 { return &ExecuteResponse{ Success: false, - Error: fmt.Sprintf("plugin server returned status %d: %s", resp.StatusCode, string(respBody)), + Error: fmt.Sprintf("Planelet server returned status %d: %s", resp.StatusCode, string(respBody)), }, nil } diff --git a/pkg/integrations/plugin/example.go b/pkg/integrations/planelet/example.go similarity index 95% rename from pkg/integrations/plugin/example.go rename to pkg/integrations/planelet/example.go index 033a27703b..f1b265657f 100644 --- a/pkg/integrations/plugin/example.go +++ b/pkg/integrations/planelet/example.go @@ -1,4 +1,4 @@ -package plugin +package planelet import ( _ "embed" diff --git a/pkg/integrations/plugin/example_output.json b/pkg/integrations/planelet/example_output.json similarity index 81% rename from pkg/integrations/plugin/example_output.json rename to pkg/integrations/planelet/example_output.json index a191862aa3..17f9b2d740 100644 --- a/pkg/integrations/plugin/example_output.json +++ b/pkg/integrations/planelet/example_output.json @@ -1,5 +1,5 @@ { - "type": "plugin.action.success", + "type": "planelet.action.success", "data": { "action": "example-action", "result": { diff --git a/pkg/integrations/plugin/manifest.go b/pkg/integrations/planelet/manifest.go similarity index 98% rename from pkg/integrations/plugin/manifest.go rename to pkg/integrations/planelet/manifest.go index 54c444d6ec..1576c18566 100644 --- a/pkg/integrations/plugin/manifest.go +++ b/pkg/integrations/planelet/manifest.go @@ -1,4 +1,4 @@ -package plugin +package planelet import ( "sync" diff --git a/pkg/integrations/plugin/on_event.go b/pkg/integrations/planelet/on_event.go similarity index 88% rename from pkg/integrations/plugin/on_event.go rename to pkg/integrations/planelet/on_event.go index f3d24edd53..f17363df26 100644 --- a/pkg/integrations/plugin/on_event.go +++ b/pkg/integrations/planelet/on_event.go @@ -1,4 +1,4 @@ -package plugin +package planelet import ( "fmt" @@ -20,19 +20,19 @@ type OnEventMetadata struct { } func (t *OnEvent) Name() string { - return "plugin.onEvent" + return "planelet.onEvent" } func (t *OnEvent) Label() string { - return "On Plugin Event" + return "On Planelet Event" } func (t *OnEvent) Description() string { - return "Listen for events from a connected plugin server" + return "Listen for events from a connected Planelet server" } func (t *OnEvent) Documentation() string { - return `Triggers a workflow when a plugin server emits an event. + return `Triggers a workflow when a Planelet server emits an event. ## Use Cases @@ -46,7 +46,7 @@ func (t *OnEvent) Documentation() string { ## Event Data -The event payload depends on what the plugin server sends. It is passed through as-is.` +The event payload depends on what the Planelet server sends. It is passed through as-is.` } func (t *OnEvent) Icon() string { @@ -59,7 +59,7 @@ func (t *OnEvent) Color() string { func (t *OnEvent) ExampleData() map[string]any { return map[string]any{ - "type": "plugin.event", + "type": "planelet.event", "data": map[string]any{ "eventType": "example.event", "payload": map[string]any{ @@ -98,7 +98,7 @@ func (t *OnEvent) Setup(ctx core.TriggerContext) error { } subscriptionConfig := map[string]any{ - "type": "plugin_event", + "type": "planelet_event", } if config.EventType != "" { subscriptionConfig["eventType"] = config.EventType diff --git a/pkg/integrations/plugin/plugin.go b/pkg/integrations/planelet/planelet.go similarity index 68% rename from pkg/integrations/plugin/plugin.go rename to pkg/integrations/planelet/planelet.go index c3a73199c8..21cb766771 100644 --- a/pkg/integrations/plugin/plugin.go +++ b/pkg/integrations/planelet/planelet.go @@ -1,4 +1,4 @@ -package plugin +package planelet import ( "encoding/json" @@ -13,56 +13,56 @@ import ( ) func init() { - registry.RegisterIntegration("plugin", &Plugin{}) + registry.RegisterIntegration("planelet", &Planelet{}) } -type Plugin struct{} +type Planelet struct{} -type PluginConfiguration struct { +type PlaneletConfiguration struct { ServerURL string `json:"serverUrl" mapstructure:"serverUrl"` AuthToken string `json:"authToken" mapstructure:"authToken"` } -type PluginMetadata struct { +type PlaneletMetadata struct { ManifestName string `json:"manifestName" mapstructure:"manifestName"` ManifestLabel string `json:"manifestLabel" mapstructure:"manifestLabel"` } -func (p *Plugin) Name() string { - return "plugin" +func (p *Planelet) Name() string { + return "planelet" } -func (p *Plugin) Label() string { - return "Plugin" +func (p *Planelet) Label() string { + return "Planelets" } -func (p *Plugin) Icon() string { +func (p *Planelet) Icon() string { return "puzzle" } -func (p *Plugin) Description() string { - return "Connect to custom plugin servers built with the SuperPlane Plugin SDK" +func (p *Planelet) Description() string { + return "Connect to custom Planelet servers built with the SuperPlane Planelet SDK" } -func (p *Plugin) Instructions() string { +func (p *Planelet) Instructions() string { return `### Setup -1. Build a plugin server using the SuperPlane Plugin SDK -2. Deploy your plugin server so SuperPlane can reach it +1. Build a Planelet server using the SuperPlane Planelet SDK +2. Deploy your Planelet server so SuperPlane can reach it 3. Enter the server URL below 4. Optionally add an auth token if your server requires authentication -The plugin server exposes a manifest at ` + "`GET /manifest`" + ` describing available actions and their configuration fields.` +The Planelet server exposes a manifest at ` + "`GET /manifest`" + ` describing available actions and their configuration fields.` } -func (p *Plugin) Configuration() []configuration.Field { +func (p *Planelet) Configuration() []configuration.Field { return []configuration.Field{ { Name: "serverUrl", Label: "Server URL", Type: configuration.FieldTypeString, Required: true, - Description: "URL of the plugin server (e.g. https://my-plugin.example.com)", + Description: "URL of the Planelet server (e.g. https://my-planelet.example.com)", }, { Name: "authToken", @@ -70,25 +70,25 @@ func (p *Plugin) Configuration() []configuration.Field { Type: configuration.FieldTypeString, Required: false, Sensitive: true, - Description: "Optional bearer token for authenticating with the plugin server", + Description: "Optional bearer token for authenticating with the Planelet server", }, } } -func (p *Plugin) Actions() []core.Action { +func (p *Planelet) Actions() []core.Action { return []core.Action{ &RunAction{}, } } -func (p *Plugin) Triggers() []core.Trigger { +func (p *Planelet) Triggers() []core.Trigger { return []core.Trigger{ &OnEvent{}, } } -func (p *Plugin) Sync(ctx core.SyncContext) error { - config := PluginConfiguration{} +func (p *Planelet) Sync(ctx core.SyncContext) error { + config := PlaneletConfiguration{} if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { return fmt.Errorf("failed to decode configuration: %w", err) } @@ -105,16 +105,16 @@ func (p *Plugin) Sync(ctx core.SyncContext) error { manifest, err := client.FetchManifest() if err != nil { - return fmt.Errorf("failed to connect to plugin server: %w", err) + return fmt.Errorf("failed to connect to Planelet server: %w", err) } if manifest.Name == "" { - return fmt.Errorf("plugin manifest is missing 'name' field") + return fmt.Errorf("Planelet manifest is missing 'name' field") } setCachedManifest(manifest) - ctx.Integration.SetMetadata(PluginMetadata{ + ctx.Integration.SetMetadata(PlaneletMetadata{ ManifestName: manifest.Name, ManifestLabel: manifest.Label, }) @@ -123,12 +123,12 @@ func (p *Plugin) Sync(ctx core.SyncContext) error { return nil } -func (p *Plugin) Cleanup(ctx core.IntegrationCleanupContext) error { +func (p *Planelet) Cleanup(ctx core.IntegrationCleanupContext) error { setCachedManifest(nil) return nil } -func (p *Plugin) HandleRequest(ctx core.HTTPRequestContext) { +func (p *Planelet) HandleRequest(ctx core.HTTPRequestContext) { body, err := io.ReadAll(ctx.Request.Body) if err != nil { ctx.Logger.Errorf("failed to read request body: %v", err) @@ -159,7 +159,7 @@ func (p *Plugin) HandleRequest(ctx core.HTTPRequestContext) { } subType, _ := config["type"].(string) - if subType != "plugin_event" { + if subType != "planelet_event" { continue } @@ -176,7 +176,7 @@ func (p *Plugin) HandleRequest(ctx core.HTTPRequestContext) { ctx.Response.WriteHeader(http.StatusOK) } -func (p *Plugin) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { +func (p *Planelet) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { if resourceType != "action" { return []core.IntegrationResource{}, nil } @@ -205,10 +205,10 @@ func (p *Plugin) ListResources(resourceType string, ctx core.ListResourcesContex return resources, nil } -func (p *Plugin) Hooks() []core.Hook { +func (p *Planelet) Hooks() []core.Hook { return []core.Hook{} } -func (p *Plugin) HandleHook(ctx core.IntegrationHookContext) error { +func (p *Planelet) HandleHook(ctx core.IntegrationHookContext) error { return nil } diff --git a/pkg/integrations/plugin/run_action.go b/pkg/integrations/planelet/run_action.go similarity index 86% rename from pkg/integrations/plugin/run_action.go rename to pkg/integrations/planelet/run_action.go index 3bbd195003..d688269337 100644 --- a/pkg/integrations/plugin/run_action.go +++ b/pkg/integrations/planelet/run_action.go @@ -1,4 +1,4 @@ -package plugin +package planelet import ( "fmt" @@ -13,8 +13,8 @@ import ( const ( SuccessChannel = "success" FailureChannel = "failure" - SuccessPayloadType = "plugin.action.success" - FailurePayloadType = "plugin.action.failed" + SuccessPayloadType = "planelet.action.success" + FailurePayloadType = "planelet.action.failed" ) type RunAction struct{} @@ -24,39 +24,39 @@ type RunActionConfiguration struct { } func (r *RunAction) Name() string { - return "plugin.runAction" + return "planelet.runAction" } func (r *RunAction) Label() string { - return "Run Plugin Action" + return "Run Planelet Action" } func (r *RunAction) Description() string { manifest := getCachedManifest() if manifest == nil { - return "Execute an action on a connected plugin server" + return "Execute an action on a connected Planelet server" } return fmt.Sprintf("Execute an action on %s", manifest.Label) } func (r *RunAction) Documentation() string { - return `Run a remote action on a plugin server connected via the Plugin integration. + return `Run a remote action on a Planelet server connected via the Planelets integration. ## Use Cases - Execute custom business logic hosted on your own server -- Integrate with internal services via the Plugin SDK -- Run any action defined in the plugin server's manifest +- Integrate with internal services via the Planelet SDK +- Run any action defined in the Planelet server's manifest ## Configuration -- **Action**: Select which action to run from the plugin server's manifest +- **Action**: Select which action to run from the Planelet server's manifest - Additional fields appear dynamically based on the selected action ## Output Channels -- **Success**: Emitted when the plugin action succeeds, contains the action's response data -- **Failure**: Emitted when the action fails or the plugin server returns an error` +- **Success**: Emitted when the Planelet action succeeds, contains the action's response data +- **Failure**: Emitted when the action fails or the Planelet server returns an error` } func (r *RunAction) Icon() string { @@ -86,7 +86,7 @@ func (r *RunAction) Configuration() []configuration.Field { Type: "action", }, }, - Description: "The plugin action to execute", + Description: "The Planelet action to execute", }, } @@ -155,7 +155,7 @@ func (r *RunAction) Setup(ctx core.SetupContext) error { client, err := NewClientWithHTTP(ctx.Integration, ctx.HTTP) if err != nil { - return fmt.Errorf("failed to create plugin client: %w", err) + return fmt.Errorf("failed to create Planelet client: %w", err) } manifest, err := client.FetchManifest() @@ -172,7 +172,7 @@ func (r *RunAction) Setup(ctx core.SetupContext) error { } if !found { - return fmt.Errorf("action %q not found in plugin manifest", config.ActionName) + return fmt.Errorf("action %q not found in Planelet manifest", config.ActionName) } return nil @@ -194,7 +194,7 @@ func (r *RunAction) Execute(ctx core.ExecutionContext) error { client, err := NewClientWithHTTP(ctx.Integration, ctx.HTTP) if err != nil { - return r.emitFailure(ctx, fmt.Sprintf("failed to create plugin client: %v", err)) + return r.emitFailure(ctx, fmt.Sprintf("failed to create Planelet client: %v", err)) } params := extractActionParams(ctx.Configuration, config.ActionName) diff --git a/pkg/registryimports/registryimports.go b/pkg/registryimports/registryimports.go index 4290be6cf0..e0752ca533 100644 --- a/pkg/registryimports/registryimports.go +++ b/pkg/registryimports/registryimports.go @@ -55,7 +55,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/openai" _ "github.com/superplanehq/superplane/pkg/integrations/pagerduty" _ "github.com/superplanehq/superplane/pkg/integrations/perplexity" - _ "github.com/superplanehq/superplane/pkg/integrations/plugin" + _ "github.com/superplanehq/superplane/pkg/integrations/planelet" _ "github.com/superplanehq/superplane/pkg/integrations/prometheus" _ "github.com/superplanehq/superplane/pkg/integrations/render" _ "github.com/superplanehq/superplane/pkg/integrations/rootly" From ded3031d10b9cd2048e89e065f2a261aaca9c643 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 11:40:48 -0700 Subject: [PATCH 17/23] :truck: refactor(sdk): rename plugin SDK to planelet Match the "Planelets" product branding used in the canvas UI and the renamed backend integration. Renames the npm package (@superplane/plugin-sdk -> @superplane/planelet-sdk), the public API (createPlugin -> createPlanelet, PluginServer -> PlaneletServer, PluginOptions -> PlaneletOptions), and the example that consumes it. --- sdk/example/index.ts | 10 +++++----- sdk/example/package.json | 4 ++-- sdk/package.json | 4 ++-- sdk/src/index.ts | 18 +++++++++--------- sdk/src/server.ts | 10 +++++----- sdk/src/types.ts | 2 +- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/sdk/example/index.ts b/sdk/example/index.ts index 8c054a2fb3..785f8cb330 100644 --- a/sdk/example/index.ts +++ b/sdk/example/index.ts @@ -1,6 +1,6 @@ -import { createPlugin } from "@superplane/plugin-sdk"; +import { createPlanelet } from "@superplane/planelet-sdk"; -const plugin = createPlugin({ +const planelet = createPlanelet({ name: "quotes", label: "Random Quotes", icon: "quote", @@ -25,7 +25,7 @@ const quotes = [ { text: "Talk is cheap. Show me the code.", author: "Linus Torvalds" }, ]; -plugin.action("get-quote", { +planelet.action("get-quote", { label: "Get Random Quote", description: "Returns a random inspirational quote", fields: { @@ -52,7 +52,7 @@ plugin.action("get-quote", { }, }); -plugin.action("greet", { +planelet.action("greet", { label: "Generate Greeting", description: "Generate a personalized greeting message", fields: { @@ -92,4 +92,4 @@ plugin.action("greet", { }, }); -plugin.listen(3001); +planelet.listen(3001); diff --git a/sdk/example/package.json b/sdk/example/package.json index 44f783cab4..7434970933 100644 --- a/sdk/example/package.json +++ b/sdk/example/package.json @@ -1,5 +1,5 @@ { - "name": "plugin-example", + "name": "planelet-example", "version": "0.1.0", "private": true, "type": "module", @@ -7,6 +7,6 @@ "start": "bun run index.ts" }, "dependencies": { - "@superplane/plugin-sdk": "file:../" + "@superplane/planelet-sdk": "file:../" } } diff --git a/sdk/package.json b/sdk/package.json index a13503b0ec..c26d68b21c 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,7 +1,7 @@ { - "name": "@superplane/plugin-sdk", + "name": "@superplane/planelet-sdk", "version": "0.1.0", - "description": "SDK for building SuperPlane plugin integrations", + "description": "SDK for building SuperPlane Planelet integrations", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/sdk/src/index.ts b/sdk/src/index.ts index e2f4b2b3ad..4fb1fe28d4 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -1,20 +1,20 @@ -import { PluginServer } from "./server.js"; +import { PlaneletServer } from "./server.js"; import type { ActionDefinition, - PluginOptions, + PlaneletOptions, FieldDefinition, FieldOption, } from "./types.js"; -export function createPlugin(options: PluginOptions): PluginBuilder { - return new PluginBuilder(options); +export function createPlanelet(options: PlaneletOptions): PlaneletBuilder { + return new PlaneletBuilder(options); } -class PluginBuilder { - private server: PluginServer; +class PlaneletBuilder { + private server: PlaneletServer; - constructor(options: PluginOptions) { - this.server = new PluginServer(options); + constructor(options: PlaneletOptions) { + this.server = new PlaneletServer(options); } action>( @@ -30,4 +30,4 @@ class PluginBuilder { } } -export type { ActionDefinition, PluginOptions, FieldDefinition, FieldOption }; +export type { ActionDefinition, PlaneletOptions, FieldDefinition, FieldOption }; diff --git a/sdk/src/server.ts b/sdk/src/server.ts index bbbd9c102a..6cf0918e59 100644 --- a/sdk/src/server.ts +++ b/sdk/src/server.ts @@ -5,15 +5,15 @@ import type { ExecuteRequest, ExecuteResponse, Manifest, - PluginOptions, + PlaneletOptions, } from "./types.js"; -export class PluginServer { +export class PlaneletServer { private app: Express; private actions: Map = new Map(); - private options: PluginOptions; + private options: PlaneletOptions; - constructor(options: PluginOptions) { + constructor(options: PlaneletOptions) { this.options = options; this.app = express(); this.app.use(express.json()); @@ -30,7 +30,7 @@ export class PluginServer { callback ?? (() => { console.log( - `Plugin server "${this.options.name}" listening on port ${port}`, + `Planelet server "${this.options.name}" listening on port ${port}`, ); }), ); diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 9246e4e032..4d7970e591 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -50,7 +50,7 @@ export interface Manifest { actions: ActionManifest[]; } -export interface PluginOptions { +export interface PlaneletOptions { name: string; label?: string; icon?: string; From 3e186824584c9a9ebff8cd63504b6148fc61f401 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 11:40:57 -0700 Subject: [PATCH 18/23] :truck: docs: rename plugin SDK docs to planelet Follow the package and integration rename so the docs reference the current @superplane/planelet-sdk API and "Planelets" canvas labels. --- PLUGIN-DOCS.md => PLANELET-DOCS.md | 74 +++++++++++++++--------------- 1 file changed, 37 insertions(+), 37 deletions(-) rename PLUGIN-DOCS.md => PLANELET-DOCS.md (70%) diff --git a/PLUGIN-DOCS.md b/PLANELET-DOCS.md similarity index 70% rename from PLUGIN-DOCS.md rename to PLANELET-DOCS.md index 865559e903..e233cf8aa1 100644 --- a/PLUGIN-DOCS.md +++ b/PLANELET-DOCS.md @@ -1,13 +1,13 @@ -# SuperPlane Plugin SDK +# SuperPlane Planelet SDK -Build custom SuperPlane integrations without modifying the SuperPlane codebase. Write a plugin server with the TypeScript SDK, point SuperPlane at it, and your actions appear natively in the canvas UI. +Build custom SuperPlane integrations without modifying the SuperPlane codebase. Write a Planelet server with the TypeScript SDK, point SuperPlane at it, and your actions appear natively in the canvas UI. ## Architecture ``` ┌──────────────┐ ┌──────────────────┐ ┌────────────────┐ -│ SuperPlane │ ──GET───▶ │ Plugin Server │ │ Your Code │ -│ (Plugin │ /manifest │ (SDK-powered) │◀────────│ (actions, │ +│ SuperPlane │ ──GET───▶ │ Planelet Server │ │ Your Code │ +│ (Planelets │ /manifest │ (SDK-powered) │◀────────│ (actions, │ │ Integration)│ │ │ │ logic) │ │ │ ──POST──▶ │ /actions/:name/ │ │ │ │ │ │ execute │ │ │ @@ -16,8 +16,8 @@ Build custom SuperPlane integrations without modifying the SuperPlane codebase. └──────────────┘ └──────────────────┘ └────────────────┘ ``` -1. You build a plugin server using the SDK -2. SuperPlane's Plugin integration connects to your server +1. You build a Planelet server using the SDK +2. SuperPlane's Planelets integration connects to your server 3. On setup, it fetches your manifest to discover available actions 4. When a canvas node runs your action, SuperPlane proxies the execution to your server 5. Your server can push events back to SuperPlane to trigger workflows @@ -27,24 +27,24 @@ Build custom SuperPlane integrations without modifying the SuperPlane codebase. ### 1. Create a new project ```bash -mkdir my-plugin && cd my-plugin +mkdir my-planelet && cd my-planelet bun init -y -bun add @superplane/plugin-sdk +bun add @superplane/planelet-sdk ``` -### 2. Write your plugin +### 2. Write your Planelet ```typescript // index.ts -import { createPlugin } from "@superplane/plugin-sdk"; +import { createPlanelet } from "@superplane/planelet-sdk"; -const plugin = createPlugin({ - name: "my-plugin", - label: "My Plugin", +const planelet = createPlanelet({ + name: "my-planelet", + label: "My Planelet", description: "Does useful things", }); -plugin.action("hello", { +planelet.action("hello", { label: "Say Hello", description: "Generates a greeting", fields: { @@ -60,41 +60,41 @@ plugin.action("hello", { }, }); -plugin.listen(3001); +planelet.listen(3001); ``` ### 3. Run it ```bash bun run index.ts -# Plugin server "my-plugin" listening on port 3001 +# Planelet server "my-planelet" listening on port 3001 ``` ### 4. Connect to SuperPlane -1. In SuperPlane, add a new **Plugin** integration -2. Set **Server URL** to your plugin server's address (e.g. `https://my-plugin.example.com`) +1. In SuperPlane, add a new **Planelets** integration +2. Set **Server URL** to your Planelet server's address (e.g. `https://my-planelet.example.com`) 3. Optionally set an **Auth Token** 4. Save — SuperPlane fetches your manifest and the integration goes ready ### 5. Use in a canvas -Add a **Run Plugin Action** node to your canvas. Select your action from the dropdown. Fill in the fields. Done. +Add a **Run Planelet Action** node to your canvas. Select your action from the dropdown. Fill in the fields. Done. ## Protocol Reference -The SDK handles all of this for you, but if you want to build a plugin server in another language, here's the protocol. +The SDK handles all of this for you, but if you want to build a Planelet server in another language, here's the protocol. ### `GET /manifest` -Returns the plugin's metadata and available actions. +Returns the Planelet's metadata and available actions. **Response:** ```json { - "name": "my-plugin", - "label": "My Plugin", + "name": "my-planelet", + "label": "My Planelet", "icon": "puzzle", "description": "Does useful things", "actions": [ @@ -156,7 +156,7 @@ Executes an action. ### Events (Triggers) -To push events from your plugin server into SuperPlane, POST to: +To push events from your Planelet server into SuperPlane, POST to: ``` POST /api/v1/integrations/{integration_id}/events @@ -168,18 +168,18 @@ Content-Type: application/json } ``` -Use the **On Plugin Event** trigger in your canvas to listen for these events. You can filter by `eventType`. +Use the **On Planelet Event** trigger in your canvas to listen for these events. You can filter by `eventType`. ## SDK API Reference -### `createPlugin(options)` +### `createPlanelet(options)` -Creates a new plugin builder. +Creates a new Planelet builder. ```typescript -const plugin = createPlugin({ - name: "my-plugin", // required, unique identifier - label: "My Plugin", // optional, display name (defaults to name) +const planelet = createPlanelet({ + name: "my-planelet", // required, unique identifier + label: "My Planelet", // optional, display name (defaults to name) icon: "puzzle", // optional, icon identifier description: "...", // optional }); @@ -190,7 +190,7 @@ const plugin = createPlugin({ Registers an action. ```typescript -plugin.action("do-thing", { +planelet.action("do-thing", { label: "Do Thing", // required, display name description: "Does a thing", // optional fields: { // required, config fields @@ -216,8 +216,8 @@ Returns `this` for chaining. Starts the HTTP server. ```typescript -plugin.listen(3001); -plugin.listen(3001, () => console.log("Ready!")); +planelet.listen(3001); +planelet.listen(3001, () => console.log("Ready!")); ``` ## Field Types @@ -249,18 +249,18 @@ plugin.listen(3001, () => console.log("Ready!")); ## Authentication -If your plugin server requires authentication, set the **Auth Token** in the SuperPlane Plugin integration config. The token is sent as a `Bearer` token in the `Authorization` header on every request from SuperPlane to your server. +If your Planelet server requires authentication, set the **Auth Token** in the SuperPlane Planelets integration config. The token is sent as a `Bearer` token in the `Authorization` header on every request from SuperPlane to your server. To validate it server-side, add middleware to your Express app (the SDK doesn't enforce auth by default): ```typescript -// Example: add auth middleware before creating the plugin +// Example: add auth middleware before creating the Planelet // This is outside the SDK — use standard Express patterns ``` ## Deploying -Your plugin server is a standard HTTP server. Deploy it anywhere SuperPlane can reach: +Your Planelet server is a standard HTTP server. Deploy it anywhere SuperPlane can reach: - **Local development**: `localhost` or tunnel (ngrok, cloudflared) - **Cloud**: any container platform (Railway, Fly.io, Cloud Run, ECS) @@ -270,7 +270,7 @@ The server must be reachable from SuperPlane at the configured URL. HTTPS is rec ## Example -See [`sdk/example/`](sdk/example/) for a complete example plugin with two actions (random quotes and greetings). Run it: +See [`sdk/example/`](sdk/example/) for a complete example Planelet with two actions (random quotes and greetings). Run it: ```bash cd sdk/example From 58718ddd69fb0c3fbba815c59106bf99ff215a0f Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 11:41:05 -0700 Subject: [PATCH 19/23] :see_no_evil: chore(sdk): ignore node_modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example installs the SDK via `file:../`, which recreates the parent (including example/node_modules) inside the install tree — an infinite nesting that must never be committed. --- sdk/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 sdk/.gitignore diff --git a/sdk/.gitignore b/sdk/.gitignore new file mode 100644 index 0000000000..1eae0cf670 --- /dev/null +++ b/sdk/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ From 3e126f1216b6ef40cd43e94d866930f993dba01a Mon Sep 17 00:00:00 2001 From: Evan Zhou Date: Sat, 30 May 2026 12:34:24 -0700 Subject: [PATCH 20/23] =?UTF-8?q?=E2=9C=A8=20New=20Planelet=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/core/trigger.go | 3 + pkg/integrations/planelet/client.go | 179 +++++++++- pkg/integrations/planelet/manifest.go | 37 ++- pkg/integrations/planelet/planelet.go | 54 +++- pkg/integrations/planelet/planelet_test.go | 305 ++++++++++++++++++ pkg/integrations/planelet/run_action.go | 91 +++--- pkg/integrations/planelet/webhook_trigger.go | 301 +++++++++++++++++ pkg/public/server.go | 31 +- sdk/example/index.ts | 54 +++- sdk/src/index.ts | 61 +++- sdk/src/server.ts | 204 ++++++++++-- sdk/src/types.ts | 175 ++++++++-- .../ui/BuildingBlocksSidebar/index.spec.tsx | 8 +- .../src/ui/BuildingBlocksSidebar/index.tsx | 1 + 14 files changed, 1343 insertions(+), 161 deletions(-) create mode 100644 pkg/integrations/planelet/planelet_test.go create mode 100644 pkg/integrations/planelet/webhook_trigger.go diff --git a/pkg/core/trigger.go b/pkg/core/trigger.go index 9ae6818814..07f724ba42 100644 --- a/pkg/core/trigger.go +++ b/pkg/core/trigger.go @@ -97,7 +97,9 @@ type EventContext interface { type WebhookRequestContext struct { Body []byte + Method string Headers http.Header + Query map[string][]string WorkflowID string NodeID string Configuration any @@ -124,6 +126,7 @@ type WebhookRequestContext struct { type WebhookResponseBody struct { Body []byte ContentType string + Headers map[string]string } type NodeWebhookContext interface { diff --git a/pkg/integrations/planelet/client.go b/pkg/integrations/planelet/client.go index ed17c28a08..a5398a2add 100644 --- a/pkg/integrations/planelet/client.go +++ b/pkg/integrations/planelet/client.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "strings" "github.com/superplanehq/superplane/pkg/core" ) @@ -29,7 +31,7 @@ func NewClient(integration core.IntegrationContext) (*Client, error) { } return &Client{ - serverURL: string(serverURL), + serverURL: strings.TrimRight(string(serverURL), "/"), authToken: authToken, httpDo: http.DefaultClient.Do, }, nil @@ -55,6 +57,62 @@ type ExecuteResponse struct { Error string `json:"error,omitempty"` } +type SetupTriggerRequest struct { + Parameters map[string]any `json:"parameters"` + Webhook TriggerWebhookConfig `json:"webhook"` +} + +type TriggerWebhookConfig struct { + URL string `json:"url"` + Secret string `json:"secret,omitempty"` +} + +type SetupTriggerResponse struct { + Success bool `json:"success"` + Metadata map[string]any `json:"metadata,omitempty"` + Error string `json:"error,omitempty"` +} + +type CleanupTriggerRequest struct { + Parameters map[string]any `json:"parameters"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type CleanupTriggerResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +type HandleTriggerWebhookRequest struct { + Parameters map[string]any `json:"parameters"` + Metadata map[string]any `json:"metadata,omitempty"` + Request ForwardedWebhookRequest `json:"request"` +} + +type ForwardedWebhookRequest struct { + Method string `json:"method"` + Headers map[string][]string `json:"headers"` + Query map[string][]string `json:"query,omitempty"` + RawBodyBase64 string `json:"rawBodyBase64"` +} + +type HandleTriggerWebhookResponse struct { + Success bool `json:"success"` + Emit bool `json:"emit"` + EventType string `json:"eventType,omitempty"` + Payload any `json:"payload,omitempty"` + Reason string `json:"reason,omitempty"` + Response *WebhookHTTPResponse `json:"response,omitempty"` + Error string `json:"error,omitempty"` + Status int `json:"status,omitempty"` +} + +type WebhookHTTPResponse struct { + Status int `json:"status,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` +} + func (c *Client) FetchManifest() (*Manifest, error) { req, err := http.NewRequest("GET", c.serverURL+"/manifest", nil) if err != nil { @@ -85,48 +143,139 @@ func (c *Client) FetchManifest() (*Manifest, error) { return &manifest, nil } -func (c *Client) ExecuteAction(actionName string, params map[string]any, input any) (*ExecuteResponse, error) { +func (c *Client) ExecuteAction(actionID string, params map[string]any, input any) (*ExecuteResponse, error) { reqBody := ExecuteRequest{ Parameters: params, Input: input, } + var result ExecuteResponse + status, body, err := c.postJSON("/actions/"+url.PathEscape(actionID)+"/execute", reqBody, &result) + if err != nil { + return nil, err + } + + if status >= 400 { + return &ExecuteResponse{ + Success: false, + Error: failureMessage(status, body, result.Error), + }, nil + } + + return &result, nil +} + +func (c *Client) SetupTrigger(triggerID string, params map[string]any, webhookURL string, secret string) (*SetupTriggerResponse, error) { + reqBody := SetupTriggerRequest{ + Parameters: params, + Webhook: TriggerWebhookConfig{ + URL: webhookURL, + Secret: secret, + }, + } + + var result SetupTriggerResponse + status, body, err := c.postJSON("/triggers/"+url.PathEscape(triggerID)+"/setup", reqBody, &result) + if err != nil { + return nil, err + } + + if status >= 400 { + return &SetupTriggerResponse{ + Success: false, + Error: failureMessage(status, body, result.Error), + }, nil + } + + return &result, nil +} + +func (c *Client) CleanupTrigger(triggerID string, params map[string]any, metadata map[string]any) (*CleanupTriggerResponse, error) { + reqBody := CleanupTriggerRequest{ + Parameters: params, + Metadata: metadata, + } + + var result CleanupTriggerResponse + status, body, err := c.postJSON("/triggers/"+url.PathEscape(triggerID)+"/cleanup", reqBody, &result) + if err != nil { + return nil, err + } + + if status >= 400 { + return &CleanupTriggerResponse{ + Success: false, + Error: failureMessage(status, body, result.Error), + }, nil + } + + return &result, nil +} + +func (c *Client) HandleTriggerWebhook(triggerID string, reqBody HandleTriggerWebhookRequest) (*HandleTriggerWebhookResponse, error) { + var result HandleTriggerWebhookResponse + status, body, err := c.postJSON("/triggers/"+url.PathEscape(triggerID)+"/webhook", reqBody, &result) + if err != nil { + return nil, err + } + + if status >= 400 { + return &HandleTriggerWebhookResponse{ + Success: false, + Error: failureMessage(status, body, result.Error), + Status: status, + }, nil + } + + return &result, nil +} + +func (c *Client) postJSON(path string, reqBody any, result any) (int, []byte, error) { bodyBytes, err := json.Marshal(reqBody) if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) + return 0, nil, fmt.Errorf("failed to marshal request: %w", err) } - req, err := http.NewRequest("POST", c.serverURL+"/actions/"+actionName+"/execute", bytes.NewReader(bodyBytes)) + req, err := http.NewRequest("POST", c.serverURL+path, bytes.NewReader(bodyBytes)) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return 0, nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") c.setAuth(req) resp, err := c.httpDo(req) if err != nil { - return nil, fmt.Errorf("failed to execute action: %w", err) + return 0, nil, fmt.Errorf("failed to call Planelet server: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) + return resp.StatusCode, nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode >= 400 { - return &ExecuteResponse{ - Success: false, - Error: fmt.Sprintf("Planelet server returned status %d: %s", resp.StatusCode, string(respBody)), - }, nil + _ = json.Unmarshal(respBody, result) + return resp.StatusCode, respBody, nil } - var result ExecuteResponse - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + if len(respBody) == 0 { + return resp.StatusCode, respBody, nil } - return &result, nil + if err := json.Unmarshal(respBody, result); err != nil { + return resp.StatusCode, respBody, fmt.Errorf("failed to parse response: %w", err) + } + + return resp.StatusCode, respBody, nil +} + +func failureMessage(status int, body []byte, parsedError string) string { + if parsedError != "" { + return parsedError + } + + return fmt.Sprintf("Planelet server returned status %d: %s", status, string(body)) } func (c *Client) setAuth(req *http.Request) { diff --git a/pkg/integrations/planelet/manifest.go b/pkg/integrations/planelet/manifest.go index 1576c18566..af3c258f77 100644 --- a/pkg/integrations/planelet/manifest.go +++ b/pkg/integrations/planelet/manifest.go @@ -5,25 +5,38 @@ import ( ) type Manifest struct { - Name string `json:"name"` - Label string `json:"label"` - Icon string `json:"icon"` - Description string `json:"description"` - Actions []ActionManifest `json:"actions"` + ID string `json:"id"` + Label string `json:"label"` + Icon string `json:"icon,omitempty"` + IconURL string `json:"iconUrl,omitempty"` + Description string `json:"description,omitempty"` + Actions []ActionManifest `json:"actions"` + Triggers []TriggerManifest `json:"triggers"` } type ActionManifest struct { - Name string `json:"name"` - Label string `json:"label"` - Description string `json:"description"` - Fields []FieldManifest `json:"fields"` + ID string `json:"id"` + Label string `json:"label"` + Icon string `json:"icon,omitempty"` + IconURL string `json:"iconUrl,omitempty"` + Description string `json:"description,omitempty"` + Parameters []ParameterManifest `json:"parameters"` +} + +type TriggerManifest struct { + ID string `json:"id"` + Label string `json:"label"` + Icon string `json:"icon,omitempty"` + IconURL string `json:"iconUrl,omitempty"` + Description string `json:"description,omitempty"` + Parameters []ParameterManifest `json:"parameters"` } -type FieldManifest struct { - Name string `json:"name"` +type ParameterManifest struct { + ID string `json:"id"` Label string `json:"label"` Type string `json:"type"` - Description string `json:"description"` + Description string `json:"description,omitempty"` Required bool `json:"required"` Default any `json:"default,omitempty"` Options []OptionManifest `json:"options,omitempty"` diff --git a/pkg/integrations/planelet/planelet.go b/pkg/integrations/planelet/planelet.go index 21cb766771..a3105be14f 100644 --- a/pkg/integrations/planelet/planelet.go +++ b/pkg/integrations/planelet/planelet.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/mitchellh/mapstructure" "github.com/superplanehq/superplane/pkg/configuration" @@ -24,8 +25,10 @@ type PlaneletConfiguration struct { } type PlaneletMetadata struct { - ManifestName string `json:"manifestName" mapstructure:"manifestName"` - ManifestLabel string `json:"manifestLabel" mapstructure:"manifestLabel"` + ManifestID string `json:"manifestId" mapstructure:"manifestId"` + ManifestLabel string `json:"manifestLabel" mapstructure:"manifestLabel"` + ManifestIcon string `json:"manifestIcon,omitempty" mapstructure:"manifestIcon"` + ManifestIconURL string `json:"manifestIconUrl,omitempty" mapstructure:"manifestIconUrl"` } func (p *Planelet) Name() string { @@ -52,7 +55,7 @@ func (p *Planelet) Instructions() string { 3. Enter the server URL below 4. Optionally add an auth token if your server requires authentication -The Planelet server exposes a manifest at ` + "`GET /manifest`" + ` describing available actions and their configuration fields.` +The Planelet server exposes a manifest at ` + "`GET /manifest`" + ` describing available actions, triggers, and their configuration parameters.` } func (p *Planelet) Configuration() []configuration.Field { @@ -84,6 +87,7 @@ func (p *Planelet) Actions() []core.Action { func (p *Planelet) Triggers() []core.Trigger { return []core.Trigger{ &OnEvent{}, + &WebhookTrigger{}, } } @@ -98,7 +102,7 @@ func (p *Planelet) Sync(ctx core.SyncContext) error { } client := &Client{ - serverURL: config.ServerURL, + serverURL: strings.TrimRight(config.ServerURL, "/"), authToken: config.AuthToken, httpDo: ctx.HTTP.Do, } @@ -108,15 +112,17 @@ func (p *Planelet) Sync(ctx core.SyncContext) error { return fmt.Errorf("failed to connect to Planelet server: %w", err) } - if manifest.Name == "" { - return fmt.Errorf("Planelet manifest is missing 'name' field") + if manifest.ID == "" { + return fmt.Errorf("Planelet manifest is missing 'id' field") } setCachedManifest(manifest) ctx.Integration.SetMetadata(PlaneletMetadata{ - ManifestName: manifest.Name, - ManifestLabel: manifest.Label, + ManifestID: manifest.ID, + ManifestLabel: manifest.Label, + ManifestIcon: manifest.Icon, + ManifestIconURL: manifest.IconURL, }) ctx.Integration.Ready() @@ -177,7 +183,7 @@ func (p *Planelet) HandleRequest(ctx core.HTTPRequestContext) { } func (p *Planelet) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { - if resourceType != "action" { + if resourceType != "action" && resourceType != "trigger" { return []core.IntegrationResource{}, nil } @@ -193,16 +199,30 @@ func (p *Planelet) ListResources(resourceType string, ctx core.ListResourcesCont setCachedManifest(manifest) - resources := make([]core.IntegrationResource, 0, len(manifest.Actions)) - for _, action := range manifest.Actions { - resources = append(resources, core.IntegrationResource{ - Type: "action", - ID: action.Name, - Name: action.Label, - }) + switch resourceType { + case "action": + resources := make([]core.IntegrationResource, 0, len(manifest.Actions)) + for _, action := range manifest.Actions { + resources = append(resources, core.IntegrationResource{ + Type: "action", + ID: action.ID, + Name: action.Label, + }) + } + return resources, nil + case "trigger": + resources := make([]core.IntegrationResource, 0, len(manifest.Triggers)) + for _, trigger := range manifest.Triggers { + resources = append(resources, core.IntegrationResource{ + Type: "trigger", + ID: trigger.ID, + Name: trigger.Label, + }) + } + return resources, nil } - return resources, nil + return []core.IntegrationResource{}, nil } func (p *Planelet) Hooks() []core.Hook { diff --git a/pkg/integrations/planelet/planelet_test.go b/pkg/integrations/planelet/planelet_test.go new file mode 100644 index 0000000000..33b54e8f4d --- /dev/null +++ b/pkg/integrations/planelet/planelet_test.go @@ -0,0 +1,305 @@ +package planelet + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__Client__ExecuteActionUsesV2Endpoint(t *testing.T) { + integration := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "serverUrl": "https://planelet.example/", + "authToken": "test-token", + }, + } + + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + jsonResponse(http.StatusOK, `{"success":true,"data":{"ok":true}}`), + }, + } + + client, err := NewClientWithHTTP(integration, httpCtx) + require.NoError(t, err) + + result, err := client.ExecuteAction("folder/create-page", map[string]any{"title": "Hello"}, map[string]any{"source": "test"}) + require.NoError(t, err) + require.True(t, result.Success) + assert.Equal(t, true, result.Data["ok"]) + + require.Len(t, httpCtx.Requests, 1) + req := httpCtx.Requests[0] + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, "https://planelet.example/actions/folder%2Fcreate-page/execute", req.URL.String()) + assert.Equal(t, "Bearer test-token", req.Header.Get("Authorization")) + + var body ExecuteRequest + require.NoError(t, decodeRequestBody(req, &body)) + assert.Equal(t, "Hello", body.Parameters["title"]) + assert.Equal(t, "test", body.Input.(map[string]any)["source"]) +} + +func Test__Planelet__ListResourcesReturnsActionsAndTriggers(t *testing.T) { + integration := &contexts.IntegrationContext{ + Configuration: map[string]any{"serverUrl": "https://planelet.example"}, + } + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + jsonResponse(http.StatusOK, planeletManifestJSON()), + jsonResponse(http.StatusOK, planeletManifestJSON()), + }, + } + + resources, err := (&Planelet{}).ListResources("action", core.ListResourcesContext{ + Integration: integration, + HTTP: httpCtx, + }) + require.NoError(t, err) + require.Len(t, resources, 1) + assert.Equal(t, core.IntegrationResource{Type: "action", ID: "create-page", Name: "Create Page"}, resources[0]) + + resources, err = (&Planelet{}).ListResources("trigger", core.ListResourcesContext{ + Integration: integration, + HTTP: httpCtx, + }) + require.NoError(t, err) + require.Len(t, resources, 1) + assert.Equal(t, core.IntegrationResource{Type: "trigger", ID: "database-created", Name: "Database Created"}, resources[0]) +} + +func Test__PlaneletComponents__UseManifestParameters(t *testing.T) { + setCachedManifest(&Manifest{ + Actions: []ActionManifest{ + { + ID: "create-page", + Label: "Create Page", + Parameters: []ParameterManifest{ + {ID: "databaseId", Label: "Database ID", Type: "string", Required: true}, + }, + }, + }, + Triggers: []TriggerManifest{ + { + ID: "database-created", + Label: "Database Created", + Parameters: []ParameterManifest{ + {ID: "workspaceId", Label: "Workspace ID", Type: "string", Required: true}, + }, + }, + }, + }) + t.Cleanup(func() { setCachedManifest(nil) }) + + actionFields := (&RunAction{}).Configuration() + require.Len(t, actionFields, 2) + assert.Equal(t, "actionId", actionFields[0].Name) + assert.Equal(t, "param_create-page_databaseId", actionFields[1].Name) + assert.Equal(t, "actionId", actionFields[1].VisibilityConditions[0].Field) + assert.Equal(t, []string{"create-page"}, actionFields[1].VisibilityConditions[0].Values) + + triggerFields := (&WebhookTrigger{}).Configuration() + require.Len(t, triggerFields, 2) + assert.Equal(t, "triggerId", triggerFields[0].Name) + assert.Equal(t, "param_database-created_workspaceId", triggerFields[1].Name) + assert.Equal(t, "triggerId", triggerFields[1].VisibilityConditions[0].Field) + assert.Equal(t, []string{"database-created"}, triggerFields[1].VisibilityConditions[0].Values) +} + +func Test__WebhookTrigger__SetupCallsPlaneletServer(t *testing.T) { + integration := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "serverUrl": "https://planelet.example", + "authToken": "test-token", + }, + } + + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + jsonResponse(http.StatusOK, planeletManifestJSON()), + jsonResponse(http.StatusOK, `{"success":true,"metadata":{"providerWebhookId":"wh_123"}}`), + }, + } + + metadataCtx := &contexts.MetadataContext{} + err := (&WebhookTrigger{}).Setup(core.TriggerContext{ + Configuration: map[string]any{ + "triggerId": "database-created", + "param_database-created_workspaceId": "ws_123", + "param_some-other-trigger_ignoredParameter": "ignored", + }, + HTTP: httpCtx, + Integration: integration, + Metadata: metadataCtx, + Webhook: &contexts.NodeWebhookContext{Secret: "superplane-secret"}, + }) + require.NoError(t, err) + + require.Len(t, httpCtx.Requests, 2) + setupReq := httpCtx.Requests[1] + assert.Equal(t, http.MethodPost, setupReq.Method) + assert.Equal(t, "https://planelet.example/triggers/database-created/setup", setupReq.URL.String()) + assert.Equal(t, "Bearer test-token", setupReq.Header.Get("Authorization")) + + var body SetupTriggerRequest + require.NoError(t, decodeRequestBody(setupReq, &body)) + assert.Equal(t, "ws_123", body.Parameters["workspaceId"]) + assert.NotEmpty(t, body.Webhook.URL) + assert.Equal(t, "superplane-secret", body.Webhook.Secret) + + metadata, ok := metadataCtx.Metadata.(WebhookTriggerMetadata) + require.True(t, ok) + assert.Equal(t, "database-created", metadata.TriggerID) + assert.Equal(t, "wh_123", metadata.PlaneletMetadata["providerWebhookId"]) + assert.Equal(t, "ws_123", metadata.Parameters["workspaceId"]) +} + +func Test__WebhookTrigger__HandleWebhookNormalizesAndEmitsEvent(t *testing.T) { + integration := &contexts.IntegrationContext{ + Configuration: map[string]any{"serverUrl": "https://planelet.example"}, + } + + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + jsonResponse(http.StatusOK, `{ + "success": true, + "emit": true, + "eventType": "notion.database.created", + "payload": {"database":{"id":"db_123"}}, + "response": { + "status": 202, + "headers": {"Content-Type":"text/plain","X-Planelet":"ok"}, + "body": "accepted" + } + }`), + }, + } + + events := &contexts.EventContext{} + status, response, err := (&WebhookTrigger{}).HandleWebhook(core.WebhookRequestContext{ + Body: []byte(`{"database":{"id":"db_123"}}`), + Method: http.MethodPost, + Headers: http.Header{"X-Notion-Signature": []string{"sig"}}, + Query: map[string][]string{"challenge": {"abc"}}, + Configuration: map[string]any{ + "triggerId": "database-created", + "param_database-created_workspaceId": "ws_123", + }, + Metadata: &contexts.MetadataContext{ + Metadata: WebhookTriggerMetadata{ + TriggerID: "database-created", + PlaneletMetadata: map[string]any{"providerWebhookId": "wh_123"}, + }, + }, + HTTP: httpCtx, + Integration: integration, + Events: events, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusAccepted, status) + require.NotNil(t, response) + assert.Equal(t, "accepted", string(response.Body)) + assert.Equal(t, "text/plain", response.ContentType) + assert.Equal(t, "ok", response.Headers["X-Planelet"]) + + require.Len(t, events.Payloads, 1) + assert.Equal(t, "notion.database.created", events.Payloads[0].Type) + assert.Equal(t, "db_123", events.Payloads[0].Data.(map[string]any)["database"].(map[string]any)["id"]) + + require.Len(t, httpCtx.Requests, 1) + var forwarded HandleTriggerWebhookRequest + require.NoError(t, decodeRequestBody(httpCtx.Requests[0], &forwarded)) + assert.Equal(t, "ws_123", forwarded.Parameters["workspaceId"]) + assert.Equal(t, "wh_123", forwarded.Metadata["providerWebhookId"]) + assert.Equal(t, http.MethodPost, forwarded.Request.Method) + assert.Equal(t, []string{"sig"}, forwarded.Request.Headers["X-Notion-Signature"]) + assert.Equal(t, []string{"abc"}, forwarded.Request.Query["challenge"]) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(`{"database":{"id":"db_123"}}`)), forwarded.Request.RawBodyBase64) +} + +func Test__WebhookTrigger__CleanupCallsPlaneletServer(t *testing.T) { + integration := &contexts.IntegrationContext{ + Configuration: map[string]any{"serverUrl": "https://planelet.example"}, + } + + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + jsonResponse(http.StatusOK, `{"success":true}`), + }, + } + + err := (&WebhookTrigger{}).Cleanup(core.TriggerContext{ + Configuration: map[string]any{"triggerId": "database-created"}, + Metadata: &contexts.MetadataContext{ + Metadata: WebhookTriggerMetadata{ + TriggerID: "database-created", + Parameters: map[string]any{"workspaceId": "ws_123"}, + PlaneletMetadata: map[string]any{"providerWebhookId": "wh_123"}, + }, + }, + HTTP: httpCtx, + Integration: integration, + }) + require.NoError(t, err) + + require.Len(t, httpCtx.Requests, 1) + req := httpCtx.Requests[0] + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, "https://planelet.example/triggers/database-created/cleanup", req.URL.String()) + + var body CleanupTriggerRequest + require.NoError(t, decodeRequestBody(req, &body)) + assert.Equal(t, "ws_123", body.Parameters["workspaceId"]) + assert.Equal(t, "wh_123", body.Metadata["providerWebhookId"]) +} + +func planeletManifestJSON() string { + return `{ + "id": "notion", + "label": "Notion", + "iconUrl": "https://example.com/notion.svg", + "actions": [ + { + "id": "create-page", + "label": "Create Page", + "parameters": [ + {"id": "databaseId", "label": "Database ID", "type": "string", "required": true} + ] + } + ], + "triggers": [ + { + "id": "database-created", + "label": "Database Created", + "parameters": [ + {"id": "workspaceId", "label": "Workspace ID", "type": "string", "required": true} + ] + } + ] + }` +} + +func jsonResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func decodeRequestBody(request *http.Request, v any) error { + body, err := io.ReadAll(request.Body) + if err != nil { + return err + } + + return json.Unmarshal(body, v) +} diff --git a/pkg/integrations/planelet/run_action.go b/pkg/integrations/planelet/run_action.go index d688269337..01092922df 100644 --- a/pkg/integrations/planelet/run_action.go +++ b/pkg/integrations/planelet/run_action.go @@ -2,6 +2,7 @@ package planelet import ( "fmt" + "strings" "github.com/google/uuid" "github.com/mitchellh/mapstructure" @@ -20,7 +21,7 @@ const ( type RunAction struct{} type RunActionConfiguration struct { - ActionName string `json:"actionName" mapstructure:"actionName"` + ActionID string `json:"actionId" mapstructure:"actionId"` } func (r *RunAction) Name() string { @@ -51,7 +52,7 @@ func (r *RunAction) Documentation() string { ## Configuration - **Action**: Select which action to run from the Planelet server's manifest -- Additional fields appear dynamically based on the selected action +- Additional parameters appear dynamically based on the selected action ## Output Channels @@ -77,7 +78,7 @@ func (r *RunAction) OutputChannels(cfg any) []core.OutputChannel { func (r *RunAction) Configuration() []configuration.Field { fields := []configuration.Field{ { - Name: "actionName", + Name: "actionId", Label: "Action", Type: configuration.FieldTypeIntegrationResource, Required: true, @@ -93,28 +94,28 @@ func (r *RunAction) Configuration() []configuration.Field { manifest := getCachedManifest() if manifest != nil { for _, action := range manifest.Actions { - fields = append(fields, manifestFieldsToConfig(action.Fields, action.Name)...) + fields = append(fields, manifestParametersToConfig(action.Parameters, "actionId", action.ID)...) } } return fields } -func manifestFieldsToConfig(mFields []FieldManifest, actionName string) []configuration.Field { - fields := make([]configuration.Field, 0, len(mFields)) - for _, f := range mFields { +func manifestParametersToConfig(parameters []ParameterManifest, ownerField string, ownerID string) []configuration.Field { + fields := make([]configuration.Field, 0, len(parameters)) + for _, p := range parameters { field := configuration.Field{ - Name: fmt.Sprintf("param_%s_%s", actionName, f.Name), - Label: f.Label, - Description: f.Description, - Required: f.Required, - Default: f.Default, + Name: parameterFieldName(ownerID, p.ID), + Label: p.Label, + Description: p.Description, + Required: p.Required, + Default: p.Default, VisibilityConditions: []configuration.VisibilityCondition{ - {Field: "actionName", Values: []string{actionName}}, + {Field: ownerField, Values: []string{ownerID}}, }, } - switch f.Type { + switch p.Type { case "string": field.Type = configuration.FieldTypeString case "text": @@ -125,8 +126,8 @@ func manifestFieldsToConfig(mFields []FieldManifest, actionName string) []config field.Type = configuration.FieldTypeBool case "select": field.Type = configuration.FieldTypeSelect - opts := make([]configuration.FieldOption, 0, len(f.Options)) - for _, o := range f.Options { + opts := make([]configuration.FieldOption, 0, len(p.Options)) + for _, o := range p.Options { opts = append(opts, configuration.FieldOption{Label: o.Label, Value: o.Value}) } field.TypeOptions = &configuration.TypeOptions{ @@ -143,14 +144,36 @@ func manifestFieldsToConfig(mFields []FieldManifest, actionName string) []config return fields } +func parameterFieldName(ownerID string, parameterID string) string { + return fmt.Sprintf("param_%s_%s", ownerID, parameterID) +} + +func extractPlaneletParams(rawConfig any, ownerID string) map[string]any { + configMap, ok := rawConfig.(map[string]any) + if !ok { + return map[string]any{} + } + + params := map[string]any{} + prefix := fmt.Sprintf("param_%s_", ownerID) + for key, value := range configMap { + if strings.HasPrefix(key, prefix) && len(key) > len(prefix) { + paramID := strings.TrimPrefix(key, prefix) + params[paramID] = value + } + } + + return params +} + func (r *RunAction) Setup(ctx core.SetupContext) error { var config RunActionConfiguration if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { return fmt.Errorf("failed to decode configuration: %w", err) } - if config.ActionName == "" { - return fmt.Errorf("actionName is required") + if config.ActionID == "" { + return fmt.Errorf("actionId is required") } client, err := NewClientWithHTTP(ctx.Integration, ctx.HTTP) @@ -165,14 +188,14 @@ func (r *RunAction) Setup(ctx core.SetupContext) error { found := false for _, action := range manifest.Actions { - if action.Name == config.ActionName { + if action.ID == config.ActionID { found = true break } } if !found { - return fmt.Errorf("action %q not found in Planelet manifest", config.ActionName) + return fmt.Errorf("action %q not found in Planelet manifest", config.ActionID) } return nil @@ -188,8 +211,8 @@ func (r *RunAction) Execute(ctx core.ExecutionContext) error { return r.emitFailure(ctx, fmt.Sprintf("failed to decode configuration: %v", err)) } - if config.ActionName == "" { - return r.emitFailure(ctx, "actionName is required") + if config.ActionID == "" { + return r.emitFailure(ctx, "actionId is required") } client, err := NewClientWithHTTP(ctx.Integration, ctx.HTTP) @@ -197,9 +220,9 @@ func (r *RunAction) Execute(ctx core.ExecutionContext) error { return r.emitFailure(ctx, fmt.Sprintf("failed to create Planelet client: %v", err)) } - params := extractActionParams(ctx.Configuration, config.ActionName) + params := extractPlaneletParams(ctx.Configuration, config.ActionID) - result, err := client.ExecuteAction(config.ActionName, params, ctx.Data) + result, err := client.ExecuteAction(config.ActionID, params, ctx.Data) if err != nil { return r.emitFailure(ctx, fmt.Sprintf("failed to execute action: %v", err)) } @@ -209,31 +232,13 @@ func (r *RunAction) Execute(ctx core.ExecutionContext) error { } payload := map[string]any{ - "action": config.ActionName, + "action": config.ActionID, "result": result.Data, } return ctx.ExecutionState.Emit(SuccessChannel, SuccessPayloadType, []any{payload}) } -func extractActionParams(rawConfig any, actionName string) map[string]any { - configMap, ok := rawConfig.(map[string]any) - if !ok { - return map[string]any{} - } - - params := map[string]any{} - prefix := fmt.Sprintf("param_%s_", actionName) - for key, value := range configMap { - if len(key) > len(prefix) && key[:len(prefix)] == prefix { - paramName := key[len(prefix):] - params[paramName] = value - } - } - - return params -} - func (r *RunAction) emitFailure(ctx core.ExecutionContext, errMsg string) error { payload := map[string]any{"error": errMsg} if err := ctx.ExecutionState.Emit(FailureChannel, FailurePayloadType, []any{payload}); err != nil { diff --git a/pkg/integrations/planelet/webhook_trigger.go b/pkg/integrations/planelet/webhook_trigger.go new file mode 100644 index 0000000000..bac4b38b9f --- /dev/null +++ b/pkg/integrations/planelet/webhook_trigger.go @@ -0,0 +1,301 @@ +package planelet + +import ( + "encoding/base64" + "fmt" + "net/http" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type WebhookTrigger struct{} + +type WebhookTriggerConfiguration struct { + TriggerID string `json:"triggerId" mapstructure:"triggerId"` +} + +type WebhookTriggerMetadata struct { + TriggerID string `json:"triggerId" mapstructure:"triggerId"` + WebhookURL string `json:"webhookUrl" mapstructure:"webhookUrl"` + Parameters map[string]any `json:"parameters,omitempty" mapstructure:"parameters"` + PlaneletMetadata map[string]any `json:"planeletMetadata,omitempty" mapstructure:"planeletMetadata"` +} + +func (t *WebhookTrigger) Name() string { + return "planelet.webhookTrigger" +} + +func (t *WebhookTrigger) Label() string { + return "On Planelet Webhook" +} + +func (t *WebhookTrigger) Description() string { + return "Start a workflow from a webhook trigger exposed by the connected Planelet server" +} + +func (t *WebhookTrigger) Documentation() string { + return `Triggers a workflow from a third-party webhook managed by the connected Planelet server. + +## How It Works + +1. Select a trigger from the Planelet manifest. +2. Configure the trigger's parameters. +3. When the workflow is published, SuperPlane generates a webhook URL and asks the Planelet server to register it with the third-party provider. +4. Incoming third-party webhook requests are forwarded to the Planelet server so it can verify, filter, and normalize the event before SuperPlane emits it into the workflow.` +} + +func (t *WebhookTrigger) Icon() string { + return "webhook" +} + +func (t *WebhookTrigger) Color() string { + return "gray" +} + +func (t *WebhookTrigger) ExampleData() map[string]any { + return map[string]any{ + "type": "planelet.webhook", + "data": map[string]any{ + "eventType": "example.created", + "payload": map[string]any{ + "id": "example_123", + }, + }, + } +} + +func (t *WebhookTrigger) Configuration() []configuration.Field { + fields := []configuration.Field{ + { + Name: "triggerId", + Label: "Trigger", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "trigger", + }, + }, + Description: "The Planelet webhook trigger to configure", + }, + } + + manifest := getCachedManifest() + if manifest != nil { + for _, trigger := range manifest.Triggers { + fields = append(fields, manifestParametersToConfig(trigger.Parameters, "triggerId", trigger.ID)...) + } + } + + return fields +} + +func (t *WebhookTrigger) Setup(ctx core.TriggerContext) error { + var config WebhookTriggerConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.TriggerID == "" { + return fmt.Errorf("triggerId is required") + } + + client, err := NewClientWithHTTP(ctx.Integration, ctx.HTTP) + if err != nil { + return fmt.Errorf("failed to create Planelet client: %w", err) + } + + manifest, err := client.FetchManifest() + if err != nil { + return fmt.Errorf("failed to fetch manifest: %w", err) + } + + if !manifestHasTrigger(manifest, config.TriggerID) { + return fmt.Errorf("trigger %q not found in Planelet manifest", config.TriggerID) + } + + webhookURL, err := ctx.Webhook.Setup() + if err != nil { + return fmt.Errorf("failed to setup webhook: %w", err) + } + + secret, err := ctx.Webhook.GetSecret() + if err != nil { + return fmt.Errorf("failed to read webhook secret: %w", err) + } + + params := extractPlaneletParams(ctx.Configuration, config.TriggerID) + result, err := client.SetupTrigger(config.TriggerID, params, webhookURL, string(secret)) + if err != nil { + return fmt.Errorf("failed to setup Planelet trigger: %w", err) + } + + if !result.Success { + return fmt.Errorf("Planelet trigger setup failed: %s", result.Error) + } + + return ctx.Metadata.Set(WebhookTriggerMetadata{ + TriggerID: config.TriggerID, + WebhookURL: webhookURL, + Parameters: params, + PlaneletMetadata: result.Metadata, + }) +} + +func (t *WebhookTrigger) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + var config WebhookTriggerConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.TriggerID == "" { + return http.StatusInternalServerError, nil, fmt.Errorf("triggerId is required") + } + + var metadata WebhookTriggerMetadata + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("failed to decode metadata: %w", err) + } + + client, err := NewClientWithHTTP(ctx.Integration, ctx.HTTP) + if err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("failed to create Planelet client: %w", err) + } + + method := ctx.Method + if method == "" { + method = http.MethodPost + } + + result, err := client.HandleTriggerWebhook(config.TriggerID, HandleTriggerWebhookRequest{ + Parameters: extractPlaneletParams(ctx.Configuration, config.TriggerID), + Metadata: metadata.PlaneletMetadata, + Request: ForwardedWebhookRequest{ + Method: method, + Headers: copyHeaderValues(ctx.Headers), + Query: ctx.Query, + RawBodyBase64: base64.StdEncoding.EncodeToString(ctx.Body), + }, + }) + if err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("failed to handle Planelet webhook: %w", err) + } + + if !result.Success { + status := result.Status + if status == 0 { + status = http.StatusInternalServerError + } + + return status, nil, fmt.Errorf("Planelet webhook failed: %s", result.Error) + } + + status, response := webhookHTTPResponse(result.Response) + if !result.Emit { + return status, response, nil + } + + eventType := result.EventType + if eventType == "" { + eventType = config.TriggerID + } + + if err := ctx.Events.Emit(eventType, result.Payload); err != nil { + return http.StatusInternalServerError, response, fmt.Errorf("failed to emit Planelet webhook event: %w", err) + } + + return status, response, nil +} + +func (t *WebhookTrigger) Hooks() []core.Hook { + return []core.Hook{} +} + +func (t *WebhookTrigger) HandleHook(ctx core.TriggerHookContext) (map[string]any, error) { + return nil, nil +} + +func (t *WebhookTrigger) Cleanup(ctx core.TriggerContext) error { + var config WebhookTriggerConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + var metadata WebhookTriggerMetadata + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return fmt.Errorf("failed to decode metadata: %w", err) + } + + triggerID := metadata.TriggerID + if triggerID == "" { + triggerID = config.TriggerID + } + + if triggerID == "" { + return nil + } + + params := metadata.Parameters + if params == nil { + params = extractPlaneletParams(ctx.Configuration, triggerID) + } + + client, err := NewClientWithHTTP(ctx.Integration, ctx.HTTP) + if err != nil { + return fmt.Errorf("failed to create Planelet client: %w", err) + } + + result, err := client.CleanupTrigger(triggerID, params, metadata.PlaneletMetadata) + if err != nil { + return fmt.Errorf("failed to cleanup Planelet trigger: %w", err) + } + + if !result.Success { + return fmt.Errorf("Planelet trigger cleanup failed: %s", result.Error) + } + + return nil +} + +func manifestHasTrigger(manifest *Manifest, triggerID string) bool { + for _, trigger := range manifest.Triggers { + if trigger.ID == triggerID { + return true + } + } + + return false +} + +func copyHeaderValues(headers http.Header) map[string][]string { + values := make(map[string][]string, len(headers)) + for name, headerValues := range headers { + values[name] = append([]string{}, headerValues...) + } + + return values +} + +func webhookHTTPResponse(response *WebhookHTTPResponse) (int, *core.WebhookResponseBody) { + if response == nil { + return http.StatusOK, nil + } + + status := response.Status + if status == 0 { + status = http.StatusOK + } + + body := &core.WebhookResponseBody{ + Body: []byte(response.Body), + Headers: response.Headers, + } + + if response.Headers != nil { + body.ContentType = response.Headers["Content-Type"] + } + + return status, body +} diff --git a/pkg/public/server.go b/pkg/public/server.go index ee8f06a8f7..61f4b48dd9 100644 --- a/pkg/public/server.go +++ b/pkg/public/server.go @@ -1146,16 +1146,20 @@ func (s *Server) HandleWebhook(w http.ResponseWriter, r *http.Request) { } var firstResponse *core.WebhookResponseBody + firstResponseStatus := http.StatusOK for _, node := range nodes { - code, response, err := s.executeWebhookNode(r.Context(), body, r.Header, node, onNewEvents) + code, response, err := s.executeWebhookNode(r.Context(), body, r.Method, map[string][]string(r.URL.Query()), r.Header, node, onNewEvents) if err != nil { http.Error(w, fmt.Sprintf("error handling webhook: %v", err), code) return } - if firstResponse == nil && response != nil && len(response.Body) > 0 { + if firstResponse == nil && response != nil { firstResponse = response + if code > 0 { + firstResponseStatus = code + } } } @@ -1164,25 +1168,30 @@ func (s *Server) HandleWebhook(w http.ResponseWriter, r *http.Request) { } if firstResponse != nil { + for name, value := range firstResponse.Headers { + w.Header().Set(name, value) + } if firstResponse.ContentType != "" { w.Header().Set("Content-Type", firstResponse.ContentType) } - w.WriteHeader(http.StatusOK) - w.Write(firstResponse.Body) + w.WriteHeader(firstResponseStatus) + if len(firstResponse.Body) > 0 { + w.Write(firstResponse.Body) + } } else { w.WriteHeader(http.StatusOK) } } -func (s *Server) executeWebhookNode(ctx context.Context, body []byte, headers http.Header, node models.CanvasNode, onNewEvents func([]models.CanvasEvent)) (int, *core.WebhookResponseBody, error) { +func (s *Server) executeWebhookNode(ctx context.Context, body []byte, method string, query map[string][]string, headers http.Header, node models.CanvasNode, onNewEvents func([]models.CanvasEvent)) (int, *core.WebhookResponseBody, error) { if node.Type == models.NodeTypeTrigger { - return s.executeTriggerNode(ctx, body, headers, node, onNewEvents) + return s.executeTriggerNode(ctx, body, method, query, headers, node, onNewEvents) } - return s.executeActionNode(ctx, body, headers, node, onNewEvents) + return s.executeActionNode(ctx, body, method, query, headers, node, onNewEvents) } -func (s *Server) executeTriggerNode(ctx context.Context, body []byte, headers http.Header, node models.CanvasNode, onNewEvents func([]models.CanvasEvent)) (int, *core.WebhookResponseBody, error) { +func (s *Server) executeTriggerNode(ctx context.Context, body []byte, method string, query map[string][]string, headers http.Header, node models.CanvasNode, onNewEvents func([]models.CanvasEvent)) (int, *core.WebhookResponseBody, error) { ref := node.Ref.Data() trigger, err := s.registry.GetTrigger(ref.Trigger.Name) if err != nil { @@ -1204,7 +1213,9 @@ func (s *Server) executeTriggerNode(ctx context.Context, body []byte, headers ht return trigger.HandleWebhook(core.WebhookRequestContext{ Body: body, + Method: method, Headers: headers, + Query: query, WorkflowID: node.WorkflowID.String(), NodeID: node.NodeID, Configuration: node.Configuration.Data(), @@ -1217,7 +1228,7 @@ func (s *Server) executeTriggerNode(ctx context.Context, body []byte, headers ht }) } -func (s *Server) executeActionNode(ctx context.Context, body []byte, headers http.Header, node models.CanvasNode, onNewEvents func([]models.CanvasEvent)) (int, *core.WebhookResponseBody, error) { +func (s *Server) executeActionNode(ctx context.Context, body []byte, method string, query map[string][]string, headers http.Header, node models.CanvasNode, onNewEvents func([]models.CanvasEvent)) (int, *core.WebhookResponseBody, error) { ref := node.Ref.Data() action, err := s.registry.GetAction(ref.Component.Name) if err != nil { @@ -1239,7 +1250,9 @@ func (s *Server) executeActionNode(ctx context.Context, body []byte, headers htt return action.HandleWebhook(core.WebhookRequestContext{ Body: body, + Method: method, Headers: headers, + Query: query, WorkflowID: node.WorkflowID.String(), NodeID: node.NodeID, Configuration: node.Configuration.Data(), diff --git a/sdk/example/index.ts b/sdk/example/index.ts index 785f8cb330..6bce43a4fa 100644 --- a/sdk/example/index.ts +++ b/sdk/example/index.ts @@ -1,9 +1,10 @@ import { createPlanelet } from "@superplane/planelet-sdk"; const planelet = createPlanelet({ - name: "quotes", + id: "quotes", label: "Random Quotes", icon: "quote", + iconUrl: "https://example.com/quote.svg", description: "Get random quotes and generate greetings", }); @@ -28,7 +29,7 @@ const quotes = [ planelet.action("get-quote", { label: "Get Random Quote", description: "Returns a random inspirational quote", - fields: { + parameters: { category: { label: "Category", type: "select", @@ -55,7 +56,7 @@ planelet.action("get-quote", { planelet.action("greet", { label: "Generate Greeting", description: "Generate a personalized greeting message", - fields: { + parameters: { name: { label: "Name", type: "string", @@ -74,9 +75,9 @@ planelet.action("greet", { ], }, }, - execute: async (params) => { - const name = params.name as string; - const style = params.style as string; + execute: async ({ parameters }) => { + const name = parameters.name as string; + const style = parameters.style as string; const greetings: Record = { formal: `Good day, ${name}. It is a pleasure to make your acquaintance.`, @@ -92,4 +93,45 @@ planelet.action("greet", { }, }); +planelet.trigger("quote-created", { + label: "Quote Created", + description: "Demo webhook trigger that normalizes incoming quote events", + parameters: { + workspaceId: { + label: "Workspace ID", + type: "string", + required: true, + }, + }, + setup: async ({ parameters, webhook }) => { + return { + providerWebhookId: "demo-webhook", + workspaceId: parameters.workspaceId, + webhookUrl: webhook.url, + }; + }, + cleanup: async ({ metadata }) => { + console.log("Cleaning up webhook", metadata?.providerWebhookId); + }, + handleWebhook: async ({ request, metadata }) => { + const rawBody = Buffer.from(request.rawBodyBase64, "base64").toString( + "utf8", + ); + const body = rawBody ? JSON.parse(rawBody) : {}; + + return { + eventType: "quote.created", + payload: { + providerWebhookId: metadata?.providerWebhookId, + quote: body.quote, + receivedMethod: request.method, + }, + response: { + status: 200, + body: "ok", + }, + }; + }, +}); + planelet.listen(3001); diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 4fb1fe28d4..741aad2a2f 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -1,9 +1,28 @@ import { PlaneletServer } from "./server.js"; import type { ActionDefinition, + ActionExecutionContext, + ActionManifest, + CleanupTriggerRequest, + CleanupTriggerResponse, + ExecuteRequest, + ExecuteResponse, + ForwardedWebhookRequest, + HandleTriggerWebhookRequest, + HandleTriggerWebhookResponse, + Manifest, + ParameterDefinition, + ParameterManifest, + ParameterOption, PlaneletOptions, - FieldDefinition, - FieldOption, + TriggerDefinition, + TriggerCleanupContext, + TriggerManifest, + TriggerSetupContext, + TriggerWebhookConfig, + TriggerWebhookContext, + TriggerWebhookResult, + WebhookHttpResponse, } from "./types.js"; export function createPlanelet(options: PlaneletOptions): PlaneletBuilder { @@ -18,10 +37,18 @@ class PlaneletBuilder { } action>( - name: string, + id: string, definition: ActionDefinition, ): this { - this.server.addAction(name, definition as ActionDefinition); + this.server.addAction(id, definition as ActionDefinition); + return this; + } + + trigger< + TParams = Record, + TMetadata = Record, + >(id: string, definition: TriggerDefinition): this { + this.server.addTrigger(id, definition as TriggerDefinition); return this; } @@ -30,4 +57,28 @@ class PlaneletBuilder { } } -export type { ActionDefinition, PlaneletOptions, FieldDefinition, FieldOption }; +export type { + ActionDefinition, + ActionExecutionContext, + ActionManifest, + CleanupTriggerRequest, + CleanupTriggerResponse, + ExecuteRequest, + ExecuteResponse, + ForwardedWebhookRequest, + HandleTriggerWebhookRequest, + HandleTriggerWebhookResponse, + Manifest, + ParameterDefinition, + ParameterManifest, + ParameterOption, + PlaneletOptions, + TriggerDefinition, + TriggerCleanupContext, + TriggerManifest, + TriggerSetupContext, + TriggerWebhookConfig, + TriggerWebhookContext, + TriggerWebhookResult, + WebhookHttpResponse, +}; diff --git a/sdk/src/server.ts b/sdk/src/server.ts index 6cf0918e59..2aeedd60b4 100644 --- a/sdk/src/server.ts +++ b/sdk/src/server.ts @@ -2,15 +2,26 @@ import express, { type Express, type Request, type Response } from "express"; import type { ActionDefinition, ActionManifest, + CleanupTriggerRequest, + CleanupTriggerResponse, ExecuteRequest, ExecuteResponse, + HandleTriggerWebhookRequest, + HandleTriggerWebhookResponse, Manifest, + ParameterDefinition, + ParameterManifest, PlaneletOptions, + SetupTriggerRequest, + SetupTriggerResponse, + TriggerDefinition, + TriggerManifest, } from "./types.js"; export class PlaneletServer { private app: Express; private actions: Map = new Map(); + private triggers: Map = new Map(); private options: PlaneletOptions; constructor(options: PlaneletOptions) { @@ -20,8 +31,12 @@ export class PlaneletServer { this.setupRoutes(); } - addAction(name: string, definition: ActionDefinition): void { - this.actions.set(name, definition); + addAction(id: string, definition: ActionDefinition): void { + this.actions.set(id, definition); + } + + addTrigger(id: string, definition: TriggerDefinition): void { + this.triggers.set(id, definition); } listen(port: number, callback?: () => void): void { @@ -30,7 +45,7 @@ export class PlaneletServer { callback ?? (() => { console.log( - `Planelet server "${this.options.name}" listening on port ${port}`, + `Planelet server "${this.options.id}" listening on port ${port}`, ); }), ); @@ -38,32 +53,38 @@ export class PlaneletServer { getManifest(): Manifest { const actions: ActionManifest[] = []; + const triggers: TriggerManifest[] = []; - for (const [name, def] of this.actions) { - const fields = Object.entries(def.fields).map(([fieldName, field]) => ({ - name: fieldName, - label: field.label, - type: field.type, - description: field.description ?? "", - required: field.required ?? false, - default: field.default, - options: field.options, - })); - + for (const [id, def] of this.actions) { actions.push({ - name, + id, label: def.label, - description: def.description ?? "", - fields, + icon: def.icon, + iconUrl: def.iconUrl, + description: def.description, + parameters: serializeParameters(def.parameters), + }); + } + + for (const [id, def] of this.triggers) { + triggers.push({ + id, + label: def.label, + icon: def.icon, + iconUrl: def.iconUrl, + description: def.description, + parameters: serializeParameters(def.parameters), }); } return { - name: this.options.name, - label: this.options.label ?? this.options.name, - icon: this.options.icon ?? "puzzle", - description: this.options.description ?? "", + id: this.options.id, + label: this.options.label ?? this.options.id, + icon: this.options.icon, + iconUrl: this.options.iconUrl, + description: this.options.description, actions, + triggers, }; } @@ -73,15 +94,15 @@ export class PlaneletServer { }); this.app.post( - "/actions/:name/execute", + "/actions/:id/execute", async (req: Request, res: Response) => { - const name = req.params.name as string; - const action = this.actions.get(name); + const id = req.params.id as string; + const action = this.actions.get(id); if (!action) { const response: ExecuteResponse = { success: false, - error: `Action "${name}" not found`, + error: `Action "${id}" not found`, }; res.status(404).json(response); return; @@ -90,7 +111,8 @@ export class PlaneletServer { const body = req.body as ExecuteRequest; try { - const result = await action.execute(body.parameters ?? {}, { + const result = await action.execute({ + parameters: body.parameters ?? {}, input: body.input, }); @@ -108,5 +130,135 @@ export class PlaneletServer { } }, ); + + this.app.post( + "/triggers/:id/setup", + async (req: Request, res: Response) => { + const id = req.params.id as string; + const trigger = this.triggers.get(id); + + if (!trigger) { + const response: SetupTriggerResponse = { + success: false, + error: `Trigger "${id}" not found`, + }; + res.status(404).json(response); + return; + } + + const body = req.body as SetupTriggerRequest; + + try { + const metadata = await trigger.setup({ + parameters: body.parameters ?? {}, + webhook: body.webhook, + }); + + const response: SetupTriggerResponse = { + success: true, + metadata: metadata as Record | undefined, + }; + res.json(response); + } catch (err) { + const response: SetupTriggerResponse = { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + res.status(500).json(response); + } + }, + ); + + this.app.post( + "/triggers/:id/cleanup", + async (req: Request, res: Response) => { + const id = req.params.id as string; + const trigger = this.triggers.get(id); + + if (!trigger) { + const response: CleanupTriggerResponse = { + success: false, + error: `Trigger "${id}" not found`, + }; + res.status(404).json(response); + return; + } + + const body = req.body as CleanupTriggerRequest; + + try { + await trigger.cleanup?.({ + parameters: body.parameters ?? {}, + metadata: body.metadata, + }); + + const response: CleanupTriggerResponse = { success: true }; + res.json(response); + } catch (err) { + const response: CleanupTriggerResponse = { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + res.status(500).json(response); + } + }, + ); + + this.app.post( + "/triggers/:id/webhook", + async (req: Request, res: Response) => { + const id = req.params.id as string; + const trigger = this.triggers.get(id); + + if (!trigger) { + const response: HandleTriggerWebhookResponse = { + success: false, + error: `Trigger "${id}" not found`, + }; + res.status(404).json(response); + return; + } + + const body = req.body as HandleTriggerWebhookRequest; + + try { + const result = await trigger.handleWebhook({ + parameters: body.parameters ?? {}, + metadata: body.metadata, + request: body.request, + }); + + const response: HandleTriggerWebhookResponse = { + success: true, + emit: result.emit ?? true, + eventType: "eventType" in result ? result.eventType : undefined, + payload: "payload" in result ? result.payload : undefined, + reason: "reason" in result ? result.reason : undefined, + response: result.response, + }; + res.json(response); + } catch (err) { + const response: HandleTriggerWebhookResponse = { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + res.status(500).json(response); + } + }, + ); } } + +function serializeParameters( + parameters: Record = {}, +): ParameterManifest[] { + return Object.entries(parameters).map(([id, parameter]) => ({ + id, + label: parameter.label, + type: parameter.type, + description: parameter.description, + required: parameter.required ?? false, + default: parameter.default, + options: parameter.options, + })); +} diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 4d7970e591..a539aff3f4 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -1,64 +1,149 @@ -export interface FieldOption { +export interface ParameterOption { label: string; value: string; } -export interface FieldDefinition { - name: string; +export interface ParameterDefinition { label: string; type: "string" | "text" | "number" | "bool" | "select" | "object"; description?: string; required?: boolean; default?: unknown; - options?: FieldOption[]; + options?: ParameterOption[]; +} + +export interface ActionExecutionContext> { + parameters: TParams; + input?: unknown; } export interface ActionDefinition> { label: string; description?: string; - fields: Record>; + icon?: string; + iconUrl?: string; + parameters?: Record; execute: ( - params: TParams, - ctx: ExecutionContext, - ) => Promise>; + ctx: ActionExecutionContext, + ) => Promise> | Record; } -export interface ExecutionContext { - input: unknown; +export interface TriggerWebhookConfig { + url: string; + secret?: string; +} + +export interface TriggerSetupContext> { + parameters: TParams; + webhook: TriggerWebhookConfig; +} + +export interface TriggerCleanupContext< + TParams = Record, + TMetadata = Record, +> { + parameters: TParams; + metadata?: TMetadata; +} + +export interface ForwardedWebhookRequest { + method: string; + headers: Record; + query?: Record; + rawBodyBase64: string; +} + +export interface TriggerWebhookContext< + TParams = Record, + TMetadata = Record, +> { + parameters: TParams; + metadata?: TMetadata; + request: ForwardedWebhookRequest; +} + +export interface WebhookHttpResponse { + status?: number; + headers?: Record; + body?: string; +} + +export type TriggerWebhookResult = + | { + emit?: true; + eventType?: string; + payload: unknown; + response?: WebhookHttpResponse; + } + | { + emit: false; + reason?: string; + response?: WebhookHttpResponse; + }; + +export interface TriggerDefinition< + TParams = Record, + TMetadata = Record, +> { + label: string; + description?: string; + icon?: string; + iconUrl?: string; + parameters?: Record; + setup: ( + ctx: TriggerSetupContext, + ) => Promise | TMetadata | void; + cleanup?: ( + ctx: TriggerCleanupContext, + ) => Promise | void; + handleWebhook: ( + ctx: TriggerWebhookContext, + ) => Promise | TriggerWebhookResult; +} + +export interface ParameterManifest extends ParameterDefinition { + id: string; + required: boolean; } export interface ActionManifest { - name: string; + id: string; label: string; - description: string; - fields: Array<{ - name: string; - label: string; - type: string; - description: string; - required: boolean; - default?: unknown; - options?: FieldOption[]; - }>; + icon?: string; + iconUrl?: string; + description?: string; + parameters: ParameterManifest[]; +} + +export interface TriggerManifest { + id: string; + label: string; + icon?: string; + iconUrl?: string; + description?: string; + parameters: ParameterManifest[]; } export interface Manifest { - name: string; + id: string; label: string; - icon: string; - description: string; + icon?: string; + iconUrl?: string; + description?: string; actions: ActionManifest[]; + triggers: TriggerManifest[]; } export interface PlaneletOptions { - name: string; + id: string; label?: string; icon?: string; + iconUrl?: string; description?: string; } export interface ExecuteRequest { - parameters: Record; + parameters?: Record; input?: unknown; } @@ -67,3 +152,41 @@ export interface ExecuteResponse { data?: Record; error?: string; } + +export interface SetupTriggerRequest { + parameters?: Record; + webhook: TriggerWebhookConfig; +} + +export interface SetupTriggerResponse { + success: boolean; + metadata?: Record; + error?: string; +} + +export interface CleanupTriggerRequest { + parameters?: Record; + metadata?: Record; +} + +export interface CleanupTriggerResponse { + success: boolean; + error?: string; +} + +export interface HandleTriggerWebhookRequest { + parameters?: Record; + metadata?: Record; + request: ForwardedWebhookRequest; +} + +export interface HandleTriggerWebhookResponse { + success: boolean; + emit?: boolean; + eventType?: string; + payload?: unknown; + reason?: string; + response?: WebhookHttpResponse; + error?: string; + status?: number; +} diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.spec.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.spec.tsx index 6d23c85f04..072104bf76 100644 --- a/web_src/src/ui/BuildingBlocksSidebar/index.spec.tsx +++ b/web_src/src/ui/BuildingBlocksSidebar/index.spec.tsx @@ -99,10 +99,14 @@ describe("BuildingBlocksSidebar", () => { expect(onEnterSubmit).not.toHaveBeenCalled(); }); - it("renders Debugging directly after Core", () => { + it("renders meta categories before alphabetical integrations", () => { const blocks: BuildingBlockCategory[] = [ { name: "Memory", blocks: [{ name: "addmemory", label: "Add Memory", type: "component" }] }, { name: "Debugging", blocks: [{ name: "display", label: "Display", type: "component" }] }, + { + name: "Planelets", + blocks: [{ name: "planelet.runAction", label: "Run Planelet Action", type: "component" }], + }, { name: "AWS", blocks: [{ name: "lambda", label: "Lambda", type: "component", integrationName: "aws" }] }, { name: "Core", blocks: [{ name: "filter", label: "Filter", type: "component" }] }, ]; @@ -113,7 +117,7 @@ describe("BuildingBlocksSidebar", () => { (element) => element.textContent ?? "", ); - expect(categoryNames).toEqual(["Core", "Debugging", "Memory", "AWS"]); + expect(categoryNames).toEqual(["Core", "Debugging", "Memory", "Planelets", "AWS"]); }); it("is a no-op when the sidebar is disabled", () => { diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx index de018827d2..075f7ec9ff 100644 --- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx +++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx @@ -161,6 +161,7 @@ function OpenBuildingBlocksSidebar({ Core: 0, Debugging: 1, Memory: 2, + Planelets: 3, }; return [...blocks].sort((a, b) => { From 07dc4cbc8b0247eb21a894f1f81f4755d2b3abc9 Mon Sep 17 00:00:00 2001 From: Evan Zhou Date: Sat, 30 May 2026 12:42:46 -0700 Subject: [PATCH 21/23] chore: remove unused planelet sdk artifacts --- PLANELET-DOCS.md | 281 --------------------------------------- sdk/.gitignore | 2 - sdk/example/index.ts | 137 ------------------- sdk/example/package.json | 12 -- sdk/package.json | 20 --- sdk/src/index.ts | 84 ------------ sdk/src/server.ts | 264 ------------------------------------ sdk/src/types.ts | 192 -------------------------- sdk/tsconfig.json | 14 -- 9 files changed, 1006 deletions(-) delete mode 100644 PLANELET-DOCS.md delete mode 100644 sdk/.gitignore delete mode 100644 sdk/example/index.ts delete mode 100644 sdk/example/package.json delete mode 100644 sdk/package.json delete mode 100644 sdk/src/index.ts delete mode 100644 sdk/src/server.ts delete mode 100644 sdk/src/types.ts delete mode 100644 sdk/tsconfig.json diff --git a/PLANELET-DOCS.md b/PLANELET-DOCS.md deleted file mode 100644 index e233cf8aa1..0000000000 --- a/PLANELET-DOCS.md +++ /dev/null @@ -1,281 +0,0 @@ -# SuperPlane Planelet SDK - -Build custom SuperPlane integrations without modifying the SuperPlane codebase. Write a Planelet server with the TypeScript SDK, point SuperPlane at it, and your actions appear natively in the canvas UI. - -## Architecture - -``` -┌──────────────┐ ┌──────────────────┐ ┌────────────────┐ -│ SuperPlane │ ──GET───▶ │ Planelet Server │ │ Your Code │ -│ (Planelets │ /manifest │ (SDK-powered) │◀────────│ (actions, │ -│ Integration)│ │ │ │ logic) │ -│ │ ──POST──▶ │ /actions/:name/ │ │ │ -│ │ │ execute │ │ │ -│ │ ◀──POST── │ POST events to │ │ │ -│ │ │ SuperPlane │ │ │ -└──────────────┘ └──────────────────┘ └────────────────┘ -``` - -1. You build a Planelet server using the SDK -2. SuperPlane's Planelets integration connects to your server -3. On setup, it fetches your manifest to discover available actions -4. When a canvas node runs your action, SuperPlane proxies the execution to your server -5. Your server can push events back to SuperPlane to trigger workflows - -## Quick Start - -### 1. Create a new project - -```bash -mkdir my-planelet && cd my-planelet -bun init -y -bun add @superplane/planelet-sdk -``` - -### 2. Write your Planelet - -```typescript -// index.ts -import { createPlanelet } from "@superplane/planelet-sdk"; - -const planelet = createPlanelet({ - name: "my-planelet", - label: "My Planelet", - description: "Does useful things", -}); - -planelet.action("hello", { - label: "Say Hello", - description: "Generates a greeting", - fields: { - name: { - label: "Name", - type: "string", - required: true, - description: "Who to greet", - }, - }, - execute: async (params) => { - return { message: `Hello, ${params.name}!` }; - }, -}); - -planelet.listen(3001); -``` - -### 3. Run it - -```bash -bun run index.ts -# Planelet server "my-planelet" listening on port 3001 -``` - -### 4. Connect to SuperPlane - -1. In SuperPlane, add a new **Planelets** integration -2. Set **Server URL** to your Planelet server's address (e.g. `https://my-planelet.example.com`) -3. Optionally set an **Auth Token** -4. Save — SuperPlane fetches your manifest and the integration goes ready - -### 5. Use in a canvas - -Add a **Run Planelet Action** node to your canvas. Select your action from the dropdown. Fill in the fields. Done. - -## Protocol Reference - -The SDK handles all of this for you, but if you want to build a Planelet server in another language, here's the protocol. - -### `GET /manifest` - -Returns the Planelet's metadata and available actions. - -**Response:** - -```json -{ - "name": "my-planelet", - "label": "My Planelet", - "icon": "puzzle", - "description": "Does useful things", - "actions": [ - { - "name": "hello", - "label": "Say Hello", - "description": "Generates a greeting", - "fields": [ - { - "name": "name", - "label": "Name", - "type": "string", - "description": "Who to greet", - "required": true - } - ] - } - ] -} -``` - -### `POST /actions/{name}/execute` - -Executes an action. - -**Request:** - -```json -{ - "parameters": { - "name": "World" - }, - "input": { ... } -} -``` - -- `parameters`: the field values configured by the user -- `input`: data from the upstream node in the canvas (may be `null`) - -**Success response (200):** - -```json -{ - "success": true, - "data": { - "message": "Hello, World!" - } -} -``` - -**Error response (4xx/5xx):** - -```json -{ - "success": false, - "error": "Something went wrong" -} -``` - -### Events (Triggers) - -To push events from your Planelet server into SuperPlane, POST to: - -``` -POST /api/v1/integrations/{integration_id}/events -Content-Type: application/json - -{ - "eventType": "my.event.type", - "payload": { ... } -} -``` - -Use the **On Planelet Event** trigger in your canvas to listen for these events. You can filter by `eventType`. - -## SDK API Reference - -### `createPlanelet(options)` - -Creates a new Planelet builder. - -```typescript -const planelet = createPlanelet({ - name: "my-planelet", // required, unique identifier - label: "My Planelet", // optional, display name (defaults to name) - icon: "puzzle", // optional, icon identifier - description: "...", // optional -}); -``` - -### `.action(name, definition)` - -Registers an action. - -```typescript -planelet.action("do-thing", { - label: "Do Thing", // required, display name - description: "Does a thing", // optional - fields: { // required, config fields - myField: { - label: "My Field", - type: "string", - required: true, - description: "What it does", - }, - }, - execute: async (params, ctx) => { // required, execution handler - // params = { myField: "value" } - // ctx.input = data from upstream node - return { result: "ok" }; // must return an object - }, -}); -``` - -Returns `this` for chaining. - -### `.listen(port, callback?)` - -Starts the HTTP server. - -```typescript -planelet.listen(3001); -planelet.listen(3001, () => console.log("Ready!")); -``` - -## Field Types - -| Type | Description | Extra options | -|------|-------------|---------------| -| `string` | Single-line text input | — | -| `text` | Multi-line text input | — | -| `number` | Numeric input | — | -| `bool` | Boolean checkbox | — | -| `select` | Dropdown selection | `options: [{label, value}]` | -| `object` | JSON object editor | — | - -### Field definition - -```typescript -{ - label: "Display Name", // required - type: "string", // required - description: "Help text", // optional - required: true, // optional, default false - default: "value", // optional - options: [ // required for "select" type - { label: "Option A", value: "a" }, - { label: "Option B", value: "b" }, - ], -} -``` - -## Authentication - -If your Planelet server requires authentication, set the **Auth Token** in the SuperPlane Planelets integration config. The token is sent as a `Bearer` token in the `Authorization` header on every request from SuperPlane to your server. - -To validate it server-side, add middleware to your Express app (the SDK doesn't enforce auth by default): - -```typescript -// Example: add auth middleware before creating the Planelet -// This is outside the SDK — use standard Express patterns -``` - -## Deploying - -Your Planelet server is a standard HTTP server. Deploy it anywhere SuperPlane can reach: - -- **Local development**: `localhost` or tunnel (ngrok, cloudflared) -- **Cloud**: any container platform (Railway, Fly.io, Cloud Run, ECS) -- **Self-hosted**: any server with a public or VPN-accessible URL - -The server must be reachable from SuperPlane at the configured URL. HTTPS is recommended for production. - -## Example - -See [`sdk/example/`](sdk/example/) for a complete example Planelet with two actions (random quotes and greetings). Run it: - -```bash -cd sdk/example -bun install -bun run index.ts -``` - -Then connect SuperPlane to `http://localhost:3001`. diff --git a/sdk/.gitignore b/sdk/.gitignore deleted file mode 100644 index 1eae0cf670..0000000000 --- a/sdk/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist/ -node_modules/ diff --git a/sdk/example/index.ts b/sdk/example/index.ts deleted file mode 100644 index 6bce43a4fa..0000000000 --- a/sdk/example/index.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { createPlanelet } from "@superplane/planelet-sdk"; - -const planelet = createPlanelet({ - id: "quotes", - label: "Random Quotes", - icon: "quote", - iconUrl: "https://example.com/quote.svg", - description: "Get random quotes and generate greetings", -}); - -const quotes = [ - { - text: "The only way to do great work is to love what you do.", - author: "Steve Jobs", - }, - { - text: "Innovation distinguishes between a leader and a follower.", - author: "Steve Jobs", - }, - { text: "Stay hungry, stay foolish.", author: "Steve Jobs" }, - { text: "Move fast and break things.", author: "Mark Zuckerberg" }, - { - text: "The best way to predict the future is to invent it.", - author: "Alan Kay", - }, - { text: "Talk is cheap. Show me the code.", author: "Linus Torvalds" }, -]; - -planelet.action("get-quote", { - label: "Get Random Quote", - description: "Returns a random inspirational quote", - parameters: { - category: { - label: "Category", - type: "select", - description: "Filter quotes by category", - required: false, - options: [ - { label: "All", value: "all" }, - { label: "Innovation", value: "innovation" }, - { label: "Motivation", value: "motivation" }, - ], - }, - }, - execute: async () => { - const idx = Math.floor(Math.random() * quotes.length); - const quote = quotes[idx]; - return { - quote: quote.text, - author: quote.author, - index: idx, - }; - }, -}); - -planelet.action("greet", { - label: "Generate Greeting", - description: "Generate a personalized greeting message", - parameters: { - name: { - label: "Name", - type: "string", - description: "Name of the person to greet", - required: true, - }, - style: { - label: "Style", - type: "select", - description: "Greeting style", - required: true, - options: [ - { label: "Formal", value: "formal" }, - { label: "Casual", value: "casual" }, - { label: "Enthusiastic", value: "enthusiastic" }, - ], - }, - }, - execute: async ({ parameters }) => { - const name = parameters.name as string; - const style = parameters.style as string; - - const greetings: Record = { - formal: `Good day, ${name}. It is a pleasure to make your acquaintance.`, - casual: `Hey ${name}, what's up?`, - enthusiastic: `OMG ${name}!!! SO GREAT to see you!`, - }; - - return { - greeting: greetings[style] ?? greetings.casual, - style, - recipient: name, - }; - }, -}); - -planelet.trigger("quote-created", { - label: "Quote Created", - description: "Demo webhook trigger that normalizes incoming quote events", - parameters: { - workspaceId: { - label: "Workspace ID", - type: "string", - required: true, - }, - }, - setup: async ({ parameters, webhook }) => { - return { - providerWebhookId: "demo-webhook", - workspaceId: parameters.workspaceId, - webhookUrl: webhook.url, - }; - }, - cleanup: async ({ metadata }) => { - console.log("Cleaning up webhook", metadata?.providerWebhookId); - }, - handleWebhook: async ({ request, metadata }) => { - const rawBody = Buffer.from(request.rawBodyBase64, "base64").toString( - "utf8", - ); - const body = rawBody ? JSON.parse(rawBody) : {}; - - return { - eventType: "quote.created", - payload: { - providerWebhookId: metadata?.providerWebhookId, - quote: body.quote, - receivedMethod: request.method, - }, - response: { - status: 200, - body: "ok", - }, - }; - }, -}); - -planelet.listen(3001); diff --git a/sdk/example/package.json b/sdk/example/package.json deleted file mode 100644 index 7434970933..0000000000 --- a/sdk/example/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "planelet-example", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "start": "bun run index.ts" - }, - "dependencies": { - "@superplane/planelet-sdk": "file:../" - } -} diff --git a/sdk/package.json b/sdk/package.json deleted file mode 100644 index c26d68b21c..0000000000 --- a/sdk/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@superplane/planelet-sdk", - "version": "0.1.0", - "description": "SDK for building SuperPlane Planelet integrations", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "tsc", - "dev": "tsc --watch" - }, - "dependencies": { - "express": "^5.1.0" - }, - "devDependencies": { - "@types/express": "^5.0.0", - "@types/node": "^22.0.0", - "typescript": "^5.8.0" - } -} diff --git a/sdk/src/index.ts b/sdk/src/index.ts deleted file mode 100644 index 741aad2a2f..0000000000 --- a/sdk/src/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { PlaneletServer } from "./server.js"; -import type { - ActionDefinition, - ActionExecutionContext, - ActionManifest, - CleanupTriggerRequest, - CleanupTriggerResponse, - ExecuteRequest, - ExecuteResponse, - ForwardedWebhookRequest, - HandleTriggerWebhookRequest, - HandleTriggerWebhookResponse, - Manifest, - ParameterDefinition, - ParameterManifest, - ParameterOption, - PlaneletOptions, - TriggerDefinition, - TriggerCleanupContext, - TriggerManifest, - TriggerSetupContext, - TriggerWebhookConfig, - TriggerWebhookContext, - TriggerWebhookResult, - WebhookHttpResponse, -} from "./types.js"; - -export function createPlanelet(options: PlaneletOptions): PlaneletBuilder { - return new PlaneletBuilder(options); -} - -class PlaneletBuilder { - private server: PlaneletServer; - - constructor(options: PlaneletOptions) { - this.server = new PlaneletServer(options); - } - - action>( - id: string, - definition: ActionDefinition, - ): this { - this.server.addAction(id, definition as ActionDefinition); - return this; - } - - trigger< - TParams = Record, - TMetadata = Record, - >(id: string, definition: TriggerDefinition): this { - this.server.addTrigger(id, definition as TriggerDefinition); - return this; - } - - listen(port: number, callback?: () => void): void { - this.server.listen(port, callback); - } -} - -export type { - ActionDefinition, - ActionExecutionContext, - ActionManifest, - CleanupTriggerRequest, - CleanupTriggerResponse, - ExecuteRequest, - ExecuteResponse, - ForwardedWebhookRequest, - HandleTriggerWebhookRequest, - HandleTriggerWebhookResponse, - Manifest, - ParameterDefinition, - ParameterManifest, - ParameterOption, - PlaneletOptions, - TriggerDefinition, - TriggerCleanupContext, - TriggerManifest, - TriggerSetupContext, - TriggerWebhookConfig, - TriggerWebhookContext, - TriggerWebhookResult, - WebhookHttpResponse, -}; diff --git a/sdk/src/server.ts b/sdk/src/server.ts deleted file mode 100644 index 2aeedd60b4..0000000000 --- a/sdk/src/server.ts +++ /dev/null @@ -1,264 +0,0 @@ -import express, { type Express, type Request, type Response } from "express"; -import type { - ActionDefinition, - ActionManifest, - CleanupTriggerRequest, - CleanupTriggerResponse, - ExecuteRequest, - ExecuteResponse, - HandleTriggerWebhookRequest, - HandleTriggerWebhookResponse, - Manifest, - ParameterDefinition, - ParameterManifest, - PlaneletOptions, - SetupTriggerRequest, - SetupTriggerResponse, - TriggerDefinition, - TriggerManifest, -} from "./types.js"; - -export class PlaneletServer { - private app: Express; - private actions: Map = new Map(); - private triggers: Map = new Map(); - private options: PlaneletOptions; - - constructor(options: PlaneletOptions) { - this.options = options; - this.app = express(); - this.app.use(express.json()); - this.setupRoutes(); - } - - addAction(id: string, definition: ActionDefinition): void { - this.actions.set(id, definition); - } - - addTrigger(id: string, definition: TriggerDefinition): void { - this.triggers.set(id, definition); - } - - listen(port: number, callback?: () => void): void { - this.app.listen( - port, - callback ?? - (() => { - console.log( - `Planelet server "${this.options.id}" listening on port ${port}`, - ); - }), - ); - } - - getManifest(): Manifest { - const actions: ActionManifest[] = []; - const triggers: TriggerManifest[] = []; - - for (const [id, def] of this.actions) { - actions.push({ - id, - label: def.label, - icon: def.icon, - iconUrl: def.iconUrl, - description: def.description, - parameters: serializeParameters(def.parameters), - }); - } - - for (const [id, def] of this.triggers) { - triggers.push({ - id, - label: def.label, - icon: def.icon, - iconUrl: def.iconUrl, - description: def.description, - parameters: serializeParameters(def.parameters), - }); - } - - return { - id: this.options.id, - label: this.options.label ?? this.options.id, - icon: this.options.icon, - iconUrl: this.options.iconUrl, - description: this.options.description, - actions, - triggers, - }; - } - - private setupRoutes(): void { - this.app.get("/manifest", (_req: Request, res: Response) => { - res.json(this.getManifest()); - }); - - this.app.post( - "/actions/:id/execute", - async (req: Request, res: Response) => { - const id = req.params.id as string; - const action = this.actions.get(id); - - if (!action) { - const response: ExecuteResponse = { - success: false, - error: `Action "${id}" not found`, - }; - res.status(404).json(response); - return; - } - - const body = req.body as ExecuteRequest; - - try { - const result = await action.execute({ - parameters: body.parameters ?? {}, - input: body.input, - }); - - const response: ExecuteResponse = { - success: true, - data: result, - }; - res.json(response); - } catch (err) { - const response: ExecuteResponse = { - success: false, - error: err instanceof Error ? err.message : String(err), - }; - res.status(500).json(response); - } - }, - ); - - this.app.post( - "/triggers/:id/setup", - async (req: Request, res: Response) => { - const id = req.params.id as string; - const trigger = this.triggers.get(id); - - if (!trigger) { - const response: SetupTriggerResponse = { - success: false, - error: `Trigger "${id}" not found`, - }; - res.status(404).json(response); - return; - } - - const body = req.body as SetupTriggerRequest; - - try { - const metadata = await trigger.setup({ - parameters: body.parameters ?? {}, - webhook: body.webhook, - }); - - const response: SetupTriggerResponse = { - success: true, - metadata: metadata as Record | undefined, - }; - res.json(response); - } catch (err) { - const response: SetupTriggerResponse = { - success: false, - error: err instanceof Error ? err.message : String(err), - }; - res.status(500).json(response); - } - }, - ); - - this.app.post( - "/triggers/:id/cleanup", - async (req: Request, res: Response) => { - const id = req.params.id as string; - const trigger = this.triggers.get(id); - - if (!trigger) { - const response: CleanupTriggerResponse = { - success: false, - error: `Trigger "${id}" not found`, - }; - res.status(404).json(response); - return; - } - - const body = req.body as CleanupTriggerRequest; - - try { - await trigger.cleanup?.({ - parameters: body.parameters ?? {}, - metadata: body.metadata, - }); - - const response: CleanupTriggerResponse = { success: true }; - res.json(response); - } catch (err) { - const response: CleanupTriggerResponse = { - success: false, - error: err instanceof Error ? err.message : String(err), - }; - res.status(500).json(response); - } - }, - ); - - this.app.post( - "/triggers/:id/webhook", - async (req: Request, res: Response) => { - const id = req.params.id as string; - const trigger = this.triggers.get(id); - - if (!trigger) { - const response: HandleTriggerWebhookResponse = { - success: false, - error: `Trigger "${id}" not found`, - }; - res.status(404).json(response); - return; - } - - const body = req.body as HandleTriggerWebhookRequest; - - try { - const result = await trigger.handleWebhook({ - parameters: body.parameters ?? {}, - metadata: body.metadata, - request: body.request, - }); - - const response: HandleTriggerWebhookResponse = { - success: true, - emit: result.emit ?? true, - eventType: "eventType" in result ? result.eventType : undefined, - payload: "payload" in result ? result.payload : undefined, - reason: "reason" in result ? result.reason : undefined, - response: result.response, - }; - res.json(response); - } catch (err) { - const response: HandleTriggerWebhookResponse = { - success: false, - error: err instanceof Error ? err.message : String(err), - }; - res.status(500).json(response); - } - }, - ); - } -} - -function serializeParameters( - parameters: Record = {}, -): ParameterManifest[] { - return Object.entries(parameters).map(([id, parameter]) => ({ - id, - label: parameter.label, - type: parameter.type, - description: parameter.description, - required: parameter.required ?? false, - default: parameter.default, - options: parameter.options, - })); -} diff --git a/sdk/src/types.ts b/sdk/src/types.ts deleted file mode 100644 index a539aff3f4..0000000000 --- a/sdk/src/types.ts +++ /dev/null @@ -1,192 +0,0 @@ -export interface ParameterOption { - label: string; - value: string; -} - -export interface ParameterDefinition { - label: string; - type: "string" | "text" | "number" | "bool" | "select" | "object"; - description?: string; - required?: boolean; - default?: unknown; - options?: ParameterOption[]; -} - -export interface ActionExecutionContext> { - parameters: TParams; - input?: unknown; -} - -export interface ActionDefinition> { - label: string; - description?: string; - icon?: string; - iconUrl?: string; - parameters?: Record; - execute: ( - ctx: ActionExecutionContext, - ) => Promise> | Record; -} - -export interface TriggerWebhookConfig { - url: string; - secret?: string; -} - -export interface TriggerSetupContext> { - parameters: TParams; - webhook: TriggerWebhookConfig; -} - -export interface TriggerCleanupContext< - TParams = Record, - TMetadata = Record, -> { - parameters: TParams; - metadata?: TMetadata; -} - -export interface ForwardedWebhookRequest { - method: string; - headers: Record; - query?: Record; - rawBodyBase64: string; -} - -export interface TriggerWebhookContext< - TParams = Record, - TMetadata = Record, -> { - parameters: TParams; - metadata?: TMetadata; - request: ForwardedWebhookRequest; -} - -export interface WebhookHttpResponse { - status?: number; - headers?: Record; - body?: string; -} - -export type TriggerWebhookResult = - | { - emit?: true; - eventType?: string; - payload: unknown; - response?: WebhookHttpResponse; - } - | { - emit: false; - reason?: string; - response?: WebhookHttpResponse; - }; - -export interface TriggerDefinition< - TParams = Record, - TMetadata = Record, -> { - label: string; - description?: string; - icon?: string; - iconUrl?: string; - parameters?: Record; - setup: ( - ctx: TriggerSetupContext, - ) => Promise | TMetadata | void; - cleanup?: ( - ctx: TriggerCleanupContext, - ) => Promise | void; - handleWebhook: ( - ctx: TriggerWebhookContext, - ) => Promise | TriggerWebhookResult; -} - -export interface ParameterManifest extends ParameterDefinition { - id: string; - required: boolean; -} - -export interface ActionManifest { - id: string; - label: string; - icon?: string; - iconUrl?: string; - description?: string; - parameters: ParameterManifest[]; -} - -export interface TriggerManifest { - id: string; - label: string; - icon?: string; - iconUrl?: string; - description?: string; - parameters: ParameterManifest[]; -} - -export interface Manifest { - id: string; - label: string; - icon?: string; - iconUrl?: string; - description?: string; - actions: ActionManifest[]; - triggers: TriggerManifest[]; -} - -export interface PlaneletOptions { - id: string; - label?: string; - icon?: string; - iconUrl?: string; - description?: string; -} - -export interface ExecuteRequest { - parameters?: Record; - input?: unknown; -} - -export interface ExecuteResponse { - success: boolean; - data?: Record; - error?: string; -} - -export interface SetupTriggerRequest { - parameters?: Record; - webhook: TriggerWebhookConfig; -} - -export interface SetupTriggerResponse { - success: boolean; - metadata?: Record; - error?: string; -} - -export interface CleanupTriggerRequest { - parameters?: Record; - metadata?: Record; -} - -export interface CleanupTriggerResponse { - success: boolean; - error?: string; -} - -export interface HandleTriggerWebhookRequest { - parameters?: Record; - metadata?: Record; - request: ForwardedWebhookRequest; -} - -export interface HandleTriggerWebhookResponse { - success: boolean; - emit?: boolean; - eventType?: string; - payload?: unknown; - reason?: string; - response?: WebhookHttpResponse; - error?: string; - status?: number; -} diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json deleted file mode 100644 index 8c5e8c251d..0000000000 --- a/sdk/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} From e35cb8a2c91aee2339f341e9ae4c7e093e1eba55 Mon Sep 17 00:00:00 2001 From: Evan Zhou Date: Sat, 30 May 2026 12:47:02 -0700 Subject: [PATCH 22/23] chore: drop planelet v2 naming --- pkg/integrations/planelet/planelet_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/integrations/planelet/planelet_test.go b/pkg/integrations/planelet/planelet_test.go index 33b54e8f4d..9d3a5a6811 100644 --- a/pkg/integrations/planelet/planelet_test.go +++ b/pkg/integrations/planelet/planelet_test.go @@ -14,7 +14,7 @@ import ( "github.com/superplanehq/superplane/test/support/contexts" ) -func Test__Client__ExecuteActionUsesV2Endpoint(t *testing.T) { +func Test__Client__ExecuteActionUsesActionEndpoint(t *testing.T) { integration := &contexts.IntegrationContext{ Configuration: map[string]any{ "serverUrl": "https://planelet.example/", From d04f76fb29650ac0be469a3c6ad5c9cf06487b82 Mon Sep 17 00:00:00 2001 From: ThatXliner Date: Sat, 30 May 2026 16:54:06 -0700 Subject: [PATCH 23/23] revert changes to docker-entry --- docker-entrypoint.dev.sh | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/docker-entrypoint.dev.sh b/docker-entrypoint.dev.sh index 76b44ab8d6..f423acd834 100644 --- a/docker-entrypoint.dev.sh +++ b/docker-entrypoint.dev.sh @@ -20,16 +20,11 @@ stop_watchers() { } stop_watchers -# `make dev.server` launches this via `compose exec -d`. Under Podman, when the detached -# exec session leader exits, the whole exec process group is SIGKILLed — so backgrounded -# watchers (air, vite) and the Go server they spawn get reaped the moment this script returns. -# `setsid` moves each watcher into its own session so it survives the exec teardown. -# (Docker tolerated the old `air & ; npm run dev & ; wait -n`; Podman does not.) -LOG_DIR=/app/tmp -mkdir -p "$LOG_DIR" - -setsid air >"$LOG_DIR/air.log" 2>&1 & +air & cd web_src -setsid npm run dev >"$LOG_DIR/vite.log" 2>&1 & +npm run dev & cd .. + +wait -n +exit $?