From 8648490873e67214ce8515efd1e3b7aaa03a3136 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:27:37 +0000 Subject: [PATCH 1/5] Initial plan From 9bc2b07276b452ab29723828fef85674d5a06b4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:38:14 +0000 Subject: [PATCH 2/5] Add UI plugin implementation using GoCodeAlone/go-plugin for hot-reload/hot-deploy Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- plugin/external/sdk/serve.go | 24 ++ plugin/external/sdk/ui_provider.go | 22 ++ plugin/external/ui_handler.go | 155 +++++++++ plugin/external/ui_manifest.go | 72 ++++ plugin/external/ui_plugin.go | 297 ++++++++++++++++ plugin/external/ui_plugin_test.go | 542 +++++++++++++++++++++++++++++ 6 files changed, 1112 insertions(+) create mode 100644 plugin/external/sdk/ui_provider.go create mode 100644 plugin/external/ui_handler.go create mode 100644 plugin/external/ui_manifest.go create mode 100644 plugin/external/ui_plugin.go create mode 100644 plugin/external/ui_plugin_test.go diff --git a/plugin/external/sdk/serve.go b/plugin/external/sdk/serve.go index dbe4b6d21..a83e09740 100644 --- a/plugin/external/sdk/serve.go +++ b/plugin/external/sdk/serve.go @@ -2,6 +2,8 @@ package sdk import ( "context" + "encoding/json" + "os" goplugin "github.com/GoCodeAlone/go-plugin" ext "github.com/GoCodeAlone/workflow/plugin/external" @@ -12,12 +14,20 @@ import ( // Serve is the entry point for plugin authors. It starts the gRPC plugin server // and blocks until the host process terminates the connection. // +// If provider implements UIProvider, Serve writes a "ui.json" file to the +// current working directory (if one does not already exist). Plugin authors +// can also maintain "ui.json" manually without implementing UIProvider. +// // Usage: // // func main() { // sdk.Serve(&myPlugin{}) // } func Serve(provider PluginProvider) { + if up, ok := provider.(UIProvider); ok { + writeUIManifestIfAbsent(up.UIManifest()) + } + server := newGRPCServer(provider) goplugin.Serve(&goplugin.ServeConfig{ @@ -46,3 +56,17 @@ func (p *servePlugin) GRPCServer(_ *goplugin.GRPCBroker, s *grpc.Server) error { func (p *servePlugin) GRPCClient(_ context.Context, _ *goplugin.GRPCBroker, _ *grpc.ClientConn) (any, error) { return nil, nil } + +// writeUIManifestIfAbsent writes m to "ui.json" in the current working +// directory only when that file does not already exist. +func writeUIManifestIfAbsent(m ext.UIManifest) { + const filename = "ui.json" + if _, err := os.Stat(filename); err == nil { + return // file already exists — do not overwrite + } + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return + } + _ = os.WriteFile(filename, data, 0o600) +} diff --git a/plugin/external/sdk/ui_provider.go b/plugin/external/sdk/ui_provider.go new file mode 100644 index 000000000..c1c788e76 --- /dev/null +++ b/plugin/external/sdk/ui_provider.go @@ -0,0 +1,22 @@ +package sdk + +import ext "github.com/GoCodeAlone/workflow/plugin/external" + +// UIProvider is an optional interface that PluginProvider implementations can +// satisfy to declare UI assets and navigation contributions. +// +// If a PluginProvider implements UIProvider, the SDK Serve() function will +// write a "ui.json" file to the plugin's working directory on first start +// (if one does not already exist). Alternatively, authors can maintain +// "ui.json" manually without implementing this interface. +// +// # Type aliases +// +// The UI manifest types (UIManifest, UINavItem) are defined in the +// github.com/GoCodeAlone/workflow/plugin/external package so that both the +// host engine and plugin processes share the same type definitions without +// introducing an import cycle. +type UIProvider interface { + // UIManifest returns the UI manifest for this plugin. + UIManifest() ext.UIManifest +} diff --git a/plugin/external/ui_handler.go b/plugin/external/ui_handler.go new file mode 100644 index 000000000..6f48eeaac --- /dev/null +++ b/plugin/external/ui_handler.go @@ -0,0 +1,155 @@ +package external + +import ( + "net/http" + "sort" + "strings" +) + +// UIPluginHandler provides HTTP API endpoints for managing UI plugins. +// +// Routes registered by RegisterRoutes: +// +// GET /api/v1/plugins/ui – list loaded UI plugins +// GET /api/v1/plugins/ui/available – list all discovered UI plugins +// GET /api/v1/plugins/ui/{name}/manifest – get a plugin's UI manifest +// POST /api/v1/plugins/ui/{name}/load – load a UI plugin +// POST /api/v1/plugins/ui/{name}/unload – unload a UI plugin +// POST /api/v1/plugins/ui/{name}/reload – hot-reload a UI plugin +// GET /api/v1/plugins/ui/{name}/assets/{path…} – serve static assets +type UIPluginHandler struct { + manager *UIPluginManager +} + +// NewUIPluginHandler creates a new handler backed by the given UIPluginManager. +func NewUIPluginHandler(manager *UIPluginManager) *UIPluginHandler { + return &UIPluginHandler{manager: manager} +} + +// RegisterRoutes registers all UI plugin HTTP routes on mux. +func (h *UIPluginHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/v1/plugins/ui", h.handleListLoaded) + mux.HandleFunc("GET /api/v1/plugins/ui/available", h.handleListAvailable) + mux.HandleFunc("GET /api/v1/plugins/ui/{name}/manifest", h.handleGetManifest) + mux.HandleFunc("POST /api/v1/plugins/ui/{name}/load", h.handleLoad) + mux.HandleFunc("POST /api/v1/plugins/ui/{name}/unload", h.handleUnload) + mux.HandleFunc("POST /api/v1/plugins/ui/{name}/reload", h.handleReload) + mux.HandleFunc("GET /api/v1/plugins/ui/{name}/assets/", h.handleServeAssets) +} + +// handleListLoaded returns info about all currently loaded UI plugins. +func (h *UIPluginHandler) handleListLoaded(w http.ResponseWriter, _ *http.Request) { + infos := h.manager.AllUIPluginInfos() + if infos == nil { + infos = []UIPluginInfo{} + } + sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name }) + writeOK(w, infos) +} + +// handleListAvailable lists all discovered UI plugin directories (whether +// loaded or not). +func (h *UIPluginHandler) handleListAvailable(w http.ResponseWriter, _ *http.Request) { + names, err := h.manager.DiscoverPlugins() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if names == nil { + names = []string{} + } + sort.Strings(names) + + type entry struct { + Name string `json:"name"` + Loaded bool `json:"loaded"` + } + plugins := make([]entry, 0, len(names)) + for _, name := range names { + plugins = append(plugins, entry{ + Name: name, + Loaded: h.manager.IsLoaded(name), + }) + } + writeOK(w, plugins) +} + +// handleGetManifest returns the UI manifest for a loaded plugin. +func (h *UIPluginHandler) handleGetManifest(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + entry, ok := h.manager.GetPlugin(name) + if !ok { + writeError(w, http.StatusNotFound, "UI plugin not found or not loaded") + return + } + writeOK(w, entry.Manifest) +} + +// handleLoad loads a UI plugin by reading its ui.json from disk. +func (h *UIPluginHandler) handleLoad(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + writeError(w, http.StatusBadRequest, "plugin name is required") + return + } + if err := h.manager.LoadPlugin(name); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeOK(w, map[string]string{"name": name, "action": "loaded"}) +} + +// handleUnload removes a UI plugin from the manager. +func (h *UIPluginHandler) handleUnload(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + writeError(w, http.StatusBadRequest, "plugin name is required") + return + } + if err := h.manager.UnloadPlugin(name); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeOK(w, map[string]string{"name": name, "action": "unloaded"}) +} + +// handleReload hot-reloads a UI plugin by re-reading its manifest and assets +// from disk. This is the primary mechanism for hot-deploy: copy updated files +// to the plugin directory and call this endpoint. +func (h *UIPluginHandler) handleReload(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + writeError(w, http.StatusBadRequest, "plugin name is required") + return + } + if err := h.manager.ReloadPlugin(name); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeOK(w, map[string]string{"name": name, "action": "reloaded"}) +} + +// handleServeAssets serves static asset files for a loaded UI plugin. +// The URL prefix /api/v1/plugins/ui/{name}/assets/ is stripped before +// delegating to an http.FileServer rooted at the plugin's assets directory. +func (h *UIPluginHandler) handleServeAssets(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + assetHandler := h.manager.ServeAssets(name) + if assetHandler == nil { + writeError(w, http.StatusNotFound, "UI plugin not found or not loaded") + return + } + + // Strip the route prefix so http.FileServer resolves paths relative to + // the assets root. + prefix := "/api/v1/plugins/ui/" + name + "/assets" + stripped := strings.TrimPrefix(r.URL.Path, prefix) + if stripped == r.URL.Path { + // Path did not begin with the expected prefix – shouldn't happen. + http.NotFound(w, r) + return + } + r2 := r.Clone(r.Context()) + r2.URL.Path = stripped + assetHandler.ServeHTTP(w, r2) +} diff --git a/plugin/external/ui_manifest.go b/plugin/external/ui_manifest.go new file mode 100644 index 000000000..91a3b84ac --- /dev/null +++ b/plugin/external/ui_manifest.go @@ -0,0 +1,72 @@ +package external + +// UIManifest describes the UI contribution of a go-plugin UI plugin. +// Place this as "ui.json" in the plugin directory alongside "plugin.json". +// +// # Directory Layout +// +// plugins/ +// my-ui-plugin/ +// my-ui-plugin (binary, required for API handlers; optional for asset-only plugins) +// plugin.json (gRPC plugin manifest; required when binary is present) +// ui.json (UI manifest: nav items and asset location) +// assets/ (static files: HTML, CSS, JS, images, etc.) +// index.html +// main.js +// +// # Asset Versioning +// +// Include a version hash in asset filenames (e.g. main.abc123.js) and +// reference them from index.html so browsers pick up new files after +// hot-deploy. +// +// # Hot-Reload +// +// After updating assets or ui.json, call: +// +// POST /api/v1/plugins/ui/{name}/reload +// +// This re-reads the manifest and serves the new files without restarting the +// workflow engine. +// +// # Hot-Deploy +// +// Replace the plugin binary and/or assets directory with the new version, +// then call the reload endpoint above. +type UIManifest struct { + // Name is the plugin identifier. Must match the plugin directory name. + Name string `json:"name"` + // Version is the plugin version string (e.g. "1.0.0"). + Version string `json:"version"` + // Description is a short human-readable description of the plugin. + Description string `json:"description,omitempty"` + // NavItems declares navigation entries contributed to the admin UI. + NavItems []UINavItem `json:"navItems,omitempty"` + // AssetDir is the subdirectory within the plugin directory that holds + // static assets. Defaults to "assets" when empty. + AssetDir string `json:"assetDir,omitempty"` +} + +// UINavItem describes a single entry in the admin UI navigation sidebar. +type UINavItem struct { + // ID is the unique page identifier (e.g. "my-plugin-dashboard"). + ID string `json:"id"` + // Label is the human-readable navigation label shown in the sidebar. + Label string `json:"label"` + // Icon is an emoji or icon token rendered alongside the label. + Icon string `json:"icon,omitempty"` + // Category groups navigation entries: + // "global" – always visible top-level entries + // "workflow" – shown only when a workflow is open + // "plugin" – plugin-contributed entries (default) + // "tools" – administrative tooling entries + Category string `json:"category,omitempty"` + // Order controls the sort position within the category (lower = higher up). + Order int `json:"order,omitempty"` + // RequiredRole is the minimum role needed to see this page + // (e.g. "viewer", "editor", "admin", "operator"). + RequiredRole string `json:"requiredRole,omitempty"` + // RequiredPermission is a specific permission key required to see this page + // (e.g. "plugins.manage"). + RequiredPermission string `json:"requiredPermission,omitempty"` +} diff --git a/plugin/external/ui_plugin.go b/plugin/external/ui_plugin.go new file mode 100644 index 000000000..48bdfbb46 --- /dev/null +++ b/plugin/external/ui_plugin.go @@ -0,0 +1,297 @@ +package external + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "sync" + + "github.com/GoCodeAlone/workflow/plugin" +) + +// UIPluginEntry holds the runtime state of a loaded UI plugin. +type UIPluginEntry struct { + // Manifest is the parsed ui.json for this plugin. + Manifest UIManifest + // AssetsDir is the absolute path to the plugin's static assets directory. + AssetsDir string +} + +// UIPluginInfo is the JSON representation of a UI plugin for API responses. +type UIPluginInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + NavItems []UINavItem `json:"navItems,omitempty"` + Loaded bool `json:"loaded"` +} + +// UIPluginManager discovers and manages UI plugins under a shared plugins +// directory. Each UI plugin is a subdirectory that contains a "ui.json" +// manifest and an optional "assets" subdirectory with static files. +// +// # Hot-reload +// +// Calling ReloadPlugin re-reads the manifest and the assets directory from +// disk without restarting the workflow engine. The static file server for the +// plugin is updated atomically so in-flight requests are not interrupted. +// +// # Integration with PluginManager navigation +// +// Call UIPages to get UIPageDef entries for a loaded UI plugin, then register +// a UIPluginNativePlugin wrapper with a PluginManager to surface those entries +// through the standard navigation API. +type UIPluginManager struct { + pluginsDir string + logger *log.Logger + + mu sync.RWMutex + plugins map[string]*UIPluginEntry +} + +// NewUIPluginManager creates a manager that scans the given directory for UI +// plugins (subdirectories containing a "ui.json" manifest). +func NewUIPluginManager(pluginsDir string, logger *log.Logger) *UIPluginManager { + if logger == nil { + logger = log.New(os.Stderr, "[ui-plugins] ", log.LstdFlags) + } + return &UIPluginManager{ + pluginsDir: pluginsDir, + logger: logger, + plugins: make(map[string]*UIPluginEntry), + } +} + +// DiscoverPlugins scans the plugins directory and returns names of all +// subdirectories that contain a "ui.json" manifest file. +func (m *UIPluginManager) DiscoverPlugins() ([]string, error) { + entries, err := os.ReadDir(m.pluginsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read plugins directory: %w", err) + } + + var names []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + manifestPath := filepath.Join(m.pluginsDir, name, "ui.json") + if _, statErr := os.Stat(manifestPath); statErr == nil { + names = append(names, name) + } + } + return names, nil +} + +// LoadPlugin reads the "ui.json" manifest for the named plugin and registers +// it. If the plugin is already loaded it is replaced (hot-reload semantics). +func (m *UIPluginManager) LoadPlugin(name string) error { + manifestPath := filepath.Join(m.pluginsDir, name, "ui.json") + data, err := os.ReadFile(manifestPath) //nolint:gosec // path built from trusted pluginsDir + name + if err != nil { + return fmt.Errorf("read ui.json for plugin %q: %w", name, err) + } + + var manifest UIManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return fmt.Errorf("parse ui.json for plugin %q: %w", name, err) + } + + if manifest.Name == "" { + manifest.Name = name + } + if manifest.AssetDir == "" { + manifest.AssetDir = "assets" + } + + assetDir := filepath.Join(m.pluginsDir, name, manifest.AssetDir) + + m.mu.Lock() + m.plugins[name] = &UIPluginEntry{ + Manifest: manifest, + AssetsDir: assetDir, + } + m.mu.Unlock() + + m.logger.Printf("UI plugin %q loaded (version %s)", name, manifest.Version) + return nil +} + +// UnloadPlugin removes a UI plugin from the manager. Returns an error if the +// plugin is not currently loaded. +func (m *UIPluginManager) UnloadPlugin(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, exists := m.plugins[name]; !exists { + return fmt.Errorf("UI plugin %q is not loaded", name) + } + delete(m.plugins, name) + m.logger.Printf("UI plugin %q unloaded", name) + return nil +} + +// ReloadPlugin re-reads the manifest and assets directory from disk for the +// named plugin. This is the primary hot-reload mechanism: deploy updated +// assets to the plugin directory, then call this method. +func (m *UIPluginManager) ReloadPlugin(name string) error { + return m.LoadPlugin(name) +} + +// IsLoaded returns true if the named UI plugin is currently loaded. +func (m *UIPluginManager) IsLoaded(name string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + _, exists := m.plugins[name] + return exists +} + +// GetPlugin returns the entry for the named UI plugin. The second return +// value is false if the plugin is not loaded. +func (m *UIPluginManager) GetPlugin(name string) (*UIPluginEntry, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + e, ok := m.plugins[name] + return e, ok +} + +// LoadedPlugins returns the names of all currently loaded UI plugins. +func (m *UIPluginManager) LoadedPlugins() []string { + m.mu.RLock() + defer m.mu.RUnlock() + names := make([]string, 0, len(m.plugins)) + for name := range m.plugins { + names = append(names, name) + } + return names +} + +// ServeAssets returns an http.Handler that serves the static assets of the +// named plugin directly from its assets directory. Returns nil if the plugin +// is not loaded or its assets directory does not exist. +func (m *UIPluginManager) ServeAssets(name string) http.Handler { + m.mu.RLock() + entry, ok := m.plugins[name] + m.mu.RUnlock() + if !ok { + return nil + } + return http.FileServer(http.Dir(entry.AssetsDir)) //nolint:gosec // path comes from trusted pluginsDir +} + +// UIPages converts the nav items declared in a loaded UI plugin's manifest +// into plugin.UIPageDef entries compatible with the PluginManager navigation +// system. +func (m *UIPluginManager) UIPages(name string) []plugin.UIPageDef { + m.mu.RLock() + entry, ok := m.plugins[name] + m.mu.RUnlock() + if !ok { + return nil + } + return uiNavItemsToPageDefs(name, entry.Manifest.NavItems) +} + +// AllUIPluginInfos returns summary information for every currently loaded UI +// plugin. +func (m *UIPluginManager) AllUIPluginInfos() []UIPluginInfo { + m.mu.RLock() + defer m.mu.RUnlock() + result := make([]UIPluginInfo, 0, len(m.plugins)) + for name, entry := range m.plugins { + result = append(result, UIPluginInfo{ + Name: name, + Version: entry.Manifest.Version, + Description: entry.Manifest.Description, + NavItems: entry.Manifest.NavItems, + Loaded: true, + }) + } + return result +} + +// AsNativePlugin returns a plugin.NativePlugin implementation that surfaces +// the named UI plugin's navigation entries through the standard PluginManager +// API. Returns nil if the plugin is not loaded. +// +// Use this to register a UI plugin's nav items with a PluginManager: +// +// if np := uiMgr.AsNativePlugin("my-ui-plugin"); np != nil { +// _ = pluginMgr.Register(np) +// _ = pluginMgr.Enable("my-ui-plugin") +// } +func (m *UIPluginManager) AsNativePlugin(name string) plugin.NativePlugin { + m.mu.RLock() + entry, ok := m.plugins[name] + m.mu.RUnlock() + if !ok { + return nil + } + return &UIPluginNativePlugin{ + manager: m, + name: name, + version: entry.Manifest.Version, + desc: entry.Manifest.Description, + } +} + +// UIPluginNativePlugin adapts a loaded UI plugin as a plugin.NativePlugin so +// its navigation entries are visible through the standard PluginManager API. +// It implements a read-through to the UIPluginManager so that hot-reloads +// automatically update the navigation data returned by UIPages. +type UIPluginNativePlugin struct { + manager *UIPluginManager + name string + version string + desc string +} + +func (p *UIPluginNativePlugin) Name() string { return p.name } +func (p *UIPluginNativePlugin) Version() string { return p.version } +func (p *UIPluginNativePlugin) Description() string { return p.desc } + +func (p *UIPluginNativePlugin) Dependencies() []plugin.PluginDependency { return nil } + +// UIPages reads the current nav items from the UIPluginManager so that +// hot-reloads (which call UIPluginManager.ReloadPlugin) are reflected +// immediately without re-registering the plugin. +func (p *UIPluginNativePlugin) UIPages() []plugin.UIPageDef { + return p.manager.UIPages(p.name) +} + +func (p *UIPluginNativePlugin) RegisterRoutes(_ *http.ServeMux) {} +func (p *UIPluginNativePlugin) OnEnable(_ plugin.PluginContext) error { return nil } +func (p *UIPluginNativePlugin) OnDisable(_ plugin.PluginContext) error { return nil } + +// Ensure UIPluginNativePlugin satisfies plugin.NativePlugin at compile time. +var _ plugin.NativePlugin = (*UIPluginNativePlugin)(nil) + +// uiNavItemsToPageDefs converts a slice of UINavItem into plugin.UIPageDef +// entries, filling in defaults where needed. +func uiNavItemsToPageDefs(pluginName string, items []UINavItem) []plugin.UIPageDef { + defs := make([]plugin.UIPageDef, 0, len(items)) + for _, item := range items { + category := item.Category + if category == "" { + category = "plugin" + } + defs = append(defs, plugin.UIPageDef{ + ID: item.ID, + Label: item.Label, + Icon: item.Icon, + Category: category, + Order: item.Order, + RequiredRole: item.RequiredRole, + RequiredPermission: item.RequiredPermission, + APIEndpoint: "/api/v1/plugins/ui/" + pluginName + "/assets/", + }) + } + return defs +} diff --git a/plugin/external/ui_plugin_test.go b/plugin/external/ui_plugin_test.go new file mode 100644 index 000000000..62618ed38 --- /dev/null +++ b/plugin/external/ui_plugin_test.go @@ -0,0 +1,542 @@ +package external + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/GoCodeAlone/workflow/plugin" +) + +// ---- helpers ---- + +// setupPluginDir creates a temporary plugins root and returns its path. +func setupPluginDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + return dir +} + +// makeUIPlugin creates a subdirectory under root for the named plugin with +// the given ui.json content, and optionally writes asset files. +func makeUIPlugin(t *testing.T, root, name string, manifest UIManifest, assets map[string]string) { + t.Helper() + pluginDir := filepath.Join(root, name) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatalf("create plugin dir: %v", err) + } + + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "ui.json"), data, 0o600); err != nil { + t.Fatalf("write ui.json: %v", err) + } + + if len(assets) > 0 { + assetDir := manifest.AssetDir + if assetDir == "" { + assetDir = "assets" + } + assetsPath := filepath.Join(pluginDir, assetDir) + if err := os.MkdirAll(assetsPath, 0o755); err != nil { + t.Fatalf("create assets dir: %v", err) + } + for filename, content := range assets { + if err := os.WriteFile(filepath.Join(assetsPath, filename), []byte(content), 0o600); err != nil { + t.Fatalf("write asset %s: %v", filename, err) + } + } + } +} + +// ---- UIPluginManager tests ---- + +func TestUIPluginManager_DiscoverPlugins(t *testing.T) { + root := setupPluginDir(t) + + // Plugin with ui.json + makeUIPlugin(t, root, "alpha", UIManifest{Name: "alpha", Version: "1.0.0"}, nil) + // Plugin without ui.json (should not be discovered) + if err := os.MkdirAll(filepath.Join(root, "no-ui"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // Another valid plugin + makeUIPlugin(t, root, "beta", UIManifest{Name: "beta", Version: "2.0.0"}, nil) + + mgr := NewUIPluginManager(root, nil) + names, err := mgr.DiscoverPlugins() + if err != nil { + t.Fatalf("DiscoverPlugins: %v", err) + } + sort.Strings(names) + + want := []string{"alpha", "beta"} + if len(names) != len(want) { + t.Fatalf("expected plugins %v, got %v", want, names) + } + for i, n := range names { + if n != want[i] { + t.Errorf("names[%d]: want %q, got %q", i, want[i], n) + } + } +} + +func TestUIPluginManager_DiscoverPlugins_EmptyDir(t *testing.T) { + root := setupPluginDir(t) + mgr := NewUIPluginManager(root, nil) + names, err := mgr.DiscoverPlugins() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 0 { + t.Errorf("expected no plugins, got %v", names) + } +} + +func TestUIPluginManager_DiscoverPlugins_NonexistentDir(t *testing.T) { + mgr := NewUIPluginManager("/nonexistent/path", nil) + names, err := mgr.DiscoverPlugins() + if err != nil { + t.Fatalf("expected nil error for missing dir, got: %v", err) + } + if names != nil { + t.Errorf("expected nil names, got %v", names) + } +} + +func TestUIPluginManager_LoadPlugin(t *testing.T) { + root := setupPluginDir(t) + makeUIPlugin(t, root, "my-plugin", UIManifest{ + Name: "my-plugin", + Version: "1.2.3", + Description: "A test plugin", + NavItems: []UINavItem{ + {ID: "my-page", Label: "My Page", Icon: "🔌", Category: "plugin", Order: 5}, + }, + }, nil) + + mgr := NewUIPluginManager(root, nil) + if err := mgr.LoadPlugin("my-plugin"); err != nil { + t.Fatalf("LoadPlugin: %v", err) + } + + if !mgr.IsLoaded("my-plugin") { + t.Error("plugin should be loaded") + } + + entry, ok := mgr.GetPlugin("my-plugin") + if !ok { + t.Fatal("GetPlugin returned false") + } + if entry.Manifest.Version != "1.2.3" { + t.Errorf("version: want 1.2.3, got %s", entry.Manifest.Version) + } + if len(entry.Manifest.NavItems) != 1 { + t.Errorf("nav items: want 1, got %d", len(entry.Manifest.NavItems)) + } +} + +func TestUIPluginManager_LoadPlugin_DefaultsAssetDir(t *testing.T) { + root := setupPluginDir(t) + // Manifest with no AssetDir set + makeUIPlugin(t, root, "plugin-a", UIManifest{Name: "plugin-a", Version: "1.0.0"}, nil) + + mgr := NewUIPluginManager(root, nil) + if err := mgr.LoadPlugin("plugin-a"); err != nil { + t.Fatalf("LoadPlugin: %v", err) + } + + entry, ok := mgr.GetPlugin("plugin-a") + if !ok { + t.Fatal("plugin not found") + } + wantAssetsDir := filepath.Join(root, "plugin-a", "assets") + if entry.AssetsDir != wantAssetsDir { + t.Errorf("AssetsDir: want %q, got %q", wantAssetsDir, entry.AssetsDir) + } +} + +func TestUIPluginManager_LoadPlugin_NotFound(t *testing.T) { + root := setupPluginDir(t) + mgr := NewUIPluginManager(root, nil) + err := mgr.LoadPlugin("nonexistent") + if err == nil { + t.Error("expected error for missing ui.json, got nil") + } +} + +func TestUIPluginManager_UnloadPlugin(t *testing.T) { + root := setupPluginDir(t) + makeUIPlugin(t, root, "remove-me", UIManifest{Name: "remove-me", Version: "1.0.0"}, nil) + + mgr := NewUIPluginManager(root, nil) + _ = mgr.LoadPlugin("remove-me") + + if err := mgr.UnloadPlugin("remove-me"); err != nil { + t.Fatalf("UnloadPlugin: %v", err) + } + if mgr.IsLoaded("remove-me") { + t.Error("plugin should no longer be loaded") + } +} + +func TestUIPluginManager_UnloadPlugin_NotLoaded(t *testing.T) { + root := setupPluginDir(t) + mgr := NewUIPluginManager(root, nil) + if err := mgr.UnloadPlugin("ghost"); err == nil { + t.Error("expected error when unloading a non-loaded plugin") + } +} + +func TestUIPluginManager_ReloadPlugin(t *testing.T) { + root := setupPluginDir(t) + makeUIPlugin(t, root, "reload-me", UIManifest{Name: "reload-me", Version: "1.0.0"}, nil) + + mgr := NewUIPluginManager(root, nil) + _ = mgr.LoadPlugin("reload-me") + + // Update the manifest on disk. + makeUIPlugin(t, root, "reload-me", UIManifest{Name: "reload-me", Version: "2.0.0"}, nil) + + if err := mgr.ReloadPlugin("reload-me"); err != nil { + t.Fatalf("ReloadPlugin: %v", err) + } + + entry, _ := mgr.GetPlugin("reload-me") + if entry.Manifest.Version != "2.0.0" { + t.Errorf("after reload version should be 2.0.0, got %s", entry.Manifest.Version) + } +} + +func TestUIPluginManager_LoadedPlugins(t *testing.T) { + root := setupPluginDir(t) + makeUIPlugin(t, root, "a", UIManifest{Name: "a", Version: "1.0.0"}, nil) + makeUIPlugin(t, root, "b", UIManifest{Name: "b", Version: "1.0.0"}, nil) + + mgr := NewUIPluginManager(root, nil) + _ = mgr.LoadPlugin("a") + _ = mgr.LoadPlugin("b") + + loaded := mgr.LoadedPlugins() + sort.Strings(loaded) + if len(loaded) != 2 || loaded[0] != "a" || loaded[1] != "b" { + t.Errorf("LoadedPlugins: want [a b], got %v", loaded) + } +} + +func TestUIPluginManager_ServeAssets(t *testing.T) { + root := setupPluginDir(t) + makeUIPlugin(t, root, "static-plugin", UIManifest{Name: "static-plugin", Version: "1.0.0"}, + map[string]string{ + "hello.txt": "world", + }) + + mgr := NewUIPluginManager(root, nil) + _ = mgr.LoadPlugin("static-plugin") + + h := mgr.ServeAssets("static-plugin") + if h == nil { + t.Fatal("ServeAssets returned nil for loaded plugin") + } + + req := httptest.NewRequest(http.MethodGet, "/hello.txt", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("serve asset: want 200, got %d", w.Code) + } + if body := w.Body.String(); body != "world" { + t.Errorf("unexpected body: %q", body) + } +} + +func TestUIPluginManager_ServeAssets_NotLoaded(t *testing.T) { + mgr := NewUIPluginManager(t.TempDir(), nil) + if h := mgr.ServeAssets("nonexistent"); h != nil { + t.Error("expected nil handler for unloaded plugin") + } +} + +func TestUIPluginManager_UIPages(t *testing.T) { + root := setupPluginDir(t) + makeUIPlugin(t, root, "nav-plugin", UIManifest{ + Name: "nav-plugin", + Version: "1.0.0", + NavItems: []UINavItem{ + {ID: "nav1", Label: "Nav1", Icon: "🌍", Category: "global", Order: 1}, + {ID: "nav2", Label: "Nav2", Order: 2}, // no category → defaults to "plugin" + }, + }, nil) + + mgr := NewUIPluginManager(root, nil) + _ = mgr.LoadPlugin("nav-plugin") + + pages := mgr.UIPages("nav-plugin") + if len(pages) != 2 { + t.Fatalf("want 2 UIPageDef, got %d", len(pages)) + } + if pages[0].Category != "global" { + t.Errorf("page 0 category: want global, got %s", pages[0].Category) + } + if pages[1].Category != "plugin" { + t.Errorf("page 1 category: want plugin (default), got %s", pages[1].Category) + } +} + +func TestUIPluginManager_UIPages_NotLoaded(t *testing.T) { + mgr := NewUIPluginManager(t.TempDir(), nil) + if pages := mgr.UIPages("ghost"); pages != nil { + t.Errorf("expected nil for unloaded plugin, got %v", pages) + } +} + +func TestUIPluginManager_AsNativePlugin(t *testing.T) { + root := setupPluginDir(t) + makeUIPlugin(t, root, "native-wrap", UIManifest{ + Name: "native-wrap", + Version: "3.0.0", + Description: "wrapped", + NavItems: []UINavItem{ + {ID: "nw-page", Label: "NW Page", Category: "tools"}, + }, + }, nil) + + mgr := NewUIPluginManager(root, nil) + _ = mgr.LoadPlugin("native-wrap") + + np := mgr.AsNativePlugin("native-wrap") + if np == nil { + t.Fatal("AsNativePlugin returned nil") + } + if np.Name() != "native-wrap" { + t.Errorf("Name: want native-wrap, got %s", np.Name()) + } + if np.Version() != "3.0.0" { + t.Errorf("Version: want 3.0.0, got %s", np.Version()) + } + pages := np.UIPages() + if len(pages) != 1 { + t.Fatalf("UIPages: want 1, got %d", len(pages)) + } + if pages[0].Category != "tools" { + t.Errorf("category: want tools, got %s", pages[0].Category) + } +} + +func TestUIPluginManager_AsNativePlugin_NotLoaded(t *testing.T) { + mgr := NewUIPluginManager(t.TempDir(), nil) + if np := mgr.AsNativePlugin("ghost"); np != nil { + t.Error("expected nil for unloaded plugin") + } +} + +// UIPluginNativePlugin hot-reload: updating the plugin on disk and reloading +// should be reflected through the NativePlugin.UIPages() without re-registering. +func TestUIPluginNativePlugin_HotReload_UpdatesUIPages(t *testing.T) { + root := setupPluginDir(t) + makeUIPlugin(t, root, "hot-plugin", UIManifest{ + Name: "hot-plugin", + Version: "1.0.0", + NavItems: []UINavItem{ + {ID: "old-page", Label: "Old Page"}, + }, + }, nil) + + mgr := NewUIPluginManager(root, nil) + _ = mgr.LoadPlugin("hot-plugin") + np := mgr.AsNativePlugin("hot-plugin") + + // Before reload: one page + if len(np.UIPages()) != 1 { + t.Fatalf("initial UIPages: want 1, got %d", len(np.UIPages())) + } + + // Update manifest on disk with two nav items. + makeUIPlugin(t, root, "hot-plugin", UIManifest{ + Name: "hot-plugin", + Version: "1.1.0", + NavItems: []UINavItem{ + {ID: "old-page", Label: "Old Page"}, + {ID: "new-page", Label: "New Page"}, + }, + }, nil) + _ = mgr.ReloadPlugin("hot-plugin") + + // After reload: two pages — no re-registration needed. + if len(np.UIPages()) != 2 { + t.Errorf("after reload UIPages: want 2, got %d", len(np.UIPages())) + } +} + +// ---- UIPluginHandler HTTP tests ---- + +func newTestUIPluginHandler(t *testing.T) (*UIPluginHandler, *UIPluginManager, string) { + t.Helper() + root := setupPluginDir(t) + mgr := NewUIPluginManager(root, nil) + return NewUIPluginHandler(mgr), mgr, root +} + +func TestUIPluginHandler_ListLoaded_Empty(t *testing.T) { + h, _, _ := newTestUIPluginHandler(t) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/ui", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } + var resp apiResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Status != "ok" { + t.Errorf("status: want ok, got %s", resp.Status) + } +} + +func TestUIPluginHandler_ListAvailable(t *testing.T) { + h, _, root := newTestUIPluginHandler(t) + makeUIPlugin(t, root, "discovered", UIManifest{Name: "discovered", Version: "1.0.0"}, nil) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/ui/available", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } +} + +func TestUIPluginHandler_LoadAndReload(t *testing.T) { + h, mgr, root := newTestUIPluginHandler(t) + makeUIPlugin(t, root, "loadable", UIManifest{Name: "loadable", Version: "1.0.0"}, nil) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + // Load + req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/ui/loadable/load", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("load: want 200, got %d: %s", w.Code, w.Body.String()) + } + if !mgr.IsLoaded("loadable") { + t.Error("plugin should be loaded after load request") + } + + // Update manifest and reload + makeUIPlugin(t, root, "loadable", UIManifest{Name: "loadable", Version: "2.0.0"}, nil) + req = httptest.NewRequest(http.MethodPost, "/api/v1/plugins/ui/loadable/reload", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("reload: want 200, got %d: %s", w.Code, w.Body.String()) + } + + entry, _ := mgr.GetPlugin("loadable") + if entry.Manifest.Version != "2.0.0" { + t.Errorf("after reload version should be 2.0.0, got %s", entry.Manifest.Version) + } +} + +func TestUIPluginHandler_Unload(t *testing.T) { + h, mgr, root := newTestUIPluginHandler(t) + makeUIPlugin(t, root, "removable", UIManifest{Name: "removable", Version: "1.0.0"}, nil) + _ = mgr.LoadPlugin("removable") + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/ui/removable/unload", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("unload: want 200, got %d: %s", w.Code, w.Body.String()) + } + if mgr.IsLoaded("removable") { + t.Error("plugin should not be loaded after unload request") + } +} + +func TestUIPluginHandler_GetManifest(t *testing.T) { + h, mgr, root := newTestUIPluginHandler(t) + makeUIPlugin(t, root, "manifest-plugin", UIManifest{ + Name: "manifest-plugin", Version: "9.9.9", + }, nil) + _ = mgr.LoadPlugin("manifest-plugin") + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/ui/manifest-plugin/manifest", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } +} + +func TestUIPluginHandler_GetManifest_NotFound(t *testing.T) { + h, _, _ := newTestUIPluginHandler(t) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/ui/ghost/manifest", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", w.Code) + } +} + +func TestUIPluginHandler_ServeAssets(t *testing.T) { + h, mgr, root := newTestUIPluginHandler(t) + makeUIPlugin(t, root, "asset-plugin", UIManifest{Name: "asset-plugin", Version: "1.0.0"}, + map[string]string{"hello.txt": "world"}) + _ = mgr.LoadPlugin("asset-plugin") + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/ui/asset-plugin/assets/hello.txt", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String()) + } + if got := w.Body.String(); got != "world" { + t.Errorf("asset body: want %q, got %q", "world", got) + } +} + +func TestUIPluginHandler_ServeAssets_NotLoaded(t *testing.T) { + h, _, _ := newTestUIPluginHandler(t) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/ui/ghost/assets/file.txt", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", w.Code) + } +} + +// ---- NativePlugin compile-time interface check ---- + +var _ plugin.NativePlugin = (*UIPluginNativePlugin)(nil) From 796876dc7c6b261f82610f97ea47c2c79c73c007 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 22 Feb 2026 06:46:35 -0500 Subject: [PATCH 3/5] fix: add packages:read permission and remove legacy ui_dist copy from CI - Add packages:read permission to all CI jobs so npm can install @gocodealone/workflow-ui from GitHub Packages - Remove legacy mkdir/cp of ui/dist to module/ui_dist (go:embed no longer used for admin UI) Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 954de49a5..17fba6292 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + packages: read jobs: test: @@ -15,6 +16,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + packages: read strategy: matrix: go-version: ['1.26'] @@ -40,7 +42,6 @@ jobs: - name: Build UI assets run: | cd ui && npm ci && npm run build - cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -65,6 +66,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: - name: Check out code @@ -87,7 +89,6 @@ jobs: - name: Build UI assets run: | cd ui && npm ci && npm run build - cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -102,6 +103,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: - name: Check out code @@ -124,7 +126,6 @@ jobs: - name: Build UI assets run: | cd ui && npm ci && npm run build - cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -144,6 +145,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read defaults: run: working-directory: ui @@ -180,6 +182,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: - name: Check out code @@ -202,7 +205,6 @@ jobs: - name: Build UI assets run: | cd ui && npm ci && npm run build - cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 7c9e5100baa28fffc943a960777fa818d0a44609 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 22 Feb 2026 06:59:52 -0500 Subject: [PATCH 4/5] fix: address audit findings from Phase 1-3 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove stub pre-release job from release.yml (MAJOR) - Add wfctl binary builds to release workflow - Fix ci.yml test job permissions (contents:write → contents:read) - Document secrets package type leak in API stability contract - Fix stale module/ui_dist reference in BUILDING_APPS_GUIDE.md - Add .gitignore templates for plugin and ui-plugin scaffolds - Document CSS validation limitation in UI contract Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 25 +++++-------------- cmd/wfctl/init.go | 2 ++ cmd/wfctl/templates/plugin/.gitignore.tmpl | 15 +++++++++++ cmd/wfctl/templates/ui-plugin/.gitignore.tmpl | 19 ++++++++++++++ docs/API_STABILITY.md | 2 +- docs/APPLICATION_UI_CONTRACT.md | 6 +++++ docs/BUILDING_APPS_GUIDE.md | 2 +- 8 files changed, 51 insertions(+), 22 deletions(-) create mode 100644 cmd/wfctl/templates/plugin/.gitignore.tmpl create mode 100644 cmd/wfctl/templates/ui-plugin/.gitignore.tmpl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48d151593..049e6f8c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: name: Test (Go ${{ matrix.go-version }}) runs-on: ubuntu-latest permissions: - contents: write + contents: read packages: read strategy: matrix: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 594fae6e1..d20132efc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,6 +169,12 @@ jobs: GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o dist/workflow-darwin-arm64 ./cmd/server GOOS=windows GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/workflow-windows-amd64.exe ./cmd/server + GOOS=linux GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/wfctl-linux-amd64 ./cmd/wfctl + GOOS=linux GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o dist/wfctl-linux-arm64 ./cmd/wfctl + GOOS=darwin GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/wfctl-darwin-amd64 ./cmd/wfctl + GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o dist/wfctl-darwin-arm64 ./cmd/wfctl + GOOS=windows GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/wfctl-windows-amd64.exe ./cmd/wfctl + - name: Download admin UI artifact uses: actions/download-artifact@v4 with: @@ -189,22 +195,3 @@ jobs: prerelease: ${{ contains(github.ref, '-') }} generate_release_notes: true - pre-release: - name: Pre-release Build (main) - runs-on: ubuntu-latest - # This job only runs on tag pushes that look like pre-releases (e.g. v1.2.3-rc.1). - # For snapshot builds on every main merge, see the pre-release-snapshot workflow. - if: contains(github.ref, '-') - needs: [test, build-ui] - - steps: - - name: Check out code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Extract pre-release metadata - run: | - VERSION=${GITHUB_REF#refs/tags/} - echo "VERSION=${VERSION}" >> "$GITHUB_ENV" - echo "Building pre-release: ${VERSION}" diff --git a/cmd/wfctl/init.go b/cmd/wfctl/init.go index ed95f55ae..d64233630 100644 --- a/cmd/wfctl/init.go +++ b/cmd/wfctl/init.go @@ -88,6 +88,7 @@ var projectTemplates = map[string]projectTemplate{ {src: "templates/plugin/main.go.tmpl"}, {src: "templates/plugin/plugin.go.tmpl"}, {src: "templates/plugin/README.md.tmpl"}, + {src: "templates/plugin/.gitignore.tmpl"}, }, }, "ui-plugin": { @@ -98,6 +99,7 @@ var projectTemplates = map[string]projectTemplate{ {src: "templates/ui-plugin/main.go.tmpl"}, {src: "templates/ui-plugin/plugin.go.tmpl"}, {src: "templates/ui-plugin/README.md.tmpl"}, + {src: "templates/ui-plugin/.gitignore.tmpl"}, {src: "templates/ui-plugin/ui/package.json.tmpl", dst: "ui/package.json"}, {src: "templates/ui-plugin/ui/vite.config.ts.tmpl", dst: "ui/vite.config.ts"}, {src: "templates/ui-plugin/ui/index.html.tmpl", dst: "ui/index.html"}, diff --git a/cmd/wfctl/templates/plugin/.gitignore.tmpl b/cmd/wfctl/templates/plugin/.gitignore.tmpl new file mode 100644 index 000000000..8aa110135 --- /dev/null +++ b/cmd/wfctl/templates/plugin/.gitignore.tmpl @@ -0,0 +1,15 @@ +# Binaries +*.exe +*.plugin +/{{.Name}} + +# Go +vendor/ + +# IDE +.idea/ +.vscode/ +*.swp + +# OS +.DS_Store diff --git a/cmd/wfctl/templates/ui-plugin/.gitignore.tmpl b/cmd/wfctl/templates/ui-plugin/.gitignore.tmpl new file mode 100644 index 000000000..03aa56ca4 --- /dev/null +++ b/cmd/wfctl/templates/ui-plugin/.gitignore.tmpl @@ -0,0 +1,19 @@ +# Binaries +*.exe +*.plugin +/{{.Name}} + +# Go +vendor/ + +# Node +node_modules/ +ui/dist/ + +# IDE +.idea/ +.vscode/ +*.swp + +# OS +.DS_Store diff --git a/docs/API_STABILITY.md b/docs/API_STABILITY.md index df0970db7..f89ec5193 100644 --- a/docs/API_STABILITY.md +++ b/docs/API_STABILITY.md @@ -319,7 +319,7 @@ The following packages are **not part of the stable public API**. They may chang | `github.com/GoCodeAlone/workflow/ai/copilot` | GitHub Copilot SDK integration. Technical Preview. | | `github.com/GoCodeAlone/workflow/admin` | Admin server internals. Not designed for external embedding. | | `github.com/GoCodeAlone/workflow/mock` | Test helpers. Not stable; only for use in the repo's own test suite. | -| `github.com/GoCodeAlone/workflow/secrets` | Secrets resolver internals. Accessed via `StdEngine.SecretsResolver()` only. | +| `github.com/GoCodeAlone/workflow/secrets` | Secrets resolver internals. Accessed via `StdEngine.SecretsResolver()` only. Note: `StdEngine.SecretsResolver()` returns `*secrets.MultiResolver`. Callers should treat this type as opaque and not depend on its internal methods. A future version may replace this with a public interface. | | `github.com/GoCodeAlone/workflow/plugin/external` | gRPC-based external plugin protocol. Wire format may change. | | `github.com/GoCodeAlone/workflow/plugin/rbac` | RBAC plugin internals. Use the `plugin.EnginePlugin` interface instead. | | `github.com/GoCodeAlone/workflow/plugin/sdk` | Plugin scaffolding/generation tools. Internal CLI tooling only. | diff --git a/docs/APPLICATION_UI_CONTRACT.md b/docs/APPLICATION_UI_CONTRACT.md index 645a6780c..41f2be115 100644 --- a/docs/APPLICATION_UI_CONTRACT.md +++ b/docs/APPLICATION_UI_CONTRACT.md @@ -50,6 +50,12 @@ A valid build **must** produce: The `wfctl build-ui --validate` command checks these requirements without running a build, which is useful in CI to verify artifacts. +> **Note:** `wfctl build-ui --validate` requires at least one `.css` file in +> `dist/assets/`. CSS-in-JS frameworks that do not emit separate CSS files +> (e.g. styled-components or Emotion without extraction) should either configure +> their build to extract CSS into a separate file, or use `--validate=false` +> with manual verification of the build output. + ### Building with `wfctl build-ui` ```bash diff --git a/docs/BUILDING_APPS_GUIDE.md b/docs/BUILDING_APPS_GUIDE.md index 4fad7c4a5..9bc6e60b3 100644 --- a/docs/BUILDING_APPS_GUIDE.md +++ b/docs/BUILDING_APPS_GUIDE.md @@ -1306,7 +1306,7 @@ The Workflow engine includes an embedded React-based visual builder for designin ### Accessing the UI -When running the server with the admin configuration, the visual builder is available at the root URL (typically `http://localhost:8080/`). The UI is embedded in the server binary from the `module/ui_dist/` directory. +When running the server with the admin configuration, the visual builder is available at the root URL (typically `http://localhost:8080/`). The UI is built as a separate artifact and served via the `static.fileserver` module configured in the admin YAML config. The admin UI is no longer embedded via `go:embed`. ### Adding Modules from the Palette From cfcceec11f828a1cb89efa440f2e5e4249a7f376 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 22 Feb 2026 07:01:35 -0500 Subject: [PATCH 5/5] fix: update package-lock.json with @gocodealone/workflow-ui Regenerate lock file to include the workflow-ui shared library dependency, fixing npm ci failures in CI. Co-Authored-By: Claude Opus 4.6 --- ui/package-lock.json | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 0e01d4527..1c31e53fd 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,6 +8,7 @@ "name": "ui", "version": "0.0.0", "dependencies": { + "@gocodealone/workflow-ui": "^0.1.0", "@types/dagre": "^0.7.53", "@xyflow/react": "^12.10.0", "dagre": "^0.8.5", @@ -140,7 +141,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -490,7 +490,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -531,7 +530,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1153,6 +1151,17 @@ } } }, + "node_modules/@gocodealone/workflow-ui": { + "version": "0.1.0", + "resolved": "https://npm.pkg.github.com/download/@gocodealone/workflow-ui/0.1.0/47fe64515cf7913e2f2190ec819f895dc8da8b0b", + "integrity": "sha512-N4RalEllW2rLHCYKfBbHZWk1LyEPHFLQh+tFGEKj8wwfyCiEykg+YqRr77/5gtRu/AoIEDRqvMqsntOZq0jkVA==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "zustand": "^4.0.0 || ^5.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1730,7 +1739,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1887,7 +1897,6 @@ "integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1898,7 +1907,6 @@ "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1909,7 +1917,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1959,7 +1966,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -2395,7 +2401,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2446,6 +2451,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2550,7 +2556,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2796,7 +2801,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -2916,7 +2920,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-to-chromium": { "version": "1.5.286", @@ -3016,7 +3021,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3563,7 +3567,6 @@ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", @@ -3737,6 +3740,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -3955,7 +3959,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4055,6 +4058,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4070,6 +4074,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -4108,7 +4113,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4121,7 +4125,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -4503,7 +4508,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4616,7 +4620,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4898,7 +4901,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }