Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
81ace7e
:sparkles: feat(plugin): add manifest types and global cache
ThatXliner May 30, 2026
720b231
:sparkles: feat(plugin): add HTTP client for plugin servers
ThatXliner May 30, 2026
59206a5
:sparkles: feat(plugin): add RunAction with dynamic config from manifest
ThatXliner May 30, 2026
8ea8fbd
:sparkles: feat(plugin): add OnEvent trigger for plugin server events
ThatXliner May 30, 2026
99f750f
:sparkles: feat(plugin): add main integration with registration
ThatXliner May 30, 2026
5589215
:sparkles: feat(sdk): add TypeScript Plugin SDK
ThatXliner May 30, 2026
9400336
:sparkles: feat(sdk): add example quotes plugin
ThatXliner May 30, 2026
ea8af9b
:memo: docs: add Plugin SDK documentation
ThatXliner May 30, 2026
ac0515a
:wrench: chore: add plugin-example service to docker-compose
ThatXliner May 30, 2026
b4a3385
:bug: fix(plugin-example): build SDK before running example in Docker
ThatXliner May 30, 2026
ecdeec5
:bug: fix(plugin-example): clear node_modules and skip cache on install
ThatXliner May 30, 2026
c0d6c70
Update PLUGIN-DOCS.md
ThatXliner May 30, 2026
d32ca8c
:wrench: fix(dev): make compose stack run under Podman
ThatXliner May 30, 2026
eace1bf
:wrench: fix(dev): keep watchers alive under Podman detached exec
ThatXliner May 30, 2026
7aa3be0
:wrench: fix(dev): exclude sdk from air watcher
ThatXliner May 30, 2026
b5b57e2
:truck: refactor(integrations): rename plugin integration to planelet
ThatXliner May 30, 2026
ded3031
:truck: refactor(sdk): rename plugin SDK to planelet
ThatXliner May 30, 2026
3e18682
:truck: docs: rename plugin SDK docs to planelet
ThatXliner May 30, 2026
58718dd
:see_no_evil: chore(sdk): ignore node_modules
ThatXliner May 30, 2026
3e126f1
✨ New Planelet spec
EvanZhouDev May 30, 2026
07dc4cb
chore: remove unused planelet sdk artifacts
EvanZhouDev May 30, 2026
e35cb8a
chore: drop planelet v2 naming
EvanZhouDev May 30, 2026
4f2e6af
Merge pull request #1 from ThatXliner/codex/plugin-api-v2
ThatXliner May 30, 2026
2d4be8f
Merge branch 'main' into main
ThatXliner May 30, 2026
d04f76f
revert changes to docker-entry
ThatXliner May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .air.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 11 additions & 7 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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
Expand All @@ -144,7 +141,6 @@ services:
volumes:
- go-pkg-cache:/go
- .:/app
- /tmp:/tmp

supergit:
image: ghcr.io/superplanehq/supergit:0.1.1
Expand Down Expand Up @@ -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
Expand All @@ -215,6 +209,16 @@ services:
timeout: 3s
retries: 5

plugin-example:
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 && rm -rf node_modules && bun install --no-cache && bun run index.ts"]
volumes:
- .:/app
ports:
- ${PLUGIN_EXAMPLE_PORT:-3001}:3001

volumes:
supergit-data:
driver: local
Expand Down
3 changes: 3 additions & 0 deletions pkg/core/trigger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -124,6 +126,7 @@ type WebhookRequestContext struct {
type WebhookResponseBody struct {
Body []byte
ContentType string
Headers map[string]string
}

type NodeWebhookContext interface {
Expand Down
285 changes: 285 additions & 0 deletions pkg/integrations/planelet/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
package planelet

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"

"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: strings.TrimRight(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"`
}

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 {
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(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 0, nil, fmt.Errorf("failed to marshal request: %w", err)
}

req, err := http.NewRequest("POST", c.serverURL+path, bytes.NewReader(bodyBytes))
if err != nil {
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 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 resp.StatusCode, nil, fmt.Errorf("failed to read response: %w", err)
}

if resp.StatusCode >= 400 {
_ = json.Unmarshal(respBody, result)
return resp.StatusCode, respBody, nil
}

if len(respBody) == 0 {
return resp.StatusCode, respBody, 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) {
if c.authToken != "" {
req.Header.Set("Authorization", "Bearer "+c.authToken)
}
}
18 changes: 18 additions & 0 deletions pkg/integrations/planelet/example.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package planelet

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)
}
10 changes: 10 additions & 0 deletions pkg/integrations/planelet/example_output.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"type": "planelet.action.success",
"data": {
"action": "example-action",
"result": {
"message": "Action completed successfully"
}
},
"timestamp": "2026-05-30T12:00:00Z"
}
Loading