From 24595bd4b9f79b1e430291f2cd7613838ee6ff2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:37:45 +0000 Subject: [PATCH 1/5] Initial plan From e1c337cb9b6c8c94d07d929a2ac3b26336e4ecd3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:55:32 +0000 Subject: [PATCH 2/5] Add external plugin support and snake_case/camelCase detection to validator Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/compat.go | 10 +- cmd/wfctl/contract.go | 30 ++--- cmd/wfctl/diff.go | 2 +- cmd/wfctl/main_test.go | 59 ++++++++++ cmd/wfctl/multi_registry_test.go | 6 +- cmd/wfctl/registry.go | 28 ++--- cmd/wfctl/registry_validate.go | 1 - cmd/wfctl/template_validate.go | 56 ++++++--- cmd/wfctl/template_validate_test.go | 99 ++++++++++++++++ cmd/wfctl/type_registry.go | 33 +++++- cmd/wfctl/validate.go | 17 ++- schema/module_schema.go | 4 +- schema/schema.go | 53 +++++++++ schema/schema_test.go | 170 ++++++++++++++++++++++++++++ schema/validate.go | 49 +++++++- 15 files changed, 556 insertions(+), 61 deletions(-) diff --git a/cmd/wfctl/compat.go b/cmd/wfctl/compat.go index 4d8421ca..6e19af1d 100644 --- a/cmd/wfctl/compat.go +++ b/cmd/wfctl/compat.go @@ -21,11 +21,11 @@ type CompatibilityInfo struct { // compatCheckResult holds the result of a compatibility check. type compatCheckResult struct { - EngineVersion string `json:"engineVersion"` - RequiredModules []compatItem `json:"requiredModules"` - RequiredSteps []compatItem `json:"requiredSteps"` - Compatible bool `json:"compatible"` - Issues []string `json:"issues,omitempty"` + EngineVersion string `json:"engineVersion"` + RequiredModules []compatItem `json:"requiredModules"` + RequiredSteps []compatItem `json:"requiredSteps"` + Compatible bool `json:"compatible"` + Issues []string `json:"issues,omitempty"` } // compatItem represents a single required type and whether it's available. diff --git a/cmd/wfctl/contract.go b/cmd/wfctl/contract.go index 48df0096..dc059d8a 100644 --- a/cmd/wfctl/contract.go +++ b/cmd/wfctl/contract.go @@ -15,9 +15,9 @@ import ( // Contract is a snapshot of what a workflow application config exposes. type Contract struct { - Version string `json:"version"` - ConfigHash string `json:"configHash"` - GeneratedAt string `json:"generatedAt"` + Version string `json:"version"` + ConfigHash string `json:"configHash"` + GeneratedAt string `json:"generatedAt"` Endpoints []EndpointContract `json:"endpoints"` Modules []ModuleContract `json:"modules"` Steps []string `json:"steps"` @@ -59,25 +59,25 @@ type contractComparison struct { type changeType string const ( - changeAdded changeType = "ADDED" - changeRemoved changeType = "REMOVED" - changeChanged changeType = "CHANGED" + changeAdded changeType = "ADDED" + changeRemoved changeType = "REMOVED" + changeChanged changeType = "CHANGED" changeUnchanged changeType = "UNCHANGED" ) type endpointChange struct { - Method string - Path string - Pipeline string - Change changeType - Detail string - IsBreaking bool + Method string + Path string + Pipeline string + Change changeType + Detail string + IsBreaking bool } type moduleChange struct { - Name string - Type string - Change changeType + Name string + Type string + Change changeType } type eventChange struct { diff --git a/cmd/wfctl/diff.go b/cmd/wfctl/diff.go index d0cf4cf2..a3c1c165 100644 --- a/cmd/wfctl/diff.go +++ b/cmd/wfctl/diff.go @@ -112,7 +112,7 @@ type PipelineDiff struct { // BreakingChangeSummary aggregates breaking-change warnings across the diff. type BreakingChangeSummary struct { - ModuleName string `json:"moduleName"` + ModuleName string `json:"moduleName"` Changes []BreakingChange `json:"changes"` } diff --git a/cmd/wfctl/main_test.go b/cmd/wfctl/main_test.go index 34823b31..ffe4f005 100644 --- a/cmd/wfctl/main_test.go +++ b/cmd/wfctl/main_test.go @@ -185,6 +185,33 @@ modules: } } +func TestRunValidateSnakeCaseConfig(t *testing.T) { + dir := t.TempDir() + // "content_type" is the snake_case form of the known camelCase field "contentType" + snakeCaseConfig := ` +modules: + - name: handler + type: http.handler + config: + content_type: "application/json" +triggers: + http: + port: 8080 +` + path := writeTestConfig(t, dir, "snake.yaml", snakeCaseConfig) + // validateFile returns the detailed error; runValidate returns a summary + err := validateFile(path, false, false, false) + if err == nil { + t.Fatal("expected error for snake_case config field") + } + if !strings.Contains(err.Error(), "content_type") { + t.Errorf("expected error to mention snake_case field 'content_type', got: %v", err) + } + if !strings.Contains(err.Error(), "contentType") { + t.Errorf("expected error to suggest camelCase 'contentType', got: %v", err) + } +} + func TestRunPluginMissingSubcommand(t *testing.T) { err := runPlugin([]string{}) if err == nil { @@ -255,3 +282,35 @@ func TestRunPluginDocsMissingDir(t *testing.T) { t.Fatal("expected error when no directory given") } } + +func TestRunValidatePluginDir(t *testing.T) { + // Create a fake external plugin directory with a plugin.json declaring a custom module type. + pluginsDir := t.TempDir() + pluginSubdir := filepath.Join(pluginsDir, "my-ext-plugin") + if err := os.MkdirAll(pluginSubdir, 0755); err != nil { + t.Fatal(err) + } + manifest := `{"moduleTypes": ["custom.ext.validate.testonly"]}` + if err := os.WriteFile(filepath.Join(pluginSubdir, "plugin.json"), []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + // Config using the external plugin module type + dir := t.TempDir() + configContent := ` +modules: + - name: ext-mod + type: custom.ext.validate.testonly +` + path := writeTestConfig(t, dir, "workflow.yaml", configContent) + + // Without --plugin-dir: should fail (unknown type) + if err := runValidate([]string{path}); err == nil { + t.Fatal("expected error for unknown external module type without --plugin-dir") + } + + // With --plugin-dir: should pass + if err := runValidate([]string{"--plugin-dir", pluginsDir, path}); err != nil { + t.Errorf("expected valid config with --plugin-dir, got: %v", err) + } +} diff --git a/cmd/wfctl/multi_registry_test.go b/cmd/wfctl/multi_registry_test.go index 8ddefebc..4bb3187d 100644 --- a/cmd/wfctl/multi_registry_test.go +++ b/cmd/wfctl/multi_registry_test.go @@ -307,7 +307,7 @@ func TestMultiRegistryFetchPriority(t *testing.T) { func TestMultiRegistryFetchFallback(t *testing.T) { // Source A errors for "unique-plugin", source B has it. srcA := &mockRegistrySource{ - name: "primary", + name: "primary", manifests: map[string]*RegistryManifest{}, fetchErr: map[string]error{ "unique-plugin": fmt.Errorf("not found"), @@ -439,14 +439,14 @@ func TestMultiRegistryListDedup(t *testing.T) { srcA := &mockRegistrySource{ name: "primary", manifests: map[string]*RegistryManifest{ - "shared": {Name: "shared"}, + "shared": {Name: "shared"}, "only-in-a": {Name: "only-in-a"}, }, } srcB := &mockRegistrySource{ name: "secondary", manifests: map[string]*RegistryManifest{ - "shared": {Name: "shared"}, + "shared": {Name: "shared"}, "only-in-b": {Name: "only-in-b"}, }, } diff --git a/cmd/wfctl/registry.go b/cmd/wfctl/registry.go index 0ca8bec3..13cc4eff 100644 --- a/cmd/wfctl/registry.go +++ b/cmd/wfctl/registry.go @@ -17,21 +17,21 @@ const ( // RegistryManifest is the manifest format for the GoCodeAlone/workflow-registry. type RegistryManifest struct { - Name string `json:"name"` - Version string `json:"version"` - Author string `json:"author"` - Description string `json:"description"` - Source string `json:"source,omitempty"` - Type string `json:"type"` - Tier string `json:"tier"` - License string `json:"license"` - MinEngineVersion string `json:"minEngineVersion,omitempty"` - Repository string `json:"repository,omitempty"` - Keywords []string `json:"keywords,omitempty"` - Homepage string `json:"homepage,omitempty"` + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Source string `json:"source,omitempty"` + Type string `json:"type"` + Tier string `json:"tier"` + License string `json:"license"` + MinEngineVersion string `json:"minEngineVersion,omitempty"` + Repository string `json:"repository,omitempty"` + Keywords []string `json:"keywords,omitempty"` + Homepage string `json:"homepage,omitempty"` Capabilities *RegistryCapabilities `json:"capabilities,omitempty"` - Downloads []PluginDownload `json:"downloads,omitempty"` - Assets *PluginAssets `json:"assets,omitempty"` + Downloads []PluginDownload `json:"downloads,omitempty"` + Assets *PluginAssets `json:"assets,omitempty"` } // RegistryCapabilities describes what module/step/trigger types a plugin provides. diff --git a/cmd/wfctl/registry_validate.go b/cmd/wfctl/registry_validate.go index 5ab43fdb..9642eafd 100644 --- a/cmd/wfctl/registry_validate.go +++ b/cmd/wfctl/registry_validate.go @@ -177,4 +177,3 @@ func FormatValidationErrors(errs []ValidationError) string { } return b.String() } - diff --git a/cmd/wfctl/template_validate.go b/cmd/wfctl/template_validate.go index 41b79718..6c121c8b 100644 --- a/cmd/wfctl/template_validate.go +++ b/cmd/wfctl/template_validate.go @@ -11,22 +11,23 @@ import ( "text/template" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/schema" "gopkg.in/yaml.v3" ) // templateValidationResult holds the outcome of validating a single template. type templateValidationResult struct { - Name string - ModuleCount int - ModuleValid int - StepCount int - StepValid int - DepCount int - DepValid int - TriggerCount int - TriggerValid int - Warnings []string - Errors []string + Name string + ModuleCount int + ModuleValid int + StepCount int + StepValid int + DepCount int + DepValid int + TriggerCount int + TriggerValid int + Warnings []string + Errors []string } // pass returns true if there are no errors. @@ -77,6 +78,7 @@ func runTemplateValidate(args []string) error { configFile := fs2.String("config", "", "Validate a specific config file instead of templates") strict := fs2.Bool("strict", false, "Fail on warnings (not just errors)") format := fs2.String("format", "text", "Output format: text or json") + pluginDir := fs2.String("plugin-dir", "", "Directory of installed external plugins; their types are loaded before validation") fs2.Usage = func() { fmt.Fprintf(fs2.Output(), `Usage: wfctl template validate [options] @@ -90,6 +92,14 @@ Options: return err } + // Load external plugin types before validation so their module/trigger/workflow + // types are recognised and don't cause false "unknown type" errors. + if *pluginDir != "" { + if err := schema.LoadPluginTypesFromDir(*pluginDir); err != nil { + return fmt.Errorf("failed to load plugins from %s: %w", *pluginDir, err) + } + } + knownModules := KnownModuleTypes() knownSteps := KnownStepTypes() knownTriggers := KnownTriggerTypes() @@ -353,15 +363,23 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule result.Errors = append(result.Errors, fmt.Sprintf("module %q uses unknown type %q", mod.Name, mod.Type)) } else { result.ModuleValid++ - // 5. Warn on unknown config fields + // 5. Warn on unknown config fields (with snake_case hint) if mod.Config != nil && len(info.ConfigKeys) > 0 { knownKeys := make(map[string]bool) + snakeToCamel := make(map[string]string) for _, k := range info.ConfigKeys { knownKeys[k] = true + if snake := schema.CamelToSnake(k); snake != k { + snakeToCamel[snake] = k + } } for key := range mod.Config { if !knownKeys[key] { - result.Warnings = append(result.Warnings, fmt.Sprintf("module %q (%s) config field %q not in known fields", mod.Name, mod.Type, key)) + if camel, ok := snakeToCamel[key]; ok { + result.Warnings = append(result.Warnings, fmt.Sprintf("module %q (%s) config field %q uses snake_case; use camelCase %q instead", mod.Name, mod.Type, key, camel)) + } else { + result.Warnings = append(result.Warnings, fmt.Sprintf("module %q (%s) config field %q not in known fields", mod.Name, mod.Type, key)) + } } } } @@ -401,15 +419,23 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule result.Errors = append(result.Errors, fmt.Sprintf("pipeline %q step uses unknown type %q", pipelineName, stepType)) } else { result.StepValid++ - // Config key warnings + // Config key warnings (with snake_case hint) if stepCfg, ok := stepMap["config"].(map[string]any); ok && len(stepInfo.ConfigKeys) > 0 { knownKeys := make(map[string]bool) + snakeToCamel := make(map[string]string) for _, k := range stepInfo.ConfigKeys { knownKeys[k] = true + if snake := schema.CamelToSnake(k); snake != k { + snakeToCamel[snake] = k + } } for key := range stepCfg { if !knownKeys[key] { - result.Warnings = append(result.Warnings, fmt.Sprintf("pipeline %q step %q (%s) config field %q not in known fields", pipelineName, stepMap["name"], stepType, key)) + if camel, ok := snakeToCamel[key]; ok { + result.Warnings = append(result.Warnings, fmt.Sprintf("pipeline %q step %q (%s) config field %q uses snake_case; use camelCase %q instead", pipelineName, stepMap["name"], stepType, key, camel)) + } else { + result.Warnings = append(result.Warnings, fmt.Sprintf("pipeline %q step %q (%s) config field %q not in known fields", pipelineName, stepMap["name"], stepType, key)) + } } } } diff --git a/cmd/wfctl/template_validate_test.go b/cmd/wfctl/template_validate_test.go index 59bfd0f5..17d9cfaf 100644 --- a/cmd/wfctl/template_validate_test.go +++ b/cmd/wfctl/template_validate_test.go @@ -235,3 +235,102 @@ func TestRunTemplateUnknownSubcommand(t *testing.T) { t.Fatal("expected error for unknown subcommand") } } + +func TestValidateWorkflowConfig_SnakeCaseModuleField_Warning(t *testing.T) { + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "server", Type: "http.server", Config: map[string]any{ + // snake_case form of "readTimeout" (known field) + "read_timeout": "30s", + // correct key + "address": ":8080", + }}, + }, + } + knownModules := KnownModuleTypes() + knownSteps := KnownStepTypes() + knownTriggers := KnownTriggerTypes() + + result := validateWorkflowConfig("test", cfg, knownModules, knownSteps, knownTriggers) + found := false + for _, w := range result.Warnings { + if strings.Contains(w, "read_timeout") && strings.Contains(w, "readTimeout") { + found = true + break + } + } + if !found { + t.Errorf("expected warning for snake_case field 'read_timeout' suggesting 'readTimeout', got warnings: %v", result.Warnings) + } +} + +func TestValidateWorkflowConfig_SnakeCaseStepField_Warning(t *testing.T) { + cfg := &config.WorkflowConfig{ + Pipelines: map[string]any{ + "my-pipeline": map[string]any{ + "trigger": map[string]any{"type": "http"}, + "steps": []any{ + map[string]any{ + "name": "my-step", + "type": "step.http_call", + "config": map[string]any{ + // snake_case form of a known camelCase step config key + "target_url": "http://example.com", + }, + }, + }, + }, + }, + } + knownModules := KnownModuleTypes() + knownSteps := KnownStepTypes() + knownTriggers := KnownTriggerTypes() + + result := validateWorkflowConfig("test", cfg, knownModules, knownSteps, knownTriggers) + found := false + for _, w := range result.Warnings { + if strings.Contains(w, "target_url") { + found = true + break + } + } + if !found { + t.Errorf("expected warning for snake_case step config field 'target_url', got warnings: %v", result.Warnings) + } +} + +func TestRunTemplateValidatePluginDir(t *testing.T) { + pluginsDir := t.TempDir() + // Create a fake plugin with a custom module type + pluginSubdir := filepath.Join(pluginsDir, "my-external-plugin") + if err := os.MkdirAll(pluginSubdir, 0755); err != nil { + t.Fatal(err) + } + manifest := `{"moduleTypes": ["custom.external.module"]}` + if err := os.WriteFile(filepath.Join(pluginSubdir, "plugin.json"), []byte(manifest), 0644); err != nil { + t.Fatal(err) + } + + // Config that uses the external plugin module type + dir := t.TempDir() + configContent := ` +modules: + - name: ext-mod + type: custom.external.module +` + configPath := filepath.Join(dir, "workflow.yaml") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + // Without --plugin-dir: should fail with unknown type + err := runTemplateValidate([]string{"-config", configPath}) + if err == nil { + t.Fatal("expected error for unknown external module type without --plugin-dir") + } + + // With --plugin-dir: should pass + if err := runTemplateValidate([]string{"-plugin-dir", pluginsDir, "-config", configPath}); err != nil { + t.Errorf("expected valid config with --plugin-dir, got: %v", err) + } +} diff --git a/cmd/wfctl/type_registry.go b/cmd/wfctl/type_registry.go index 272d4640..c3dfe207 100644 --- a/cmd/wfctl/type_registry.go +++ b/cmd/wfctl/type_registry.go @@ -1,5 +1,11 @@ package main +import ( + "strings" + + "github.com/GoCodeAlone/workflow/schema" +) + // ModuleTypeInfo holds metadata about a known module type. type ModuleTypeInfo struct { Type string // e.g., "storage.sqlite" @@ -17,7 +23,7 @@ type StepTypeInfo struct { // KnownModuleTypes returns all module types registered in the engine's plugins. func KnownModuleTypes() map[string]ModuleTypeInfo { - return map[string]ModuleTypeInfo{ + m := map[string]ModuleTypeInfo{ // storage plugin "storage.s3": { Type: "storage.s3", @@ -494,11 +500,18 @@ func KnownModuleTypes() map[string]ModuleTypeInfo { ConfigKeys: []string{"account", "provider", "name", "region", "image", "instances", "http_port", "envs"}, }, } + // Include any types registered dynamically (e.g. from external plugins loaded via LoadPluginTypesFromDir). + for _, t := range schema.KnownModuleTypes() { + if _, exists := m[t]; !exists { + m[t] = ModuleTypeInfo{Type: t, Plugin: "external"} + } + } + return m } // KnownStepTypes returns all step types registered in the engine's plugins. func KnownStepTypes() map[string]StepTypeInfo { - return map[string]StepTypeInfo{ + m := map[string]StepTypeInfo{ // pipelinesteps plugin "step.validate": { Type: "step.validate", @@ -1080,15 +1093,29 @@ func KnownStepTypes() map[string]StepTypeInfo { ConfigKeys: []string{"app"}, }, } + // Include any step types registered dynamically (e.g. from external plugins). + for _, t := range schema.KnownModuleTypes() { + if strings.HasPrefix(t, "step.") { + if _, exists := m[t]; !exists { + m[t] = StepTypeInfo{Type: t, Plugin: "external"} + } + } + } + return m } // KnownTriggerTypes returns all known trigger types. func KnownTriggerTypes() map[string]bool { - return map[string]bool{ + m := map[string]bool{ "http": true, "event": true, "eventbus": true, "schedule": true, "reconciliation": true, } + // Include any trigger types registered dynamically (e.g. from external plugins). + for _, t := range schema.KnownTriggerTypes() { + m[t] = true + } + return m } diff --git a/cmd/wfctl/validate.go b/cmd/wfctl/validate.go index 2cb91b69..e3e3d766 100644 --- a/cmd/wfctl/validate.go +++ b/cmd/wfctl/validate.go @@ -17,6 +17,7 @@ func runValidate(args []string) error { skipUnknownTypes := fs.Bool("skip-unknown-types", false, "Skip unknown module/workflow/trigger type checks") allowNoEntryPoints := fs.Bool("allow-no-entry-points", false, "Allow configs with no entry points (triggers, routes, subscriptions, jobs)") dir := fs.String("dir", "", "Validate all .yaml/.yml files in a directory (recursive)") + pluginDir := fs.String("plugin-dir", "", "Directory of installed external plugins; their types are loaded before validation") fs.Usage = func() { fmt.Fprintf(fs.Output(), `Usage: wfctl validate [options] [config2.yaml ...] @@ -28,6 +29,7 @@ Examples: wfctl validate --dir ./example/ wfctl validate --strict admin/config.yaml wfctl validate --skip-unknown-types example/*.yaml + wfctl validate --plugin-dir data/plugins config.yaml Options: `) @@ -42,6 +44,14 @@ Options: return err } + // Load external plugin types before validation so their module/trigger/workflow + // types are recognised and don't cause false "unknown type" errors. + if *pluginDir != "" { + if err := schema.LoadPluginTypesFromDir(*pluginDir); err != nil { + return fmt.Errorf("failed to load plugins from %s: %w", *pluginDir, err) + } + } + // Collect files to validate var files []string @@ -180,6 +190,11 @@ func indentError(err error) string { // correctly regardless of where the user places them. func reorderFlags(args []string) []string { var flags, positional []string + // flags that take a value argument (not self-contained with "=") + valueFlagNames := map[string]bool{ + "dir": true, + "plugin-dir": true, + } for i := 0; i < len(args); i++ { if strings.HasPrefix(args[i], "-") { flags = append(flags, args[i]) @@ -189,7 +204,7 @@ func reorderFlags(args []string) []string { // Peek: could be a flag value or a positional arg. // Only consume it if the flag is known to take a value. flagName := strings.TrimLeft(args[i], "-") - if flagName == "dir" { + if valueFlagNames[flagName] { i++ flags = append(flags, args[i]) } diff --git a/schema/module_schema.go b/schema/module_schema.go index f5094a7f..13b960c1 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -1016,7 +1016,7 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { Category: "pipeline", Description: "Decodes base64-encoded content (raw or data-URI), validates MIME type and size, and returns structured metadata", Inputs: []ServiceIODef{{Name: "context", Type: "PipelineContext", Description: "Pipeline context containing the encoded data at the path specified by input_from"}}, - Outputs: []ServiceIODef{{Name: "result", Type: "StepResult", Description: "Decoded content metadata: content_type, extension, size_bytes, data (base64), valid, reason (on failure)"}}, + Outputs: []ServiceIODef{{Name: "result", Type: "StepResult", Description: "Decoded content metadata: content_type, extension, size_bytes, data (base64), valid, reason (on failure)"}}, ConfigFields: []ConfigFieldDef{ {Key: "input_from", Label: "Input From", Type: FieldTypeString, Required: true, Description: "Dotted path to the encoded data in the pipeline context (e.g., steps.upload.file_data)", Placeholder: "steps.upload.file_data"}, {Key: "format", Label: "Format", Type: FieldTypeSelect, Options: []string{"data_uri", "raw_base64"}, DefaultValue: "data_uri", Description: "Encoding format: 'data_uri' expects a data:mime/type;base64,... string; 'raw_base64' expects plain base64"}, @@ -1027,7 +1027,7 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { }) r.Register(&ModuleSchema{ - Type: "step.s3_upload", + Type: "step.s3_upload", Label: "S3 Upload", Category: "pipeline", Description: "Uploads base64-encoded binary data from the pipeline context to AWS S3 or S3-compatible storage (MinIO, LocalStack). Returns the public URL, resolved object key, and bucket name.", diff --git a/schema/schema.go b/schema/schema.go index f6f312ba..3dc757b1 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -4,6 +4,10 @@ package schema import ( + "encoding/json" + "fmt" + "os" + "path/filepath" "sort" "sync" ) @@ -318,6 +322,55 @@ func KnownWorkflowTypes() []string { return result } +// pluginManifestTypes holds the type declarations from a plugin.json manifest. +// This is a minimal subset of the full plugin manifest to avoid import cycles. +type pluginManifestTypes struct { + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + WorkflowTypes []string `json:"workflowTypes"` +} + +// LoadPluginTypesFromDir scans pluginDir for subdirectories containing a +// plugin.json manifest, reads each manifest's type declarations, and registers +// them with the schema package so that they appear in all type listings and +// pass validation. Unknown or malformed manifests are silently skipped. +// Returns an error only if pluginDir cannot be read at all. +func LoadPluginTypesFromDir(pluginDir string) error { + entries, err := os.ReadDir(pluginDir) + if err != nil { + return fmt.Errorf("read plugin dir %q: %w", pluginDir, err) + } + for _, e := range entries { + if !e.IsDir() { + continue + } + manifestPath := filepath.Join(pluginDir, e.Name(), "plugin.json") + data, err := os.ReadFile(manifestPath) //nolint:gosec // G304: path is within the trusted plugins directory + if err != nil { + continue + } + var m pluginManifestTypes + if err := json.Unmarshal(data, &m); err != nil { + continue + } + for _, t := range m.ModuleTypes { + RegisterModuleType(t) + } + for _, t := range m.StepTypes { + // Step types share the module type registry (identified by "step." prefix). + RegisterModuleType(t) + } + for _, t := range m.TriggerTypes { + RegisterTriggerType(t) + } + for _, t := range m.WorkflowTypes { + RegisterWorkflowType(t) + } + } + return nil +} + // GenerateWorkflowSchema produces the full JSON Schema describing a valid // WorkflowConfig YAML file. func GenerateWorkflowSchema() *Schema { diff --git a/schema/schema_test.go b/schema/schema_test.go index c8e9b150..81b05326 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -2,6 +2,7 @@ package schema import ( "encoding/json" + "os" "slices" "strings" "testing" @@ -781,3 +782,172 @@ func assertContains(t *testing.T, s, substr string) { t.Errorf("expected %q to contain %q", s, substr) } } + +// --------------------------------------------------------------------------- +// CamelToSnake tests +// --------------------------------------------------------------------------- + +func TestCamelToSnake(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"contentType", "content_type"}, + {"dbPath", "db_path"}, + {"maxConnections", "max_connections"}, + {"address", "address"}, + {"rootDir", "root_dir"}, + {"spaFallback", "spa_fallback"}, + {"webhookURL", "webhook_u_r_l"}, // consecutive caps — each uppercase letter gets underscore + {"already_snake", "already_snake"}, + } + for _, c := range cases { + got := CamelToSnake(c.input) + if got != c.want { + t.Errorf("CamelToSnake(%q) = %q, want %q", c.input, got, c.want) + } + } +} + +// --------------------------------------------------------------------------- +// Snake_case config field detection tests +// --------------------------------------------------------------------------- + +func TestValidateConfig_SnakeCaseConfigField_Error(t *testing.T) { + // "content_type" is the snake_case form of the known camelCase key "contentType" + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "h", Type: "http.handler", Config: map[string]any{ + "content_type": "application/json", + }}, + }, + Triggers: map[string]any{"http": map[string]any{}}, + } + err := ValidateConfig(cfg) + if err == nil { + t.Fatal("expected error for snake_case config field") + } + assertContains(t, err.Error(), "content_type") + assertContains(t, err.Error(), "contentType") +} + +func TestValidateConfig_SnakeCaseRequiredField_Hint(t *testing.T) { + // "db_path" is the snake_case form of the required key "dbPath" for storage.sqlite + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "db", Type: "storage.sqlite", Config: map[string]any{ + "db_path": "data/test.db", + }}, + }, + Triggers: map[string]any{"http": map[string]any{}}, + } + err := ValidateConfig(cfg) + if err == nil { + t.Fatal("expected error for missing required field with snake_case hint") + } + // Should mention both the snake_case hint and that camelCase should be used + assertContains(t, err.Error(), "db_path") +} + +func TestValidateConfig_CorrectCamelCase_Valid(t *testing.T) { + // Using the correct camelCase key should produce no snake_case error + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "h", Type: "http.handler", Config: map[string]any{ + "contentType": "application/json", + }}, + }, + Triggers: map[string]any{"http": map[string]any{}}, + } + if err := ValidateConfig(cfg); err != nil { + t.Errorf("expected valid config with camelCase key, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// LoadPluginTypesFromDir tests +// --------------------------------------------------------------------------- + +func TestLoadPluginTypesFromDir_NonexistentDir(t *testing.T) { + err := LoadPluginTypesFromDir("/nonexistent/path") + if err == nil { + t.Fatal("expected error for nonexistent directory") + } +} + +func TestLoadPluginTypesFromDir_Empty(t *testing.T) { + dir := t.TempDir() + if err := LoadPluginTypesFromDir(dir); err != nil { + t.Errorf("expected no error for empty directory, got: %v", err) + } +} + +func TestLoadPluginTypesFromDir_RegistersTypes(t *testing.T) { + const customModuleType = "external.plugin.module.testonly" + const customTriggerType = "external.trigger.testonly" + const customWorkflowType = "external.workflow.testonly" + + // Cleanup after test + t.Cleanup(func() { + UnregisterModuleType(customModuleType) + UnregisterTriggerType(customTriggerType) + UnregisterWorkflowType(customWorkflowType) + }) + + dir := t.TempDir() + // Create a fake plugin subdirectory with plugin.json + pluginDir := dir + "/my-plugin" + if err := makeDir(pluginDir); err != nil { + t.Fatal(err) + } + manifest := `{ + "moduleTypes": ["` + customModuleType + `"], + "stepTypes": [], + "triggerTypes": ["` + customTriggerType + `"], + "workflowTypes": ["` + customWorkflowType + `"] + }` + if err := writeFile(pluginDir+"/plugin.json", []byte(manifest)); err != nil { + t.Fatal(err) + } + + if err := LoadPluginTypesFromDir(dir); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The module type should now be recognized + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "ext", Type: customModuleType}, + }, + Triggers: map[string]any{ + customTriggerType: map[string]any{}, + }, + } + if err := ValidateConfig(cfg, WithExtraWorkflowTypes(customWorkflowType)); err != nil { + t.Errorf("expected plugin types to be recognized after LoadPluginTypesFromDir, got: %v", err) + } +} + +func TestLoadPluginTypesFromDir_MalformedManifest(t *testing.T) { + dir := t.TempDir() + pluginDir := dir + "/bad-plugin" + if err := makeDir(pluginDir); err != nil { + t.Fatal(err) + } + // Write invalid JSON + if err := writeFile(pluginDir+"/plugin.json", []byte("not json")); err != nil { + t.Fatal(err) + } + // Should silently skip and not return error + if err := LoadPluginTypesFromDir(dir); err != nil { + t.Errorf("expected malformed manifest to be silently skipped, got: %v", err) + } +} + +func makeDir(path string) error { + return os.MkdirAll(path, 0755) +} + +func writeFile(path string, data []byte) error { + return os.WriteFile(path, data, 0644) +} diff --git a/schema/validate.go b/schema/validate.go index 533a0d42..9aa31106 100644 --- a/schema/validate.go +++ b/schema/validate.go @@ -3,6 +3,7 @@ package schema import ( "fmt" "strings" + "unicode" "github.com/GoCodeAlone/workflow/config" ) @@ -256,6 +257,26 @@ func validateModuleConfig(mod config.ModuleConfig, prefix string, errs *Validati // Schema-driven validation for required fields s := schemaRegistry.Get(mod.Type) if s != nil { + // Build snake_case → camelCase mapping for "did you mean" hints. + snakeToCamel := make(map[string]string, len(s.ConfigFields)) + for i := range s.ConfigFields { + if snake := camelToSnake(s.ConfigFields[i].Key); snake != s.ConfigFields[i].Key { + snakeToCamel[snake] = s.ConfigFields[i].Key + } + } + + // Check each config key for snake_case/camelCase confusion. + if mod.Config != nil { + for key := range mod.Config { + if camel, ok := snakeToCamel[key]; ok { + *errs = append(*errs, &ValidationError{ + Path: prefix + ".config." + key, + Message: fmt.Sprintf("config field %q uses snake_case; use camelCase %q instead", key, camel), + }) + } + } + } + for i := range s.ConfigFields { if !s.ConfigFields[i].Required { continue @@ -270,9 +291,16 @@ func validateModuleConfig(mod config.ModuleConfig, prefix string, errs *Validati } v, ok := mod.Config[s.ConfigFields[i].Key] if !ok { + msg := fmt.Sprintf("required config field %q is missing", s.ConfigFields[i].Key) + // Check if the snake_case form of the required key was provided instead. + if snakeKey := camelToSnake(s.ConfigFields[i].Key); snakeKey != s.ConfigFields[i].Key { + if _, snakeProvided := mod.Config[snakeKey]; snakeProvided { + msg = fmt.Sprintf("required config field %q is missing; found snake_case %q — use camelCase instead", s.ConfigFields[i].Key, snakeKey) + } + } *errs = append(*errs, &ValidationError{ Path: fieldPath, - Message: fmt.Sprintf("required config field %q is missing", s.ConfigFields[i].Key), + Message: msg, }) continue } @@ -397,3 +425,22 @@ func makeSet(items []string) map[string]bool { } return s } + +// camelToSnake converts a camelCase identifier to its snake_case equivalent. +// For example: "contentType" → "content_type", "dbPath" → "db_path". +func camelToSnake(s string) string { + return CamelToSnake(s) +} + +// CamelToSnake converts a camelCase identifier to its snake_case equivalent. +// For example: "contentType" → "content_type", "dbPath" → "db_path". +func CamelToSnake(s string) string { + var b strings.Builder + for i, r := range s { + if i > 0 && unicode.IsUpper(r) { + b.WriteByte('_') + } + b.WriteRune(unicode.ToLower(r)) + } + return b.String() +} From 22c3d4de54393f0e834a358cb13e4da086b38780 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:21:32 +0000 Subject: [PATCH 3/5] Merge origin/main into copilot/add-external-plugins-validation, resolve conflicts keeping all features Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- DOCUMENTATION.md | 115 +++- cmd/wfctl/main.go | 22 + cmd/wfctl/mcp.go | 53 ++ cmd/wfctl/mcp_test.go | 21 + cmd/wfctl/template_validate.go | 147 +++++- cmd/wfctl/template_validate_test.go | 238 +++++++++ cmd/wfctl/update.go | 376 +++++++++++++ cmd/wfctl/update_test.go | 311 +++++++++++ config/config.go | 120 ++++- config/config_import_test.go | 643 +++++++++++++++++++++++ docs/mcp.md | 77 ++- example/go.mod | 23 +- example/go.sum | 70 ++- go.mod | 4 +- go.sum | 10 +- module/pipeline_template.go | 187 ++++++- module/pipeline_template_test.go | 203 +++++++ plugins/modularcompat/jsonschema_test.go | 258 +++++++++ plugins/modularcompat/plugin.go | 18 +- plugins/modularcompat/plugin_test.go | 23 +- schema/module_schema.go | 10 + schema/schema.go | 1 + 22 files changed, 2874 insertions(+), 56 deletions(-) create mode 100644 cmd/wfctl/mcp.go create mode 100644 cmd/wfctl/mcp_test.go create mode 100644 cmd/wfctl/update.go create mode 100644 cmd/wfctl/update_test.go create mode 100644 config/config_import_test.go create mode 100644 plugins/modularcompat/jsonschema_test.go diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index edacaf73..2b7f384d 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -124,12 +124,46 @@ Pipeline steps support Go template syntax with these built-in functions: | Function | Description | Example | |----------|-------------|---------| | `uuidv4` | Generates a UUID v4 | `{{ uuidv4 }}` | -| `now` | Current time in RFC3339 format | `{{ now }}` | +| `now` | Current time (RFC3339 default, or named/custom layout) | `{{ now }}`, `{{ now "DateOnly" }}`, `{{ now "2006-01-02" }}` | | `lower` | Lowercase string | `{{ lower .name }}` | | `default` | Default value when empty | `{{ default "pending" .status }}` | | `json` | Marshal value to JSON string | `{{ json .data }}` | +| `trimPrefix` | Remove prefix from string | `{{ .phone | trimPrefix "+" }}` | +| `trimSuffix` | Remove suffix from string | `{{ .file | trimSuffix ".txt" }}` | +| `step` | Access step outputs by name and keys | `{{ step "parse-request" "path_params" "id" }}` | +| `trigger` | Access trigger data by keys | `{{ trigger "path_params" "id" }}` | -Template expressions can reference previous step outputs via `{{ .steps.step-name.field }}` or for hyphenated names `{{index .steps "step-name" "field"}}`. +#### Template Data Context + +Templates have access to four top-level namespaces: + +| Variable | Source | Description | +|----------|--------|-------------| +| `{{ .field }}` | `pc.Current` | Merged trigger data + all prior step outputs (flat) | +| `{{ .steps.NAME.field }}` | `pc.StepOutputs` | Namespaced access to a specific step's output | +| `{{ .trigger.field }}` | `pc.TriggerData` | Original trigger data (immutable) | +| `{{ .meta.field }}` | `pc.Metadata` | Execution metadata (pipeline name, etc.) | + +#### Hyphenated Step Names + +Step names commonly contain hyphens (e.g., `parse-request`, `fetch-orders`). Go's template parser treats `-` as subtraction, so `{{ .steps.my-step.field }}` would normally fail. The engine handles this automatically: + +**Auto-fix (just works):** Write natural dot notation — the engine rewrites it before parsing: +```yaml +value: "{{ .steps.parse-request.path_params.id }}" +``` + +**Preferred syntax:** The `step` function avoids quoting issues entirely: +```yaml +value: '{{ step "parse-request" "path_params" "id" }}' +``` + +**Manual alternative:** The `index` function also works: +```yaml +value: '{{ index .steps "parse-request" "path_params" "id" }}' +``` + +`wfctl template validate --config workflow.yaml` lints template expressions and warns on undefined step references, forward references, and suggests the `step` function for hyphenated names. ### Infrastructure | Type | Description | @@ -816,6 +850,83 @@ triggers: action: "create-order" ``` +### Config Imports + +Large configs can be split into domain-specific files using `imports`. Each imported file is a standard workflow config — no special format required. Imports are recursive (imported files can import other files) with circular import detection. + +```yaml +# main.yaml +imports: + - config/modules.yaml + - config/routes/agents.yaml + - config/routes/tasks.yaml + - config/routes/requests.yaml + +modules: + - name: extra-module + type: http.server + config: + address: ":9090" + +pipelines: + health-check: + steps: + - name: respond + type: step.json_response + config: + body: '{"status": "ok"}' +``` + +```yaml +# config/modules.yaml +modules: + - name: my-db + type: storage.sqlite + config: + dbPath: ./data/app.db + + - name: my-router + type: http.router +``` + +```yaml +# config/routes/agents.yaml +pipelines: + list-agents: + steps: + - name: query + type: step.db_query + config: + query: "SELECT * FROM agents" + - name: respond + type: step.json_response + +triggers: + list-agents: + type: http + config: + path: /api/agents + method: GET +``` + +**Merge rules:** +- **Modules:** All modules from all files are included. Main file's modules appear first. +- **Pipelines, triggers, workflows, platform:** Main file's definitions take precedence. Imported values only fill in keys not already defined. +- **Recursive imports:** Imported files can themselves use `imports`. Circular imports are detected and produce an error. Diamond imports (multiple files importing a shared dependency) are allowed. +- **Import order:** Depth-first traversal — if main imports [A, B] and A imports C, module order is: main, A, C, B. +- **Relative paths:** Import paths are resolved relative to the importing file's directory. + +**Comparison with ApplicationConfig:** + +| Feature | `imports` | `ApplicationConfig` | +|---------|-----------|---------------------| +| Format | Standard `WorkflowConfig` with `imports:` field | Separate `application:` top-level format | +| Conflict handling | Main file wins silently | Errors on name conflicts | +| Use case | Splitting a monolith incrementally | Composing independent workflow services | +| Nesting | Files can import other files recursively | Flat list of workflow references | + +Both approaches work with `wfctl template validate --config` for validation. + ## Visual Workflow Builder (UI) A React-based visual editor for composing workflow configurations (`ui/` directory). diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index 5ecaeed5..57666032 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "time" ) var version = "dev" @@ -29,6 +30,8 @@ var commands = map[string]func([]string) error{ "generate": runGenerate, "git": runGit, "registry": runRegistry, + "update": runUpdate, + "mcp": runMCP, } func usage() { @@ -59,6 +62,8 @@ Commands: generate Code generation (github-actions: generate CI/CD workflows from config) git Git integration (connect: link to GitHub repo, push: commit and push) registry Registry management (list, add, remove plugin registry sources) + update Update wfctl to the latest version (use --check to only check) + mcp Start the MCP server over stdio for AI assistant integration Run 'wfctl -h' for command-specific help. `, version) @@ -87,8 +92,25 @@ func main() { os.Exit(1) } + // Start the update check in the background before running the command so + // that it runs concurrently. For long-running commands (mcp, run) we skip + // it entirely. After the command finishes we wait briefly for the result. + var updateNoticeDone <-chan struct{} + if cmd != "mcp" && cmd != "run" { + updateNoticeDone = checkForUpdateNotice() + } + if err := fn(os.Args[2:]); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) //nolint:gosec // G705: CLI error output os.Exit(1) } + + // Wait briefly for the update notice after the command completes. + // A 1-second ceiling ensures we never meaningfully delay the shell prompt. + if updateNoticeDone != nil { + select { + case <-updateNoticeDone: + case <-time.After(time.Second): + } + } } diff --git a/cmd/wfctl/mcp.go b/cmd/wfctl/mcp.go new file mode 100644 index 00000000..66143357 --- /dev/null +++ b/cmd/wfctl/mcp.go @@ -0,0 +1,53 @@ +package main + +import ( + "flag" + "fmt" + + workflowmcp "github.com/GoCodeAlone/workflow/mcp" +) + +// runMCP starts the workflow MCP (Model Context Protocol) server over stdio. +// This exposes workflow engine tools and resources to AI assistants. +func runMCP(args []string) error { + fs := flag.NewFlagSet("mcp", flag.ContinueOnError) + pluginDir := fs.String("plugin-dir", "data/plugins", "Plugin data directory") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl mcp [options] + +Start the workflow MCP (Model Context Protocol) server over stdio. +This exposes workflow engine tools and resources to AI assistants such as +Claude Desktop, VS Code with GitHub Copilot, and Cursor. + +The server provides tools for listing module types, validating configs, +generating schemas, and inspecting workflow YAML configurations. + +Options: +`) + fs.PrintDefaults() + fmt.Fprintf(fs.Output(), ` +Example Claude Desktop configuration (~/.config/claude/claude_desktop_config.json): + + { + "mcpServers": { + "workflow": { + "command": "wfctl", + "args": ["mcp", "-plugin-dir", "/path/to/data/plugins"] + } + } + } + +See docs/mcp.md for full setup instructions. +`) + } + if err := fs.Parse(args); err != nil { + return err + } + + // Propagate the CLI version so the MCP handshake and version output + // reflect the release version set at build time. + workflowmcp.Version = version + + srv := workflowmcp.NewServer(*pluginDir) + return srv.ServeStdio() +} diff --git a/cmd/wfctl/mcp_test.go b/cmd/wfctl/mcp_test.go new file mode 100644 index 00000000..bd08563b --- /dev/null +++ b/cmd/wfctl/mcp_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "testing" +) + +func TestRunMCP_Usage(t *testing.T) { + // Passing -h should return an error from flag parsing (ExitOnError calls os.Exit, + // but we use ContinueOnError in runMCP so it returns an error instead). + err := runMCP([]string{"-h"}) + if err == nil { + t.Fatal("expected error from -h flag") + } +} + +func TestRunMCP_UnknownFlag(t *testing.T) { + err := runMCP([]string{"--unknown-flag"}) + if err == nil { + t.Fatal("expected error for unknown flag") + } +} diff --git a/cmd/wfctl/template_validate.go b/cmd/wfctl/template_validate.go index 6c121c8b..97bf929d 100644 --- a/cmd/wfctl/template_validate.go +++ b/cmd/wfctl/template_validate.go @@ -351,7 +351,7 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule moduleNames[mod.Name] = true } - // 1. Validate module types + // 1. Validate module types and config fields result.ModuleCount = len(cfg.Modules) for _, mod := range cfg.Modules { if mod.Type == "" { @@ -363,7 +363,7 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule result.Errors = append(result.Errors, fmt.Sprintf("module %q uses unknown type %q", mod.Name, mod.Type)) } else { result.ModuleValid++ - // 5. Warn on unknown config fields (with snake_case hint) + // Warn on unknown config fields (with snake_case hint) if mod.Config != nil && len(info.ConfigKeys) > 0 { knownKeys := make(map[string]bool) snakeToCamel := make(map[string]string) @@ -385,7 +385,7 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule } } - // 3. Validate dependencies + // 2. Validate dependencies for _, dep := range mod.DependsOn { result.DepCount++ if !moduleNames[dep] { @@ -396,7 +396,7 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule } } - // 2. Validate step types in pipelines + // 3. Validate step types in pipelines for pipelineName, pipelineRaw := range cfg.Pipelines { pipelineMap, ok := pipelineRaw.(map[string]any) if !ok { @@ -442,7 +442,10 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule } } - // 4. Validate trigger types + // 4. Validate template expressions in pipeline steps + validatePipelineTemplates(pipelineName, stepsRaw, &result) + + // 5. Validate trigger types if triggerRaw, ok := pipelineMap["trigger"]; ok { if triggerMap, ok := triggerRaw.(map[string]any); ok { triggerType, _ := triggerMap["type"].(string) @@ -556,3 +559,137 @@ func printTemplateValidationResults(results []templateValidationResult, summary // templateFSReader allows reading from the embedded templateFS for validation. // It wraps around the existing templateFS embed.FS. var _ fs.FS = templateFS + +// --- Pipeline template expression linting --- + +// templateExprRe matches template actions {{ ... }}. +var templateExprRe = regexp.MustCompile(`\{\{(.*?)\}\}`) + +// stepRefDotRe matches .steps.STEP_NAME patterns (dot access). +var stepRefDotRe = regexp.MustCompile(`\.steps\.([a-zA-Z_][a-zA-Z0-9_-]*)`) + +// stepRefIndexRe matches index .steps "STEP_NAME" patterns. +var stepRefIndexRe = regexp.MustCompile(`index\s+\.steps\s+"([^"]+)"`) + +// stepRefFuncRe matches step "STEP_NAME" function calls at the start of an +// action, after a pipe, or after an opening parenthesis. +var stepRefFuncRe = regexp.MustCompile(`(?:^|\||\()\s*step\s+"([^"]+)"`) + +// hyphenDotRe matches dot-access chains with hyphens (e.g., .steps.my-step.field), +// including continuation segments after the hyphenated part. +var hyphenDotRe = regexp.MustCompile(`\.[a-zA-Z_][a-zA-Z0-9_]*-[a-zA-Z0-9_-]*(?:\.[a-zA-Z_][a-zA-Z0-9_-]*)*`) + +// validatePipelineTemplates checks template expressions in pipeline step configs for +// references to nonexistent or forward-declared steps and common template pitfalls. +func validatePipelineTemplates(pipelineName string, stepsRaw []any, result *templateValidationResult) { + // Build ordered step name list + stepNames := make(map[string]int) // step name -> index in pipeline + for i, stepRaw := range stepsRaw { + stepMap, ok := stepRaw.(map[string]any) + if !ok { + continue + } + name, _ := stepMap["name"].(string) + if name != "" { + stepNames[name] = i + } + } + + // Check each step's config for template expressions + for i, stepRaw := range stepsRaw { + stepMap, ok := stepRaw.(map[string]any) + if !ok { + continue + } + stepName, _ := stepMap["name"].(string) + if stepName == "" { + stepName = fmt.Sprintf("step[%d]", i) + } + + // Collect all string values from the step config recursively + templates := collectTemplateStrings(stepMap) + + for _, tmpl := range templates { + // Find all template actions + actions := templateExprRe.FindAllStringSubmatch(tmpl, -1) + for _, action := range actions { + if len(action) < 2 { + continue + } + actionContent := action[1] + + // Skip comments + trimmed := strings.TrimSpace(actionContent) + if strings.HasPrefix(trimmed, "/*") { + continue + } + + // Check for step name references via dot-access + dotMatches := stepRefDotRe.FindAllStringSubmatch(actionContent, -1) + for _, m := range dotMatches { + refName := m[1] + validateStepRef(pipelineName, stepName, refName, i, stepNames, result) + } + + // Check for step name references via index + indexMatches := stepRefIndexRe.FindAllStringSubmatch(actionContent, -1) + for _, m := range indexMatches { + refName := m[1] + validateStepRef(pipelineName, stepName, refName, i, stepNames, result) + } + + // Check for step name references via step function + funcMatches := stepRefFuncRe.FindAllStringSubmatch(actionContent, -1) + for _, m := range funcMatches { + refName := m[1] + validateStepRef(pipelineName, stepName, refName, i, stepNames, result) + } + + // Warn on hyphenated dot-access (auto-fixed but suggest preferred syntax) + if hyphenDotRe.MatchString(actionContent) { + result.Warnings = append(result.Warnings, + fmt.Sprintf("pipeline %q step %q: template uses hyphenated dot-access which is auto-fixed; prefer step \"name\" \"field\" syntax", pipelineName, stepName)) + } + } + } + } +} + +// validateStepRef checks that a referenced step name exists and appears before the +// current step in the pipeline execution order. +func validateStepRef(pipelineName, currentStep, refName string, currentIdx int, stepNames map[string]int, result *templateValidationResult) { + refIdx, exists := stepNames[refName] + switch { + case !exists: + result.Warnings = append(result.Warnings, + fmt.Sprintf("pipeline %q step %q: references step %q which does not exist in this pipeline", pipelineName, currentStep, refName)) + case refIdx == currentIdx: + result.Warnings = append(result.Warnings, + fmt.Sprintf("pipeline %q step %q: references itself; a step cannot use its own outputs because they are not available until after execution", pipelineName, currentStep)) + case refIdx > currentIdx: + result.Warnings = append(result.Warnings, + fmt.Sprintf("pipeline %q step %q: references step %q which has not executed yet (appears later in pipeline)", pipelineName, currentStep, refName)) + } +} + +// collectTemplateStrings recursively finds all strings containing {{ in a value tree. +// This intentionally scans all fields (not just "config") because template expressions +// can appear in conditions, names, and other step fields. +func collectTemplateStrings(data any) []string { + var results []string + switch v := data.(type) { + case string: + if strings.Contains(v, "{{") { + results = append(results, v) + } + case map[string]any: + for _, val := range v { + results = append(results, collectTemplateStrings(val)...) + } + case []any: + for _, item := range v { + results = append(results, collectTemplateStrings(item)...) + } + } + return results +} diff --git a/cmd/wfctl/template_validate_test.go b/cmd/wfctl/template_validate_test.go index 17d9cfaf..fefd3b03 100644 --- a/cmd/wfctl/template_validate_test.go +++ b/cmd/wfctl/template_validate_test.go @@ -334,3 +334,241 @@ modules: t.Errorf("expected valid config with --plugin-dir, got: %v", err) } } + +// --- Pipeline template expression linting tests --- + +func TestValidateConfigWithValidStepRefs(t *testing.T) { + dir := t.TempDir() + configContent := ` +pipelines: + api: + trigger: + type: http + config: + path: /items/:id + method: GET + steps: + - name: parse-request + type: step.set + config: + values: + item_id: "static-id" + - name: db-query + type: step.set + config: + values: + query: "{{ .steps.parse-request.path_params.id }}" +` + configPath := filepath.Join(dir, "workflow.yaml") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + err := runTemplateValidate([]string{"-config", configPath}) + if err != nil { + t.Fatalf("expected valid step refs to pass, got: %v", err) + } +} + +func TestValidateConfigWithMissingStepRef(t *testing.T) { + cfg := &config.WorkflowConfig{ + Pipelines: map[string]any{ + "api": map[string]any{ + "steps": []any{ + map[string]any{ + "name": "do-thing", + "type": "step.set", + "config": map[string]any{ + "values": map[string]any{ + "x": "{{ .steps.nonexistent.field }}", + }, + }, + }, + }, + }, + }, + } + knownModules := KnownModuleTypes() + knownSteps := KnownStepTypes() + knownTriggers := KnownTriggerTypes() + + result := validateWorkflowConfig("test", cfg, knownModules, knownSteps, knownTriggers) + + found := false + for _, w := range result.Warnings { + if strings.Contains(w, "nonexistent") && strings.Contains(w, "does not exist") { + found = true + break + } + } + if !found { + t.Errorf("expected warning about nonexistent step reference, got warnings: %v", result.Warnings) + } +} + +func TestValidateConfigWithForwardStepRef(t *testing.T) { + cfg := &config.WorkflowConfig{ + Pipelines: map[string]any{ + "api": map[string]any{ + "steps": []any{ + map[string]any{ + "name": "first", + "type": "step.set", + "config": map[string]any{ + "values": map[string]any{ + "x": "{{ .steps.second.output }}", + }, + }, + }, + map[string]any{ + "name": "second", + "type": "step.set", + "config": map[string]any{ + "values": map[string]any{ + "y": "hello", + }, + }, + }, + }, + }, + }, + } + knownModules := KnownModuleTypes() + knownSteps := KnownStepTypes() + knownTriggers := KnownTriggerTypes() + + result := validateWorkflowConfig("test", cfg, knownModules, knownSteps, knownTriggers) + + found := false + for _, w := range result.Warnings { + if strings.Contains(w, "second") && strings.Contains(w, "has not executed yet") { + found = true + break + } + } + if !found { + t.Errorf("expected warning about forward step reference, got warnings: %v", result.Warnings) + } +} + +func TestValidateConfigWithStepFunction(t *testing.T) { + cfg := &config.WorkflowConfig{ + Pipelines: map[string]any{ + "api": map[string]any{ + "steps": []any{ + map[string]any{ + "name": "parse-request", + "type": "step.set", + "config": map[string]any{ + "values": map[string]any{ + "x": "raw-body", + }, + }, + }, + map[string]any{ + "name": "process", + "type": "step.set", + "config": map[string]any{ + "values": map[string]any{ + "name": `{{ step "parse-request" "body" "name" }}`, + }, + }, + }, + }, + }, + }, + } + knownModules := KnownModuleTypes() + knownSteps := KnownStepTypes() + knownTriggers := KnownTriggerTypes() + + result := validateWorkflowConfig("test", cfg, knownModules, knownSteps, knownTriggers) + + // parse-request exists and is before process, so no warning about missing/forward ref + for _, w := range result.Warnings { + if strings.Contains(w, "parse-request") && (strings.Contains(w, "does not exist") || strings.Contains(w, "has not executed yet")) { + t.Errorf("unexpected warning about parse-request: %s", w) + } + } +} + +func TestValidateConfigWithSelfReference(t *testing.T) { + cfg := &config.WorkflowConfig{ + Pipelines: map[string]any{ + "api": map[string]any{ + "steps": []any{ + map[string]any{ + "name": "do-thing", + "type": "step.set", + "config": map[string]any{ + "values": map[string]any{ + "x": "{{ .steps.do-thing.output }}", + }, + }, + }, + }, + }, + }, + } + knownModules := KnownModuleTypes() + knownSteps := KnownStepTypes() + knownTriggers := KnownTriggerTypes() + + result := validateWorkflowConfig("test", cfg, knownModules, knownSteps, knownTriggers) + + found := false + for _, w := range result.Warnings { + if strings.Contains(w, "references itself") { + found = true + break + } + } + if !found { + t.Errorf("expected self-reference warning, got warnings: %v", result.Warnings) + } +} + +func TestValidateConfigWithHyphenDotAccess(t *testing.T) { + cfg := &config.WorkflowConfig{ + Pipelines: map[string]any{ + "api": map[string]any{ + "steps": []any{ + map[string]any{ + "name": "my-step", + "type": "step.set", + "config": map[string]any{ + "values": map[string]any{ + "x": "hello", + }, + }, + }, + map[string]any{ + "name": "consumer", + "type": "step.set", + "config": map[string]any{ + "values": map[string]any{ + "y": "{{ .steps.my-step.field }}", + }, + }, + }, + }, + }, + }, + } + knownModules := KnownModuleTypes() + knownSteps := KnownStepTypes() + knownTriggers := KnownTriggerTypes() + + result := validateWorkflowConfig("test", cfg, knownModules, knownSteps, knownTriggers) + + found := false + for _, w := range result.Warnings { + if strings.Contains(w, "hyphenated dot-access") && strings.Contains(w, "prefer") { + found = true + break + } + } + if !found { + t.Errorf("expected informational warning about hyphenated dot-access, got warnings: %v", result.Warnings) + } +} diff --git a/cmd/wfctl/update.go b/cmd/wfctl/update.go new file mode 100644 index 00000000..6c37040b --- /dev/null +++ b/cmd/wfctl/update.go @@ -0,0 +1,376 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + githubReleasesURL = "https://api.github.com/repos/GoCodeAlone/workflow/releases/latest" + envNoUpdateCheck = "WFCTL_NO_UPDATE_CHECK" + downloadTimeout = 10 * time.Minute // generous timeout for large binary downloads +) + +// githubReleasesURLOverride allows tests to substitute a fake server URL. +var githubReleasesURLOverride string + +// githubRelease is the minimal GitHub releases API response we need. +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []githubAsset `json:"assets"` + HTMLURL string `json:"html_url"` +} + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// runUpdate handles the "wfctl update" command. +func runUpdate(args []string) error { + fs := flag.NewFlagSet("update", flag.ContinueOnError) + checkOnly := fs.Bool("check", false, "Only check for updates without installing") + fs.Usage = func() { + fmt.Fprintf(fs.Output(), `Usage: wfctl update [options] + +Download and install the latest version of wfctl, replacing the current binary. + +Options: +`) + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + + if version == "dev" && !*checkOnly { + fmt.Fprintln(os.Stderr, "warning: running a dev build; update will install the latest release") + } + + fmt.Fprintln(os.Stderr, "Checking for updates...") + rel, err := fetchLatestRelease() + if err != nil { + return fmt.Errorf("check for updates: %w", err) + } + + latest := strings.TrimPrefix(rel.TagName, "v") + current := strings.TrimPrefix(version, "v") + + if *checkOnly { + if current == "dev" || latest == current { + fmt.Printf("wfctl is up to date (version %s)\n", version) + } else { + fmt.Printf("Update available: %s → %s\n", version, rel.TagName) + fmt.Printf("Run 'wfctl update' to install the latest version.\n") + fmt.Printf("Release notes: %s\n", rel.HTMLURL) + } + return nil + } + + if latest == current && current != "dev" { + fmt.Printf("wfctl %s is already the latest version.\n", version) + return nil + } + + asset, err := findReleaseAsset(rel.Assets) + if err != nil { + fmt.Fprintf(os.Stderr, "hint: visit %s to download manually\n", rel.HTMLURL) + return fmt.Errorf("no binary found for %s/%s in release %s: %w", runtime.GOOS, runtime.GOARCH, rel.TagName, err) + } + + fmt.Fprintf(os.Stderr, "Downloading %s...\n", asset.Name) + data, err := downloadWithTimeout(asset.BrowserDownloadURL, downloadTimeout) + if err != nil { + return fmt.Errorf("download: %w", err) + } + + // Verify integrity using the release's checksums.txt if available. + if checksumAsset := findChecksumAsset(rel.Assets); checksumAsset != nil { + fmt.Fprintln(os.Stderr, "Verifying checksum...") + if err := verifyAssetChecksum(checksumAsset, asset.Name, data); err != nil { + return fmt.Errorf("integrity check failed: %w", err) + } + fmt.Fprintln(os.Stderr, "Checksum verified.") + } + + // If it's an archive, extract it. + var binaryData []byte + if strings.HasSuffix(asset.Name, ".tar.gz") { + binaryData, err = extractBinaryFromTarGz(data, "wfctl") + if err != nil { + return fmt.Errorf("extract binary from archive: %w", err) + } + } else { + binaryData = data + } + + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("find current executable: %w", err) + } + // Resolve symlinks so we replace the real binary. + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return fmt.Errorf("resolve executable path: %w", err) + } + + if err := replaceBinary(execPath, binaryData); err != nil { + return fmt.Errorf("replace binary: %w", err) + } + + fmt.Printf("wfctl updated to %s\n", rel.TagName) + return nil +} + +// checkForUpdateNotice starts a background goroutine that checks GitHub for a +// newer version and prints a notice to stderr if one is available. +// It returns a channel that is closed when the check completes (or is skipped). +// Callers should wait on the channel after their main work is done to allow +// the notice to be printed without delaying command execution. +func checkForUpdateNotice() <-chan struct{} { + done := make(chan struct{}) + if os.Getenv(envNoUpdateCheck) != "" || version == "dev" { + close(done) + return done + } + go func() { + defer close(done) + rel, err := fetchLatestRelease() + if err != nil || rel == nil { + return + } + latest := strings.TrimPrefix(rel.TagName, "v") + current := strings.TrimPrefix(version, "v") + if latest != "" && latest != current { + fmt.Fprintf(os.Stderr, "\n⚡ wfctl %s is available (you have %s). Run 'wfctl update' to upgrade.\n\n", rel.TagName, version) + } + }() + return done +} + +// fetchLatestRelease queries the GitHub releases API for the latest release. +func fetchLatestRelease() (*githubRelease, error) { + url := githubReleasesURL + if githubReleasesURLOverride != "" { + url = githubReleasesURLOverride + } + req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:noctx // no context needed for a quick check + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "wfctl/"+version) + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned HTTP %d", resp.StatusCode) + } + + var rel githubRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + if rel.TagName == "" { + return nil, fmt.Errorf("no releases found") + } + return &rel, nil +} + +// findReleaseAsset locates the wfctl binary asset for the current OS and arch. +// It tries several naming conventions used by GoReleaser. +func findReleaseAsset(assets []githubAsset) (*githubAsset, error) { + goos := runtime.GOOS + goarch := runtime.GOARCH + + // Candidate names in preference order. + candidates := []string{ + fmt.Sprintf("wfctl-%s-%s", goos, goarch), + fmt.Sprintf("wfctl-%s-%s.tar.gz", goos, goarch), + fmt.Sprintf("wfctl_%s_%s", goos, goarch), + fmt.Sprintf("wfctl_%s_%s.tar.gz", goos, goarch), + } + if goos == "windows" { + candidates = append( + []string{ + fmt.Sprintf("wfctl-%s-%s.exe", goos, goarch), + fmt.Sprintf("wfctl_%s_%s.exe", goos, goarch), + }, + candidates..., + ) + } + + for _, name := range candidates { + for i := range assets { + if strings.EqualFold(assets[i].Name, name) { + return &assets[i], nil + } + } + } + return nil, fmt.Errorf("no matching asset for %s/%s", goos, goarch) +} + +// findChecksumAsset looks for a checksums.txt asset in the release. +func findChecksumAsset(assets []githubAsset) *githubAsset { + for i := range assets { + if strings.EqualFold(assets[i].Name, "checksums.txt") { + return &assets[i] + } + } + return nil +} + +// verifyAssetChecksum downloads checksums.txt and verifies the SHA256 of data +// matches the entry for assetName. The checksums file uses the format produced +// by sha256sum: " " per line. +func verifyAssetChecksum(checksumAsset *githubAsset, assetName string, data []byte) error { + checksumData, err := downloadWithTimeout(checksumAsset.BrowserDownloadURL, 30*time.Second) + if err != nil { + return fmt.Errorf("download checksums.txt: %w", err) + } + + for _, line := range strings.Split(string(checksumData), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) != 2 { + continue + } + if strings.EqualFold(parts[1], assetName) { + h := sha256.Sum256(data) + got := hex.EncodeToString(h[:]) + if !strings.EqualFold(got, parts[0]) { + return fmt.Errorf("checksum mismatch for %s: got %s, want %s", assetName, got, parts[0]) + } + return nil + } + } + return fmt.Errorf("checksum for %q not found in checksums.txt", assetName) +} + +// downloadWithTimeout fetches a URL using an HTTP client with the given timeout. +func downloadWithTimeout(url string, timeout time.Duration) ([]byte, error) { + client := &http.Client{Timeout: timeout} + req, err := http.NewRequest(http.MethodGet, url, nil) //nolint:noctx // timeout is set on the client + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "wfctl/"+version) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + return io.ReadAll(resp.Body) +} + +// replaceBinary writes newData to execPath atomically by writing to a temp file +// first and then renaming it over the original. +// On Windows, replacing a running executable via rename is not supported; the +// new binary is written alongside the current one and the user is instructed to +// complete the swap manually. +func replaceBinary(execPath string, newData []byte) error { + // Preserve the existing file's permissions; fall back to 0755 if stat fails. + mode := os.FileMode(0755) //nolint:gosec // G302: executable needs at least 0755 + if fi, err := os.Stat(execPath); err == nil { + mode = fi.Mode().Perm() + } + + if runtime.GOOS == "windows" { + // Windows does not allow replacing a running .exe via rename. + // Write the new binary with a .new.exe suffix and instruct the user. + newPath := strings.TrimSuffix(execPath, ".exe") + ".new.exe" + if err := os.WriteFile(newPath, newData, mode); err != nil { + return fmt.Errorf("write new binary: %w", err) + } + fmt.Fprintf(os.Stderr, + "New binary written to %s\nTo complete the update, replace %s with %s (e.g., after closing this terminal).\n", + newPath, execPath, newPath) + return nil + } + + dir := filepath.Dir(execPath) + tmp, err := os.CreateTemp(dir, ".wfctl-update-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + defer func() { + _ = os.Remove(tmpName) // clean up if rename failed + }() + + if _, err := tmp.Write(newData); err != nil { + tmp.Close() + return fmt.Errorf("write temp file: %w", err) + } + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + return fmt.Errorf("chmod temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Rename(tmpName, execPath); err != nil { //nolint:gosec // G703: execPath comes from os.Executable()+EvalSymlinks, tmpName from os.CreateTemp in the same dir + return fmt.Errorf("replace binary: %w", err) + } + return nil +} + +// extractBinaryFromTarGz extracts a named binary from a .tar.gz archive. +// It searches for the first entry whose base name matches binaryName (case-insensitive, +// with or without a .exe extension on Windows). +func extractBinaryFromTarGz(data []byte, binaryName string) ([]byte, error) { + tmpDir, err := os.MkdirTemp("", "wfctl-update-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + // Re-use extractTarGz from plugin_install.go to avoid duplicating decompression logic. + if err := extractTarGz(data, tmpDir); err != nil { + return nil, err + } + + // Walk the extracted directory looking for the binary. + var found string + if walkErr := filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + base := strings.ToLower(info.Name()) + target := strings.ToLower(binaryName) + if base == target || base == target+".exe" { + found = path + } + return nil + }); walkErr != nil { + return nil, walkErr + } + + if found == "" { + return nil, fmt.Errorf("binary %q not found in archive", binaryName) + } + + return os.ReadFile(found) //nolint:gosec // G304: path is within our own temp dir +} diff --git a/cmd/wfctl/update_test.go b/cmd/wfctl/update_test.go new file mode 100644 index 00000000..e96a86be --- /dev/null +++ b/cmd/wfctl/update_test.go @@ -0,0 +1,311 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestFindReleaseAsset_Found(t *testing.T) { + goos := runtime.GOOS + goarch := runtime.GOARCH + assets := []githubAsset{ + {Name: "wfctl-" + goos + "-" + goarch, BrowserDownloadURL: "http://example.com/wfctl"}, + {Name: "wfctl-plan9-mips", BrowserDownloadURL: "http://example.com/other"}, + } + + asset, err := findReleaseAsset(assets) + if err != nil { + t.Fatalf("findReleaseAsset: %v", err) + } + if asset == nil { + t.Fatal("expected asset, got nil") + } + if asset.Name != "wfctl-"+goos+"-"+goarch { + t.Errorf("unexpected asset name: %s", asset.Name) + } +} + +func TestFindReleaseAsset_NotFound(t *testing.T) { + assets := []githubAsset{ + {Name: "wfctl-plan9-mips", BrowserDownloadURL: "http://example.com/x"}, + } + _, err := findReleaseAsset(assets) + if err == nil { + t.Fatal("expected error for unsupported platform") + } +} + +func TestReplaceBinary(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "wfctl-test") + if err := os.WriteFile(target, []byte("old"), 0755); err != nil { //nolint:gosec + t.Fatal(err) + } + if err := replaceBinary(target, []byte("new")); err != nil { + t.Fatalf("replaceBinary: %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "new" { + t.Errorf("expected 'new', got %q", got) + } +} + +func TestRunUpdate_CheckOnly(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + rel := githubRelease{ + TagName: "v9.9.9", + HTMLURL: "https://github.com/GoCodeAlone/workflow/releases/tag/v9.9.9", + Assets: []githubAsset{}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rel) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + // Should not error even when no asset matches (--check only reports version). + err := runUpdate([]string{"--check"}) + if err != nil { + t.Fatalf("runUpdate --check: %v", err) + } +} + +func TestRunUpdate_AlreadyLatest(t *testing.T) { + origVersion := version + version = "1.2.3" + defer func() { version = origVersion }() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + rel := githubRelease{ + TagName: "v1.2.3", + HTMLURL: "https://example.com", + Assets: []githubAsset{}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rel) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + if err := runUpdate([]string{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunUpdate_GitHubAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + err := runUpdate([]string{}) + if err == nil { + t.Fatal("expected error on API failure") + } +} + +func TestCheckForUpdateNotice_SkipsDevBuild(t *testing.T) { + origVersion := version + version = "dev" + defer func() { version = origVersion }() + // Should close the done channel immediately without making any network requests. + done := checkForUpdateNotice() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("expected done channel to be closed immediately for dev build") + } +} + +func TestCheckForUpdateNotice_RespectsEnvVar(t *testing.T) { + t.Setenv(envNoUpdateCheck, "1") + // Should close the done channel immediately without any network call. + done := checkForUpdateNotice() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("expected done channel to be closed immediately when update check disabled") + } +} + +func TestFetchLatestRelease_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + rel := githubRelease{ + TagName: "v1.0.0", + HTMLURL: "https://github.com/GoCodeAlone/workflow/releases/tag/v1.0.0", + Assets: []githubAsset{ + {Name: "wfctl-linux-amd64", BrowserDownloadURL: "http://example.com/wfctl"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rel) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + rel, err := fetchLatestRelease() + if err != nil { + t.Fatalf("fetchLatestRelease: %v", err) + } + if rel.TagName != "v1.0.0" { + t.Errorf("expected v1.0.0, got %s", rel.TagName) + } + if len(rel.Assets) != 1 { + t.Errorf("expected 1 asset, got %d", len(rel.Assets)) + } +} + +func TestFetchLatestRelease_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + githubReleasesURLOverride = srv.URL + defer func() { githubReleasesURLOverride = "" }() + + _, err := fetchLatestRelease() + if err == nil { + t.Fatal("expected error for non-200 response") + } +} + +func TestFindChecksumAsset(t *testing.T) { + assets := []githubAsset{ + {Name: "wfctl-linux-amd64", BrowserDownloadURL: "http://example.com/bin"}, + {Name: "checksums.txt", BrowserDownloadURL: "http://example.com/checksums.txt"}, + } + got := findChecksumAsset(assets) + if got == nil { + t.Fatal("expected to find checksums.txt asset") + } + if got.Name != "checksums.txt" { + t.Errorf("unexpected name: %s", got.Name) + } +} + +func TestFindChecksumAsset_NotFound(t *testing.T) { + assets := []githubAsset{ + {Name: "wfctl-linux-amd64", BrowserDownloadURL: "http://example.com/bin"}, + } + if got := findChecksumAsset(assets); got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +func TestVerifyAssetChecksum_Valid(t *testing.T) { + data := []byte("fake binary content") + h := sha256.Sum256(data) + hash := hex.EncodeToString(h[:]) + checksumsContent := fmt.Sprintf("%s wfctl-linux-amd64\n", hash) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(checksumsContent)) + })) + defer srv.Close() + + checksumAsset := &githubAsset{Name: "checksums.txt", BrowserDownloadURL: srv.URL} + if err := verifyAssetChecksum(checksumAsset, "wfctl-linux-amd64", data); err != nil { + t.Fatalf("verifyAssetChecksum: %v", err) + } +} + +func TestVerifyAssetChecksum_Mismatch(t *testing.T) { + data := []byte("fake binary content") + checksumsContent := "deadbeef wfctl-linux-amd64\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(checksumsContent)) + })) + defer srv.Close() + + checksumAsset := &githubAsset{Name: "checksums.txt", BrowserDownloadURL: srv.URL} + err := verifyAssetChecksum(checksumAsset, "wfctl-linux-amd64", data) + if err == nil { + t.Fatal("expected checksum mismatch error") + } +} + +func TestVerifyAssetChecksum_Missing(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("abc123 other-asset\n")) + })) + defer srv.Close() + + checksumAsset := &githubAsset{Name: "checksums.txt", BrowserDownloadURL: srv.URL} + err := verifyAssetChecksum(checksumAsset, "wfctl-linux-amd64", []byte("data")) + if err == nil { + t.Fatal("expected error when asset not in checksums.txt") + } +} + +func TestDownloadWithTimeout_Success(t *testing.T) { + body := []byte("hello world") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) + })) + defer srv.Close() + + got, err := downloadWithTimeout(srv.URL, 5*time.Second) + if err != nil { + t.Fatalf("downloadWithTimeout: %v", err) + } + if string(got) != string(body) { + t.Errorf("expected %q, got %q", body, got) + } +} + +func TestDownloadWithTimeout_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "gone", http.StatusGone) + })) + defer srv.Close() + + _, err := downloadWithTimeout(srv.URL, 5*time.Second) + if err == nil { + t.Fatal("expected error for non-200 response") + } +} + +func TestReplaceBinary_PreservesMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission bits not meaningful on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "wfctl-test") + // Write with a distinct mode. + if err := os.WriteFile(target, []byte("old"), 0750); err != nil { //nolint:gosec + t.Fatal(err) + } + if err := replaceBinary(target, []byte("new")); err != nil { + t.Fatalf("replaceBinary: %v", err) + } + fi, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0750 { + t.Errorf("expected mode 0750, got %o", fi.Mode().Perm()) + } +} diff --git a/config/config.go b/config/config.go index 7a261306..57445f12 100644 --- a/config/config.go +++ b/config/config.go @@ -92,6 +92,7 @@ type PluginRequirement struct { // WorkflowConfig represents the overall configuration for the workflow engine type WorkflowConfig struct { + Imports []string `json:"imports,omitempty" yaml:"imports,omitempty"` Modules []ModuleConfig `json:"modules" yaml:"modules"` Workflows map[string]any `json:"workflows" yaml:"workflows"` Triggers map[string]any `json:"triggers" yaml:"triggers"` @@ -113,9 +114,32 @@ func (c *WorkflowConfig) ResolveRelativePath(path string) string { return pathpkg.Join(c.ConfigDir, path) } -// LoadFromFile loads a workflow configuration from a YAML file +// LoadFromFile loads a workflow configuration from a YAML file. +// If the config contains an "imports" field, referenced files are loaded +// recursively and merged. The importing file's definitions take precedence +// over imported ones for map-based fields (workflows, triggers, pipelines, +// platform). Modules are concatenated with the main file's modules first. func LoadFromFile(filepath string) (*WorkflowConfig, error) { - data, err := os.ReadFile(filepath) + return loadFromFileWithImports(filepath, nil) +} + +func loadFromFileWithImports(filepath string, seen map[string]bool) (*WorkflowConfig, error) { + absPath, err := pathpkg.Abs(filepath) + if err != nil { + return nil, fmt.Errorf("failed to resolve path %s: %w", filepath, err) + } + + // Circular import detection + if seen == nil { + seen = make(map[string]bool) + } + if seen[absPath] { + return nil, fmt.Errorf("circular import detected: %s", filepath) + } + seen[absPath] = true + defer delete(seen, absPath) + + data, err := os.ReadFile(absPath) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } @@ -125,16 +149,100 @@ func LoadFromFile(filepath string) (*WorkflowConfig, error) { return nil, fmt.Errorf("failed to parse config file: %w", err) } - // Store the config file's directory for relative path resolution - absPath, err := pathpkg.Abs(filepath) - if err == nil { - cfg.ConfigDir = pathpkg.Dir(absPath) + cfg.ConfigDir = pathpkg.Dir(absPath) + + // Process imports + if len(cfg.Imports) > 0 { + if err := cfg.processImports(seen); err != nil { + return nil, err + } } return &cfg, nil } +// processImports loads all imported config files and merges them into this config. +// Imported definitions provide defaults — the importing file's own definitions take +// precedence for map-based fields (workflows, triggers, pipelines, platform). +// Modules are appended after the main file's modules. +// +// Import order follows depth-first traversal: if main.yaml imports [A, B] and A +// imports C, the final module order is: main's modules, A's modules, C's modules, +// B's modules. Diamond imports (multiple files importing a shared dependency) are +// allowed — the shared file is loaded each time but only non-duplicate map keys +// are added (main-file-wins semantics). +func (cfg *WorkflowConfig) processImports(seen map[string]bool) error { + for _, imp := range cfg.Imports { + impPath := imp + if !pathpkg.IsAbs(impPath) { + impPath = pathpkg.Join(cfg.ConfigDir, impPath) + } + + impCfg, err := loadFromFileWithImports(impPath, seen) + if err != nil { + return fmt.Errorf("import %q: %w", imp, err) + } + + // Merge imported modules — deduplicate by name (first definition wins) + existingModules := make(map[string]struct{}, len(cfg.Modules)) + for _, m := range cfg.Modules { + existingModules[m.Name] = struct{}{} + } + for _, m := range impCfg.Modules { + if _, exists := existingModules[m.Name]; exists { + continue + } + cfg.Modules = append(cfg.Modules, m) + existingModules[m.Name] = struct{}{} + } + + // Merge maps — imported values only added if not already defined in main file + if cfg.Workflows == nil { + cfg.Workflows = make(map[string]any) + } + for k, v := range impCfg.Workflows { + if _, exists := cfg.Workflows[k]; !exists { + cfg.Workflows[k] = v + } + } + + if cfg.Triggers == nil { + cfg.Triggers = make(map[string]any) + } + for k, v := range impCfg.Triggers { + if _, exists := cfg.Triggers[k]; !exists { + cfg.Triggers[k] = v + } + } + + if cfg.Pipelines == nil { + cfg.Pipelines = make(map[string]any) + } + for k, v := range impCfg.Pipelines { + if _, exists := cfg.Pipelines[k]; !exists { + cfg.Pipelines[k] = v + } + } + + if impCfg.Platform != nil { + if cfg.Platform == nil { + cfg.Platform = make(map[string]any) + } + for k, v := range impCfg.Platform { + if _, exists := cfg.Platform[k]; !exists { + cfg.Platform[k] = v + } + } + } + } + + cfg.Imports = nil // clear after processing + return nil +} + // LoadFromString loads a workflow configuration from a YAML string. +// Note: imports are NOT processed when loading from a string because there is +// no file path context to resolve relative import paths against. func LoadFromString(yamlContent string) (*WorkflowConfig, error) { var cfg WorkflowConfig if err := yaml.Unmarshal([]byte(yamlContent), &cfg); err != nil { diff --git a/config/config_import_test.go b/config/config_import_test.go new file mode 100644 index 00000000..0c142fb6 --- /dev/null +++ b/config/config_import_test.go @@ -0,0 +1,643 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadFromFile_WithImports(t *testing.T) { + dir := t.TempDir() + + // Create imported file with a module + modulesYAML := ` +modules: + - name: my-db + type: storage.sqlite + config: + path: ./data/app.db +` + if err := os.WriteFile(filepath.Join(dir, "modules.yaml"), []byte(modulesYAML), 0644); err != nil { + t.Fatal(err) + } + + // Create imported routes file with a pipeline + routesYAML := ` +pipelines: + get-items: + steps: + - name: query + type: step.db_query + config: + query: "SELECT * FROM items" + +triggers: + get-items-trigger: + type: http + config: + path: /items + method: GET +` + if err := os.WriteFile(filepath.Join(dir, "routes.yaml"), []byte(routesYAML), 0644); err != nil { + t.Fatal(err) + } + + // Create main file that imports both + mainYAML := ` +imports: + - modules.yaml + - routes.yaml + +modules: + - name: my-server + type: http.server + config: + address: ":8080" + +workflows: + main: + steps: + - name: init + type: step.log +` + mainPath := filepath.Join(dir, "main.yaml") + if err := os.WriteFile(mainPath, []byte(mainYAML), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFromFile(mainPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should have 2 modules (my-server from main + my-db from import) + if len(cfg.Modules) != 2 { + t.Errorf("expected 2 modules, got %d", len(cfg.Modules)) + } + + // Main file's module should come first + if cfg.Modules[0].Name != "my-server" { + t.Errorf("expected first module to be 'my-server', got %q", cfg.Modules[0].Name) + } + if cfg.Modules[1].Name != "my-db" { + t.Errorf("expected second module to be 'my-db', got %q", cfg.Modules[1].Name) + } + + // Should have the pipeline from import + if _, ok := cfg.Pipelines["get-items"]; !ok { + t.Error("expected 'get-items' pipeline from import") + } + + // Should have the trigger from import + if _, ok := cfg.Triggers["get-items-trigger"]; !ok { + t.Error("expected 'get-items-trigger' trigger from import") + } + + // Should have the workflow from main + if _, ok := cfg.Workflows["main"]; !ok { + t.Error("expected 'main' workflow from main file") + } + + // Imports field should be cleared after processing + if len(cfg.Imports) != 0 { + t.Errorf("expected imports to be cleared, got %v", cfg.Imports) + } +} + +func TestLoadFromFile_ImportPrecedence(t *testing.T) { + dir := t.TempDir() + + // Create imported file with a pipeline + importedYAML := ` +pipelines: + my-pipeline: + steps: + - name: imported-step + type: step.log + config: + message: "from import" + +triggers: + my-trigger: + type: http + config: + path: /imported +` + if err := os.WriteFile(filepath.Join(dir, "imported.yaml"), []byte(importedYAML), 0644); err != nil { + t.Fatal(err) + } + + // Create main file with same pipeline name — main should win + mainYAML := ` +imports: + - imported.yaml + +pipelines: + my-pipeline: + steps: + - name: main-step + type: step.log + config: + message: "from main" + +triggers: + my-trigger: + type: http + config: + path: /main +` + mainPath := filepath.Join(dir, "main.yaml") + if err := os.WriteFile(mainPath, []byte(mainYAML), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFromFile(mainPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Main file's pipeline definition should win + pipeline, ok := cfg.Pipelines["my-pipeline"] + if !ok { + t.Fatal("expected 'my-pipeline' pipeline") + } + pMap, ok := pipeline.(map[string]any) + if !ok { + t.Fatal("expected pipeline to be a map") + } + steps, ok := pMap["steps"].([]any) + if !ok || len(steps) == 0 { + t.Fatal("expected pipeline to have steps") + } + step0, ok := steps[0].(map[string]any) + if !ok { + t.Fatal("expected step to be a map") + } + if step0["name"] != "main-step" { + t.Errorf("expected main file's step 'main-step', got %q", step0["name"]) + } + + // Main file's trigger definition should win + trigger, ok := cfg.Triggers["my-trigger"] + if !ok { + t.Fatal("expected 'my-trigger' trigger") + } + tMap, ok := trigger.(map[string]any) + if !ok { + t.Fatal("expected trigger to be a map") + } + tConfig, ok := tMap["config"].(map[string]any) + if !ok { + t.Fatal("expected trigger config to be a map") + } + if tConfig["path"] != "/main" { + t.Errorf("expected main file's trigger path '/main', got %q", tConfig["path"]) + } +} + +func TestLoadFromFile_CircularImport(t *testing.T) { + dir := t.TempDir() + + // a.yaml imports b.yaml + aYAML := ` +imports: + - b.yaml +modules: + - name: mod-a + type: test.a +` + if err := os.WriteFile(filepath.Join(dir, "a.yaml"), []byte(aYAML), 0644); err != nil { + t.Fatal(err) + } + + // b.yaml imports a.yaml — circular! + bYAML := ` +imports: + - a.yaml +modules: + - name: mod-b + type: test.b +` + if err := os.WriteFile(filepath.Join(dir, "b.yaml"), []byte(bYAML), 0644); err != nil { + t.Fatal(err) + } + + _, err := LoadFromFile(filepath.Join(dir, "a.yaml")) + if err == nil { + t.Fatal("expected circular import error, got nil") + } + if !strings.Contains(err.Error(), "circular import") { + t.Errorf("expected error to contain 'circular import', got: %v", err) + } +} + +func TestLoadFromFile_NestedImports(t *testing.T) { + dir := t.TempDir() + + // c.yaml — leaf, no imports + cYAML := ` +modules: + - name: mod-c + type: test.c + +pipelines: + pipeline-c: + steps: + - name: step-c + type: step.log +` + if err := os.WriteFile(filepath.Join(dir, "c.yaml"), []byte(cYAML), 0644); err != nil { + t.Fatal(err) + } + + // b.yaml imports c.yaml + bYAML := ` +imports: + - c.yaml + +modules: + - name: mod-b + type: test.b + +pipelines: + pipeline-b: + steps: + - name: step-b + type: step.log +` + if err := os.WriteFile(filepath.Join(dir, "b.yaml"), []byte(bYAML), 0644); err != nil { + t.Fatal(err) + } + + // a.yaml imports b.yaml + aYAML := ` +imports: + - b.yaml + +modules: + - name: mod-a + type: test.a + +pipelines: + pipeline-a: + steps: + - name: step-a + type: step.log +` + if err := os.WriteFile(filepath.Join(dir, "a.yaml"), []byte(aYAML), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFromFile(filepath.Join(dir, "a.yaml")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should have 3 modules: mod-a (main), mod-b (from b.yaml), mod-c (from c.yaml via b.yaml) + if len(cfg.Modules) != 3 { + t.Errorf("expected 3 modules, got %d", len(cfg.Modules)) + } + + // Main file's module should be first + if cfg.Modules[0].Name != "mod-a" { + t.Errorf("expected first module 'mod-a', got %q", cfg.Modules[0].Name) + } + + // Should have all three pipelines + for _, name := range []string{"pipeline-a", "pipeline-b", "pipeline-c"} { + if _, ok := cfg.Pipelines[name]; !ok { + t.Errorf("expected pipeline %q", name) + } + } +} + +func TestLoadFromFile_ImportNotFound(t *testing.T) { + dir := t.TempDir() + + mainYAML := ` +imports: + - nonexistent.yaml + +modules: + - name: mod-main + type: test.main +` + mainPath := filepath.Join(dir, "main.yaml") + if err := os.WriteFile(mainPath, []byte(mainYAML), 0644); err != nil { + t.Fatal(err) + } + + _, err := LoadFromFile(mainPath) + if err == nil { + t.Fatal("expected error for missing import, got nil") + } + if !strings.Contains(err.Error(), "nonexistent.yaml") { + t.Errorf("expected error to reference 'nonexistent.yaml', got: %v", err) + } +} + +func TestLoadFromFile_NoImports(t *testing.T) { + dir := t.TempDir() + + // Standard config without imports — backward compatibility + mainYAML := ` +modules: + - name: my-server + type: http.server + config: + address: ":8080" + +workflows: + main: + steps: + - name: init + type: step.log + +triggers: + main-trigger: + type: http + config: + path: / + method: GET + +pipelines: + main-pipeline: + steps: + - name: step1 + type: step.log +` + mainPath := filepath.Join(dir, "main.yaml") + if err := os.WriteFile(mainPath, []byte(mainYAML), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFromFile(mainPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(cfg.Modules) != 1 { + t.Errorf("expected 1 module, got %d", len(cfg.Modules)) + } + if cfg.Modules[0].Name != "my-server" { + t.Errorf("expected module 'my-server', got %q", cfg.Modules[0].Name) + } + if _, ok := cfg.Workflows["main"]; !ok { + t.Error("expected 'main' workflow") + } + if _, ok := cfg.Triggers["main-trigger"]; !ok { + t.Error("expected 'main-trigger' trigger") + } + if _, ok := cfg.Pipelines["main-pipeline"]; !ok { + t.Error("expected 'main-pipeline' pipeline") + } +} + +func TestLoadFromFile_ImportRelativePath(t *testing.T) { + dir := t.TempDir() + + // Create a subdirectory for imported files + subDir := filepath.Join(dir, "includes") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatal(err) + } + + // Create imported file in subdirectory + importedYAML := ` +modules: + - name: imported-mod + type: test.imported + +pipelines: + imported-pipeline: + steps: + - name: step1 + type: step.log +` + if err := os.WriteFile(filepath.Join(subDir, "shared.yaml"), []byte(importedYAML), 0644); err != nil { + t.Fatal(err) + } + + // Main file uses relative path to subdirectory + mainYAML := ` +imports: + - includes/shared.yaml + +modules: + - name: main-mod + type: test.main +` + mainPath := filepath.Join(dir, "main.yaml") + if err := os.WriteFile(mainPath, []byte(mainYAML), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFromFile(mainPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(cfg.Modules) != 2 { + t.Errorf("expected 2 modules, got %d", len(cfg.Modules)) + } + if cfg.Modules[0].Name != "main-mod" { + t.Errorf("expected first module 'main-mod', got %q", cfg.Modules[0].Name) + } + if cfg.Modules[1].Name != "imported-mod" { + t.Errorf("expected second module 'imported-mod', got %q", cfg.Modules[1].Name) + } + if _, ok := cfg.Pipelines["imported-pipeline"]; !ok { + t.Error("expected 'imported-pipeline' from import") + } +} + +func TestLoadFromFile_ImportedImportsAlsoImport(t *testing.T) { + dir := t.TempDir() + + // Three-level deep: main -> domain1 -> shared + + // shared.yaml — common modules + sharedYAML := ` +modules: + - name: shared-db + type: storage.sqlite + config: + path: ./shared.db + +platform: + logging: + level: info +` + if err := os.WriteFile(filepath.Join(dir, "shared.yaml"), []byte(sharedYAML), 0644); err != nil { + t.Fatal(err) + } + + // domain1.yaml imports shared.yaml + domain1YAML := ` +imports: + - shared.yaml + +modules: + - name: domain1-service + type: http.server + config: + address: ":8081" + +pipelines: + domain1-handler: + steps: + - name: handle + type: step.log + +platform: + domain1: + feature: enabled +` + if err := os.WriteFile(filepath.Join(dir, "domain1.yaml"), []byte(domain1YAML), 0644); err != nil { + t.Fatal(err) + } + + // main.yaml imports domain1.yaml + mainYAML := ` +imports: + - domain1.yaml + +modules: + - name: main-gateway + type: http.server + config: + address: ":8080" + +workflows: + main: + steps: + - name: init + type: step.log + +platform: + main: + version: "1.0" +` + mainPath := filepath.Join(dir, "main.yaml") + if err := os.WriteFile(mainPath, []byte(mainYAML), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFromFile(mainPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // 3 modules: main-gateway, domain1-service, shared-db + if len(cfg.Modules) != 3 { + t.Errorf("expected 3 modules, got %d", len(cfg.Modules)) + } + if cfg.Modules[0].Name != "main-gateway" { + t.Errorf("expected first module 'main-gateway', got %q", cfg.Modules[0].Name) + } + if cfg.Modules[1].Name != "domain1-service" { + t.Errorf("expected second module 'domain1-service', got %q", cfg.Modules[1].Name) + } + if cfg.Modules[2].Name != "shared-db" { + t.Errorf("expected third module 'shared-db', got %q", cfg.Modules[2].Name) + } + + // Pipeline from domain1 + if _, ok := cfg.Pipelines["domain1-handler"]; !ok { + t.Error("expected 'domain1-handler' pipeline from domain1 import") + } + + // Workflow from main + if _, ok := cfg.Workflows["main"]; !ok { + t.Error("expected 'main' workflow") + } + + // Platform should have all three keys + if cfg.Platform == nil { + t.Fatal("expected platform config") + } + for _, key := range []string{"main", "domain1", "logging"} { + if _, ok := cfg.Platform[key]; !ok { + t.Errorf("expected platform key %q", key) + } + } +} + +func TestLoadFromFile_DiamondImports(t *testing.T) { + dir := t.TempDir() + + // shared.yaml (D) - common dependency imported by both B and C + sharedYAML := ` +modules: + - name: shared-db + type: state.connector +` + if err := os.WriteFile(filepath.Join(dir, "shared.yaml"), []byte(sharedYAML), 0644); err != nil { + t.Fatal(err) + } + + // service-b.yaml (B) imports shared.yaml + serviceBYAML := ` +imports: + - shared.yaml + +modules: + - name: service-b + type: http.handler +` + if err := os.WriteFile(filepath.Join(dir, "service-b.yaml"), []byte(serviceBYAML), 0644); err != nil { + t.Fatal(err) + } + + // service-c.yaml (C) imports shared.yaml + serviceCYAML := ` +imports: + - shared.yaml + +modules: + - name: service-c + type: http.handler +` + if err := os.WriteFile(filepath.Join(dir, "service-c.yaml"), []byte(serviceCYAML), 0644); err != nil { + t.Fatal(err) + } + + // main.yaml (A) imports both B and C + mainYAML := ` +imports: + - service-b.yaml + - service-c.yaml + +modules: + - name: main-gateway + type: http.server +` + mainPath := filepath.Join(dir, "main.yaml") + if err := os.WriteFile(mainPath, []byte(mainYAML), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFromFile(mainPath) + if err != nil { + t.Fatalf("unexpected error loading diamond imports: %v", err) + } + + gotModules := make(map[string]bool) + for _, m := range cfg.Modules { + if gotModules[m.Name] { + t.Errorf("duplicate module %q found in diamond import scenario", m.Name) + } + gotModules[m.Name] = true + } + + for _, name := range []string{"main-gateway", "service-b", "service-c", "shared-db"} { + if !gotModules[name] { + t.Errorf("expected module %q to be loaded in diamond import scenario", name) + } + } + + if len(cfg.Modules) != 4 { + t.Errorf("expected exactly 4 modules (no duplicates), got %d", len(cfg.Modules)) + } +} diff --git a/docs/mcp.md b/docs/mcp.md index ce371a2c..038c24a7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2,6 +2,8 @@ The workflow engine includes a built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that exposes engine functionality to AI assistants and tools. +> **Note**: The MCP server is now integrated into the `wfctl` CLI as the `mcp` subcommand. The standalone `workflow-mcp-server` binary is still available for backward compatibility but the recommended approach is to use `wfctl mcp`. This ensures the MCP server version always matches the CLI and engine version, and benefits from automatic updates via `wfctl update`. + ## Features The MCP server provides: @@ -28,20 +30,33 @@ The MCP server provides: | `workflow://docs/yaml-syntax` | YAML configuration syntax guide | | `workflow://docs/module-reference` | Dynamic module type reference | -## Building +## Installation + +Install `wfctl` (the CLI includes the MCP server): ```bash -# Build the MCP server binary -make build-mcp +# Install via Go +go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest -# Or directly with Go -go build -o workflow-mcp-server ./cmd/workflow-mcp-server +# Or download a pre-built binary from GitHub releases +# https://github.com/GoCodeAlone/workflow/releases/latest -# Or install globally with Go -go install github.com/GoCodeAlone/workflow/cmd/workflow-mcp-server@latest +# Keep wfctl up to date (and thus the MCP server too) +wfctl update ``` -## Installation +### Building from Source + +```bash +# Build wfctl (includes the mcp command) +go build -o wfctl ./cmd/wfctl + +# Build the standalone MCP server binary (legacy) +make build-mcp +# Or: go build -o workflow-mcp-server ./cmd/workflow-mcp-server +``` + +## Configuring AI Clients ### Claude Desktop @@ -50,6 +65,18 @@ Add the following to your Claude Desktop configuration file: **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +```json +{ + "mcpServers": { + "workflow": { + "command": "wfctl", + "args": ["mcp", "-plugin-dir", "/path/to/data/plugins"] + } + } +} +``` + +**Legacy (standalone binary)**: ```json { "mcpServers": { @@ -69,8 +96,8 @@ Add to your VS Code `settings.json`: { "github.copilot.chat.mcpServers": { "workflow": { - "command": "/path/to/workflow-mcp-server", - "args": ["-plugin-dir", "/path/to/data/plugins"] + "command": "wfctl", + "args": ["mcp", "-plugin-dir", "/path/to/data/plugins"] } } } @@ -84,8 +111,8 @@ Add to your Cursor MCP configuration (`.cursor/mcp.json`): { "mcpServers": { "workflow": { - "command": "/path/to/workflow-mcp-server", - "args": ["-plugin-dir", "/path/to/data/plugins"] + "command": "wfctl", + "args": ["mcp", "-plugin-dir", "/path/to/data/plugins"] } } } @@ -96,7 +123,7 @@ Add to your Cursor MCP configuration (`.cursor/mcp.json`): The server communicates over **stdio** using JSON-RPC 2.0. Any MCP-compatible client can connect: ```bash -./workflow-mcp-server -plugin-dir ./data/plugins +wfctl mcp -plugin-dir ./data/plugins ``` ## Usage Examples @@ -131,13 +158,26 @@ Inspect this config and show me the dependency graph... ## Command Line Options ``` -Usage: workflow-mcp-server [options] +Usage: wfctl mcp [options] Options: -plugin-dir string Plugin data directory (default "data/plugins") - -version Show version and exit ``` +## Keeping the MCP Server Up to Date + +Because the MCP server is now part of `wfctl`, you can use the built-in update command to keep everything in sync: + +```bash +# Check for updates +wfctl update --check + +# Install the latest version (replaces the wfctl binary in-place) +wfctl update +``` + +Set `WFCTL_NO_UPDATE_CHECK=1` to suppress automatic update notices. + ## Dynamic Updates The MCP server dynamically reflects the current state of the engine: @@ -153,15 +193,20 @@ This means the MCP server automatically picks up new module types and plugins wi ```bash go test -v ./mcp/ +go test -v -run TestRunMCP ./cmd/wfctl/ ``` ## Architecture ``` -cmd/workflow-mcp-server/main.go → Entry point (stdio transport) +cmd/wfctl/main.go → CLI entry point; registers "mcp" command +cmd/wfctl/mcp.go → "wfctl mcp" command handler (delegates to mcp package) mcp/server.go → MCP server setup, tool handlers, resource handlers mcp/docs.go → Embedded documentation content mcp/server_test.go → Unit tests + +cmd/workflow-mcp-server/main.go → Standalone binary entry point (legacy) ``` The server uses the [mcp-go](https://github.com/mark3labs/mcp-go) library for MCP protocol implementation over stdio transport. + diff --git a/example/go.mod b/example/go.mod index 072feed5..42bf27cf 100644 --- a/example/go.mod +++ b/example/go.mod @@ -18,11 +18,24 @@ require ( cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect cloud.google.com/go/storage v1.60.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/CrisisTextLine/modular/modules/auth v0.4.0 // indirect github.com/CrisisTextLine/modular/modules/cache v0.4.0 // indirect + github.com/CrisisTextLine/modular/modules/chimux v1.4.0 // indirect github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0 // indirect + github.com/CrisisTextLine/modular/modules/httpclient v0.5.0 // indirect + github.com/CrisisTextLine/modular/modules/httpserver v0.4.0 // indirect + github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0 // indirect + github.com/CrisisTextLine/modular/modules/letsencrypt v0.4.0 // indirect + github.com/CrisisTextLine/modular/modules/logmasker v0.3.0 // indirect github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0 // indirect github.com/CrisisTextLine/modular/modules/scheduler v0.4.0 // indirect github.com/DataDog/datadog-go/v5 v5.4.0 // indirect @@ -82,6 +95,8 @@ require ( github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-acme/lego/v4 v4.26.0 // indirect + github.com/go-chi/chi/v5 v5.2.2 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -117,9 +132,11 @@ require ( github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect - github.com/json-iterator/go v1.1.12 // indirect + github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/klauspost/compress v1.18.3 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/miekg/dns v1.1.68 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -133,6 +150,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/prometheus/client_golang v1.19.1 // indirect @@ -144,6 +162,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect @@ -162,12 +181,14 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/api v0.265.0 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect diff --git a/example/go.sum b/example/go.sum index 16d6c141..a61a88d7 100644 --- a/example/go.sum +++ b/example/go.sum @@ -20,8 +20,31 @@ cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVz cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 h1:5YTBM8QDVIBN3sxBil89WfdAAqDZbyJTgh688DSxX5w= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0 h1:MhRfI58HblXzCtWEZCO0feHs8LweePB3s90r7WaR1KU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0/go.mod h1:okZ+ZURbArNdlJ+ptXoyHNuOETzOl1Oww19rm8I2WLA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 h1:zLzoX5+W2l95UJoVwiyNS4dX8vHyQ6x2xRLoBBL9wMk= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0/go.mod h1:wVEOJfGTj0oPAUGA1JuRAvz/lxXQsWW16axmHPP47Bk= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= @@ -30,10 +53,22 @@ github.com/CrisisTextLine/modular/modules/auth v0.4.0 h1:sP7CYgdJPz88M1PrfOz2knw github.com/CrisisTextLine/modular/modules/auth v0.4.0/go.mod h1:0DnUawpxdFCka4BjMhmXubQIjkF4VRwRdN6c64Jbvvo= github.com/CrisisTextLine/modular/modules/cache v0.4.0 h1:vlPXAsucSM1M0RsPly9cWyODouMLQMUwhW/wltQZHZk= github.com/CrisisTextLine/modular/modules/cache v0.4.0/go.mod h1:4irZOGXxUlgJqAnWlpMyPC3C1tM/f5145/wMThYnAsY= +github.com/CrisisTextLine/modular/modules/chimux v1.4.0 h1:lUX7SI3W25jhNzPX8TBrhAQPD8+MYVNN7kaem74WmAw= +github.com/CrisisTextLine/modular/modules/chimux v1.4.0/go.mod h1:9s5ndk4pPWtAMSi53UlQNTpOWfU4QaXwdBGhXTSkTUc= github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 h1:SSeu7rjuECDgFa+iNyndn94YPQxffHxJgfR7U4psz6E= github.com/CrisisTextLine/modular/modules/eventbus v1.7.0/go.mod h1:I1tGf3DmadwyMP2NE2m6XHYl9ebXB9wBc/KZLywTR4c= github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0 h1:bDNWBparvVzXnhLxjFPJ9MDg7N4NUnNOjfn56G/CwGU= github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0/go.mod h1:5DmacIYrhhiN18i2OHyAVBiNKbN2jHuEv2UJoRToMg0= +github.com/CrisisTextLine/modular/modules/httpclient v0.5.0 h1:Wi9YJNNALBgmz2aU9xq8GILXXfmEJaImMrL26suA/c0= +github.com/CrisisTextLine/modular/modules/httpclient v0.5.0/go.mod h1:fxRWgjzYdWgpgkk+Li1RU8Wvhs4t0Gpl9yddFzRxowM= +github.com/CrisisTextLine/modular/modules/httpserver v0.4.0 h1:uFHZK9Pk5yy2+DxxtsAZSb8D9RkvtfV8cSfqwLc4zVw= +github.com/CrisisTextLine/modular/modules/httpserver v0.4.0/go.mod h1:Wr1VWGXhwDYTRobJWmLIyC4/DydRMEaAgA5RWvkDbkg= +github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0 h1:NIhTrDgjhGwMi2D0ukGsd3n/M1W807u6Rhlqm89Sj8Q= +github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0/go.mod h1:TeM3mt/+1X5VmlWF4nZpgp4qCGPmAahQs5jAzuWLbOo= +github.com/CrisisTextLine/modular/modules/letsencrypt v0.4.0 h1:PomfCavLGe/jZynO4wydJQ0B8Muq1R8GqPf9Kwr4Kq0= +github.com/CrisisTextLine/modular/modules/letsencrypt v0.4.0/go.mod h1:8TUUiT4nkEQwIo2Pc5vPAq28xYZliN754KbXqxXoVXw= +github.com/CrisisTextLine/modular/modules/logmasker v0.3.0 h1:m/TXbnpLlNbiE7ZztphvbkmrWzwJX23SGYe/Bx0eb7A= +github.com/CrisisTextLine/modular/modules/logmasker v0.3.0/go.mod h1:eNVM8Hjx/9J1WDnz/2qHT/uFgqEWS+xuCzlP8xB/9rw= github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0 h1:SUJEPA61IbjdUwKdSembQTbX9rKz5v4vmyr/cbvb4tY= github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0/go.mod h1:/jVQz+0c/OSm0KcLElNAQueI5BoLd48l1KHV4Np+RO8= github.com/CrisisTextLine/modular/modules/scheduler v0.4.0 h1:PDYAD+hL7E6mM7YJey9ag1dnTTcJwsepoylxfZY8trw= @@ -154,6 +189,8 @@ github.com/digitalocean/godo v1.175.0 h1:tpfwJFkBzpePxvvFazOn69TXctdxuFlOs7DMVXs github.com/digitalocean/godo v1.175.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -184,6 +221,8 @@ github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-acme/lego/v4 v4.26.0 h1:521aEQxNstXvPQcFDDPrJiFfixcCQuvAvm35R4GbyYA= +github.com/go-acme/lego/v4 v4.26.0/go.mod h1:BQVAWgcyzW4IT9eIKHY/RxYlVhoyKyOMXOkq7jK1eEQ= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= @@ -197,8 +236,8 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= -github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= @@ -241,8 +280,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-memdb v1.3.4 h1:XSL3NR682X/cVk2IeV0d70N4DZ9ljI885xAEU8IoK3c= -github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg= +github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo= +github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= @@ -258,8 +297,8 @@ github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0Yg github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= -github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= @@ -290,8 +329,10 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= @@ -303,10 +344,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= +github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -346,6 +391,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -375,11 +422,13 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -480,6 +529,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= diff --git a/go.mod b/go.mod index 383b6912..4882ea71 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/CrisisTextLine/modular/modules/auth v0.4.0 github.com/CrisisTextLine/modular/modules/cache v0.4.0 github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0 + github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0 github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0 github.com/CrisisTextLine/modular/modules/scheduler v0.4.0 github.com/GoCodeAlone/go-plugin v0.0.0-20260220090904-b4c35f0e4271 @@ -152,7 +153,7 @@ require ( github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect + github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/launchdarkly/ccache v1.1.0 // indirect github.com/launchdarkly/eventsource v1.10.0 // indirect @@ -189,6 +190,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect diff --git a/go.sum b/go.sum index 70e4bb2c..d8931945 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 h1:SSeu7rjuECDgFa+iNyn github.com/CrisisTextLine/modular/modules/eventbus v1.7.0/go.mod h1:I1tGf3DmadwyMP2NE2m6XHYl9ebXB9wBc/KZLywTR4c= github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0 h1:bDNWBparvVzXnhLxjFPJ9MDg7N4NUnNOjfn56G/CwGU= github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0/go.mod h1:5DmacIYrhhiN18i2OHyAVBiNKbN2jHuEv2UJoRToMg0= +github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0 h1:NIhTrDgjhGwMi2D0ukGsd3n/M1W807u6Rhlqm89Sj8Q= +github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0/go.mod h1:TeM3mt/+1X5VmlWF4nZpgp4qCGPmAahQs5jAzuWLbOo= github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0 h1:SUJEPA61IbjdUwKdSembQTbX9rKz5v4vmyr/cbvb4tY= github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0/go.mod h1:/jVQz+0c/OSm0KcLElNAQueI5BoLd48l1KHV4Np+RO8= github.com/CrisisTextLine/modular/modules/scheduler v0.4.0 h1:PDYAD+hL7E6mM7YJey9ag1dnTTcJwsepoylxfZY8trw= @@ -170,6 +172,8 @@ github.com/digitalocean/godo v1.175.0 h1:tpfwJFkBzpePxvvFazOn69TXctdxuFlOs7DMVXs github.com/digitalocean/godo v1.175.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -321,8 +325,8 @@ github.com/jhump/protoreflect v1.16.0 h1:54fZg+49widqXYQ0b+usAFHbMkBGR4PpXrsHc8+ github.com/jhump/protoreflect v1.16.0/go.mod h1:oYPd7nPvcBw/5wlDfm/AVmU9zH9BgqGCI469pGxfj/8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003 h1:vJ0Snvo+SLMY72r5J4sEfkuE7AFbixEP2qRbEcum/wA= github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003/go.mod h1:zNBxMY8P21owkeogJELCLeHIt+voOSduHYTFUbwRAV8= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= @@ -440,6 +444,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= diff --git a/module/pipeline_template.go b/module/pipeline_template.go index c66ed3f3..51870a61 100644 --- a/module/pipeline_template.go +++ b/module/pipeline_template.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "regexp" "strings" "text/template" "time" @@ -40,6 +41,188 @@ func (te *TemplateEngine) templateData(pc *PipelineContext) map[string]any { return data } +// dotChainRe matches dot-access chains like .steps.my-step.field. +// Hyphens are intentionally allowed within identifier segments so that +// hyphenated step names and fields (e.g. .steps.my-step.field) are +// treated as a single chain. This means ambiguous cases like ".x-1" +// are interpreted as a hyphenated identifier ("x-1") rather than as +// subtraction ".x - 1" when applying the auto-fix rewrite. +var dotChainRe = regexp.MustCompile(`\.[a-zA-Z_][a-zA-Z0-9_-]*(?:\.[a-zA-Z_][a-zA-Z0-9_-]*)*`) + +// stringLiteralRe matches double-quoted and backtick-quoted string literals. +// Go templates only support double-quoted and backtick strings (not single-quoted), +// so single quotes are intentionally not handled here. +// Note: Go's regexp package uses RE2 (linear-time matching), so there is no risk +// of catastrophic backtracking / ReDoS with this pattern. +var stringLiteralRe = regexp.MustCompile(`"(?:[^"\\]|\\.)*"` + "|`[^`]*`") + +// preprocessTemplate rewrites dot-access chains containing hyphens into index +// syntax so that Go's text/template parser does not treat hyphens as minus. +// For example: {{ .steps.my-step.field }} → {{ (index .steps "my-step" "field") }} +func preprocessTemplate(tmplStr string) string { + // Quick exit: nothing to do if there are no actions or no hyphens. + if !strings.Contains(tmplStr, "{{") || !strings.Contains(tmplStr, "-") { + return tmplStr + } + + var out strings.Builder + rest := tmplStr + + for { + openIdx := strings.Index(rest, "{{") + if openIdx < 0 { + out.WriteString(rest) + break + } + closeIdx := strings.Index(rest[openIdx:], "}}") + if closeIdx < 0 { + out.WriteString(rest) + break + } + closeIdx += openIdx // absolute position + + // Write text before the action. + out.WriteString(rest[:openIdx]) + + action := rest[openIdx+2 : closeIdx] // content between {{ and }} + + // Skip pure template comments {{/* ... */}}. Only actions whose entire + // content (after trimming) is a block comment are skipped. Mixed actions + // like {{ x /* comment */ y }} are not skipped since they contain code. + trimmed := strings.TrimSpace(action) + if strings.HasPrefix(trimmed, "/*") && strings.HasSuffix(trimmed, "*/") { + out.WriteString("{{") + out.WriteString(action) + out.WriteString("}}") + rest = rest[closeIdx+2:] + continue + } + + // Strip string literals to avoid false matches on quoted hyphens. + var placeholders []string + const placeholderSentinel = "\x00" + stripped := stringLiteralRe.ReplaceAllStringFunc(action, func(m string) string { + placeholders = append(placeholders, m) + return placeholderSentinel + }) + + // Rewrite hyphenated dot-chains in the stripped action. + rewritten := dotChainRe.ReplaceAllStringFunc(stripped, func(chain string) string { + segments := strings.Split(chain[1:], ".") // drop leading dot + hasHyphen := false + for _, seg := range segments { + if strings.Contains(seg, "-") { + hasHyphen = true + break + } + } + if !hasHyphen { + return chain // no hyphens → leave as-is + } + + // Find the first hyphenated segment. + firstHyphen := -1 + for i, seg := range segments { + if strings.Contains(seg, "-") { + firstHyphen = i + break + } + } + + // Build the prefix (non-hyphenated dot-access) and the quoted tail. + var prefix string + if firstHyphen == 0 { + prefix = "." + } else { + prefix = "." + strings.Join(segments[:firstHyphen], ".") + } + + var quoted []string + for _, seg := range segments[firstHyphen:] { + quoted = append(quoted, `"`+seg+`"`) + } + + return "(index " + prefix + " " + strings.Join(quoted, " ") + ")" + }) + + // Restore string literals from placeholders using strings.Index for O(n) scanning. + var restored string + if len(placeholders) > 0 { + phIdx := 0 + var final strings.Builder + remaining := rewritten + for { + idx := strings.Index(remaining, placeholderSentinel) + if idx < 0 { + final.WriteString(remaining) + break + } + final.WriteString(remaining[:idx]) + if phIdx < len(placeholders) { + final.WriteString(placeholders[phIdx]) + phIdx++ + } + remaining = remaining[idx+len(placeholderSentinel):] + } + restored = final.String() + } else { + restored = rewritten + } + + out.WriteString("{{") + out.WriteString(restored) + out.WriteString("}}") + rest = rest[closeIdx+2:] + } + + return out.String() +} + +// funcMapWithContext returns the base template functions plus context-aware +// helper functions (step, trigger) that access PipelineContext data directly. +func (te *TemplateEngine) funcMapWithContext(pc *PipelineContext) template.FuncMap { + fm := templateFuncMap() + + // step accesses step outputs by name and optional nested keys. + // Usage: {{ step "parse-request" "path_params" "id" }} + // Returns nil if the step doesn't exist, a key is missing, or an + // intermediate value is not a map (consistent with missingkey=zero). + fm["step"] = func(name string, keys ...string) any { + stepMap, ok := pc.StepOutputs[name] + if !ok || stepMap == nil { + return nil + } + var val any = stepMap + for _, key := range keys { + m, ok := val.(map[string]any) + if !ok { + return nil + } + val = m[key] + } + return val + } + + // trigger accesses trigger data by nested keys. + // Usage: {{ trigger "path_params" "id" }} + fm["trigger"] = func(keys ...string) any { + if pc.TriggerData == nil { + return nil + } + var val any = map[string]any(pc.TriggerData) + for _, key := range keys { + m, ok := val.(map[string]any) + if !ok { + return nil + } + val = m[key] + } + return val + } + + return fm +} + // Resolve evaluates a template string against a PipelineContext. // If the string does not contain {{ }}, it is returned as-is. func (te *TemplateEngine) Resolve(tmplStr string, pc *PipelineContext) (string, error) { @@ -47,7 +230,9 @@ func (te *TemplateEngine) Resolve(tmplStr string, pc *PipelineContext) (string, return tmplStr, nil } - t, err := template.New("").Funcs(templateFuncMap()).Option("missingkey=zero").Parse(tmplStr) + tmplStr = preprocessTemplate(tmplStr) + + t, err := template.New("").Funcs(te.funcMapWithContext(pc)).Option("missingkey=zero").Parse(tmplStr) if err != nil { return "", fmt.Errorf("template parse error: %w", err) } diff --git a/module/pipeline_template_test.go b/module/pipeline_template_test.go index 029c179b..ff6be420 100644 --- a/module/pipeline_template_test.go +++ b/module/pipeline_template_test.go @@ -366,3 +366,206 @@ func TestTemplateEngine_ResolveMap_DoesNotMutateInput(t *testing.T) { t.Errorf("expected 'resolved', got %v", result["key"]) } } + +// --- preprocessTemplate tests --- + +func TestPreprocessTemplate_HyphenatedStepName(t *testing.T) { + input := "{{ .steps.my-step.field }}" + result := preprocessTemplate(input) + if !strings.Contains(result, `index .steps "my-step" "field"`) { + t.Errorf("expected index rewrite, got %q", result) + } +} + +func TestPreprocessTemplate_NoHyphens(t *testing.T) { + input := "{{ .steps.validate.result }}" + result := preprocessTemplate(input) + if result != input { + t.Errorf("expected unchanged %q, got %q", input, result) + } +} + +func TestPreprocessTemplate_SingleHyphenatedSegment(t *testing.T) { + input := "{{ .my-var }}" + result := preprocessTemplate(input) + if !strings.Contains(result, `index . "my-var"`) { + t.Errorf("expected index rewrite for single hyphenated segment, got %q", result) + } +} + +func TestPreprocessTemplate_MultipleHyphenatedSegments(t *testing.T) { + input := "{{ .steps.my-step.sub-field.more }}" + result := preprocessTemplate(input) + if !strings.Contains(result, `index .steps "my-step" "sub-field" "more"`) { + t.Errorf("expected index rewrite for multiple hyphenated segments, got %q", result) + } +} + +func TestPreprocessTemplate_WithPipe(t *testing.T) { + input := "{{ .steps.my-step.field | lower }}" + result := preprocessTemplate(input) + if !strings.Contains(result, `index .steps "my-step" "field"`) { + t.Errorf("expected index rewrite before pipe, got %q", result) + } + if !strings.Contains(result, "| lower") { + t.Errorf("expected pipe preserved, got %q", result) + } +} + +func TestPreprocessTemplate_WithFunction(t *testing.T) { + input := `{{ default "x" .steps.my-step.field }}` + result := preprocessTemplate(input) + if !strings.Contains(result, `index .steps "my-step" "field"`) { + t.Errorf("expected index rewrite with function, got %q", result) + } + if !strings.Contains(result, `default "x"`) { + t.Errorf("expected function preserved, got %q", result) + } +} + +func TestPreprocessTemplate_StringLiteralsSkipped(t *testing.T) { + input := `{{ index .steps "my-step" "field" }}` + result := preprocessTemplate(input) + if result != input { + t.Errorf("expected unchanged when hyphens are in string literals, got %q", result) + } +} + +func TestPreprocessTemplate_MixedContent(t *testing.T) { + input := "Hello {{ .steps.my-step.name }}!" + result := preprocessTemplate(input) + if !strings.HasPrefix(result, "Hello ") { + t.Errorf("expected text prefix preserved, got %q", result) + } + if !strings.HasSuffix(result, "!") { + t.Errorf("expected text suffix preserved, got %q", result) + } + if !strings.Contains(result, `index .steps "my-step" "name"`) { + t.Errorf("expected index rewrite in action, got %q", result) + } +} + +func TestPreprocessTemplate_NoTemplate(t *testing.T) { + input := "plain text" + result := preprocessTemplate(input) + if result != input { + t.Errorf("expected unchanged %q, got %q", input, result) + } +} + +func TestPreprocessTemplate_AlreadyUsingIndex(t *testing.T) { + input := `{{ index .steps "my-step" "field" }}` + result := preprocessTemplate(input) + if result != input { + t.Errorf("expected unchanged %q, got %q", input, result) + } +} + +// --- step and trigger helper function tests --- + +func TestTemplateEngine_StepFunction(t *testing.T) { + te := NewTemplateEngine() + pc := NewPipelineContext(nil, nil) + pc.MergeStepOutput("validate", map[string]any{"result": "passed"}) + + result, err := te.Resolve(`{{ step "validate" "result" }}`, pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "passed" { + t.Errorf("expected 'passed', got %q", result) + } +} + +func TestTemplateEngine_StepFunctionHyphenated(t *testing.T) { + te := NewTemplateEngine() + pc := NewPipelineContext(nil, nil) + pc.MergeStepOutput("parse-request", map[string]any{ + "path_params": map[string]any{"id": "42"}, + }) + + result, err := te.Resolve(`{{ step "parse-request" "path_params" "id" }}`, pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "42" { + t.Errorf("expected '42', got %q", result) + } +} + +func TestTemplateEngine_StepFunctionDeepNesting(t *testing.T) { + te := NewTemplateEngine() + pc := NewPipelineContext(nil, nil) + pc.MergeStepOutput("parse-request", map[string]any{ + "body": map[string]any{ + "nested": "deep-value", + }, + }) + + result, err := te.Resolve(`{{ step "parse-request" "body" "nested" }}`, pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "deep-value" { + t.Errorf("expected 'deep-value', got %q", result) + } +} + +func TestTemplateEngine_StepFunctionMissing(t *testing.T) { + te := NewTemplateEngine() + pc := NewPipelineContext(nil, nil) + + result, err := te.Resolve(`{{ step "nonexistent" "field" }}`, pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // nil renders as "" with missingkey=zero; just verify no error + _ = result +} + +func TestTemplateEngine_TriggerFunction(t *testing.T) { + te := NewTemplateEngine() + pc := NewPipelineContext(map[string]any{ + "path_params": map[string]any{"id": "99"}, + }, nil) + + result, err := te.Resolve(`{{ trigger "path_params" "id" }}`, pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "99" { + t.Errorf("expected '99', got %q", result) + } +} + +func TestPreprocessTemplate_UnclosedAction(t *testing.T) { + input := "{{ .steps.my-step.field" + result := preprocessTemplate(input) + if result != input { + t.Errorf("unclosed action should be returned unchanged, got %q", result) + } +} + +func TestPreprocessTemplate_EscapedQuotesPreserved(t *testing.T) { + input := `{{ index .steps "my\"step" "field" }}` + result := preprocessTemplate(input) + if !strings.Contains(result, `"my\"step"`) { + t.Errorf("escaped quotes should be preserved, got %q", result) + } +} + +func TestTemplateEngine_HyphenatedResolveEndToEnd(t *testing.T) { + te := NewTemplateEngine() + pc := NewPipelineContext(nil, nil) + pc.MergeStepOutput("parse-request", map[string]any{ + "path_params": map[string]any{"id": "123"}, + }) + + result, err := te.Resolve("{{ .steps.parse-request.path_params.id }}", pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "123" { + t.Errorf("expected '123', got %q", result) + } +} diff --git a/plugins/modularcompat/jsonschema_test.go b/plugins/modularcompat/jsonschema_test.go new file mode 100644 index 00000000..b42a4cec --- /dev/null +++ b/plugins/modularcompat/jsonschema_test.go @@ -0,0 +1,258 @@ +package modularcompat + +import ( + "log/slog" + "os" + "strings" + "testing" + + "github.com/CrisisTextLine/modular" + "github.com/CrisisTextLine/modular/modules/jsonschema" +) + +// userSchema is a sample JSON Schema used across tests to validate user payloads. +const userSchema = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "age": { "type": "integer", "minimum": 0 }, + "email": { "type": "string" } + }, + "required": ["name", "age"], + "additionalProperties": false +}` + +// writeSchemaFile creates a temp file containing schema JSON and returns its path. +// The caller is responsible for removing the file. +func writeSchemaFile(t *testing.T, content string) string { + t.Helper() + f, err := os.CreateTemp("", "wf-schema-*.json") + if err != nil { + t.Fatalf("create temp schema file: %v", err) + } + if _, err := f.WriteString(content); err != nil { + t.Fatalf("write schema file: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("close schema file: %v", err) + } + t.Cleanup(func() { _ = os.Remove(f.Name()) }) + return f.Name() +} + +// newTestApp initialises a modular application with the jsonschema module registered. +// It returns the started Application; the caller must call Stop. +func newTestApp(t *testing.T) modular.Application { + t.Helper() + app := modular.NewStdApplication( + modular.NewStdConfigProvider(nil), + slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})), + ) + app.RegisterModule(jsonschema.NewModule()) + if err := app.Init(); err != nil { + t.Fatalf("app.Init: %v", err) + } + if err := app.Start(); err != nil { + t.Fatalf("app.Start: %v", err) + } + t.Cleanup(func() { + if err := app.Stop(); err != nil { + t.Logf("app.Stop: %v", err) + } + }) + return app +} + +// getJSONSchemaService retrieves the JSONSchemaService registered by the jsonschema module. +func getJSONSchemaService(t *testing.T, app modular.Application) jsonschema.JSONSchemaService { + t.Helper() + var svc jsonschema.JSONSchemaService + if err := app.GetService("jsonschema.service", &svc); err != nil { + t.Fatalf("GetService(jsonschema.service): %v", err) + } + return svc +} + +// TestJSONSchemaModuleWiredIntoApp verifies that the jsonschema module registered +// via the modularcompat plugin factory initialises inside a modular application +// and exposes its service. +func TestJSONSchemaModuleWiredIntoApp(t *testing.T) { + p := New() + factories := p.ModuleFactories() + factory, ok := factories["jsonschema.modular"] + if !ok { + t.Fatal("jsonschema.modular factory not found in modularcompat plugin") + } + + app := modular.NewStdApplication( + modular.NewStdConfigProvider(nil), + slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})), + ) + app.RegisterModule(factory("schema-module", nil)) + if err := app.Init(); err != nil { + t.Fatalf("app.Init: %v", err) + } + t.Cleanup(func() { _ = app.Stop() }) + + var svc jsonschema.JSONSchemaService + if err := app.GetService("jsonschema.service", &svc); err != nil { + t.Fatalf("jsonschema.service not available after Init: %v", err) + } +} + +// TestJSONSchemaValidateBytes tests validating raw JSON bytes against a compiled schema. +func TestJSONSchemaValidateBytes(t *testing.T) { + app := newTestApp(t) + svc := getJSONSchemaService(t, app) + + schemaPath := writeSchemaFile(t, userSchema) + schema, err := svc.CompileSchema(schemaPath) + if err != nil { + t.Fatalf("CompileSchema: %v", err) + } + + cases := []struct { + name string + payload string + wantErr bool + }{ + {"valid payload", `{"name":"Alice","age":30}`, false}, + {"valid with optional email", `{"name":"Bob","age":25,"email":"bob@example.com"}`, false}, + {"missing required field age", `{"name":"Charlie"}`, true}, + {"missing required field name", `{"age":40}`, true}, + {"extra property rejected", `{"name":"Dave","age":20,"phone":"555"}`, true}, + {"wrong type for age", `{"name":"Eve","age":"old"}`, true}, + {"empty object", `{}`, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := svc.ValidateBytes(schema, []byte(tc.payload)) + if tc.wantErr && err == nil { + t.Errorf("expected validation error, got nil") + } + if !tc.wantErr && err != nil { + t.Errorf("unexpected validation error: %v", err) + } + }) + } +} + +// TestJSONSchemaValidateReader tests validating JSON from an io.Reader. +func TestJSONSchemaValidateReader(t *testing.T) { + app := newTestApp(t) + svc := getJSONSchemaService(t, app) + + schemaPath := writeSchemaFile(t, userSchema) + schema, err := svc.CompileSchema(schemaPath) + if err != nil { + t.Fatalf("CompileSchema: %v", err) + } + + t.Run("valid", func(t *testing.T) { + if err := svc.ValidateReader(schema, strings.NewReader(`{"name":"Alice","age":30}`)); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("invalid", func(t *testing.T) { + if err := svc.ValidateReader(schema, strings.NewReader(`{"name":"Alice"}`)); err == nil { + t.Error("expected validation error, got nil") + } + }) +} + +// TestJSONSchemaValidateInterface tests validating a Go map (unmarshaled JSON) directly. +func TestJSONSchemaValidateInterface(t *testing.T) { + app := newTestApp(t) + svc := getJSONSchemaService(t, app) + + schemaPath := writeSchemaFile(t, userSchema) + schema, err := svc.CompileSchema(schemaPath) + if err != nil { + t.Fatalf("CompileSchema: %v", err) + } + + valid := map[string]any{"name": "Alice", "age": float64(30)} + if err := svc.ValidateInterface(schema, valid); err != nil { + t.Errorf("unexpected error for valid interface: %v", err) + } + + invalid := map[string]any{"name": "Alice"} // missing "age" + if err := svc.ValidateInterface(schema, invalid); err == nil { + t.Error("expected validation error for invalid interface, got nil") + } +} + +// TestJSONSchemaRegistryWorkflow simulates the schema-registry + validator use case +// described in the PR review: compile multiple schemas once into an in-memory +// "registry" map, then validate incoming payloads using the right schema by name. +func TestJSONSchemaRegistryWorkflow(t *testing.T) { + app := newTestApp(t) + svc := getJSONSchemaService(t, app) + + // --- schema definitions --- + schemas := map[string]string{ + "user": `{ + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer", "minimum": 0} + }, + "required": ["name", "age"], + "additionalProperties": false + }`, + "product": `{ + "type": "object", + "properties": { + "id": {"type": "string"}, + "price": {"type": "number", "minimum": 0} + }, + "required": ["id", "price"], + "additionalProperties": false + }`, + } + + // Compile all schemas once and store in a registry map. + schemaRegistry := make(map[string]jsonschema.Schema, len(schemas)) + for name, def := range schemas { + path := writeSchemaFile(t, def) + compiled, err := svc.CompileSchema(path) + if err != nil { + t.Fatalf("CompileSchema(%s): %v", name, err) + } + schemaRegistry[name] = compiled + } + + // --- validation table --- + type testCase struct { + schemaName string + payload string + wantErr bool + } + cases := []testCase{ + {"user", `{"name":"Alice","age":30}`, false}, + {"user", `{"name":"Bob"}`, true}, // missing age + {"user", `{"name":"Carol","age":25,"x":"y"}`, true}, // extra field + {"product", `{"id":"sku-1","price":9.99}`, false}, + {"product", `{"id":"sku-2"}`, true}, // missing price + {"product", `{"id":"sku-3","price":-1}`, true}, // price below minimum + } + + for _, tc := range cases { + t.Run(tc.schemaName+"/"+tc.payload, func(t *testing.T) { + sc, ok := schemaRegistry[tc.schemaName] + if !ok { + t.Fatalf("schema %q not in registry", tc.schemaName) + } + err := svc.ValidateBytes(sc, []byte(tc.payload)) + if tc.wantErr && err == nil { + t.Errorf("expected validation error, got nil") + } + if !tc.wantErr && err != nil { + t.Errorf("unexpected validation error: %v", err) + } + }) + } +} diff --git a/plugins/modularcompat/plugin.go b/plugins/modularcompat/plugin.go index a906934c..2af3e88c 100644 --- a/plugins/modularcompat/plugin.go +++ b/plugins/modularcompat/plugin.go @@ -1,10 +1,11 @@ // Package modularcompat provides a plugin that registers CrisisTextLine/modular -// framework module adapters: scheduler.modular, cache.modular. +// framework module adapters: scheduler.modular, cache.modular, jsonschema.modular. package modularcompat import ( "github.com/CrisisTextLine/modular" "github.com/CrisisTextLine/modular/modules/cache" + "github.com/CrisisTextLine/modular/modules/jsonschema" "github.com/CrisisTextLine/modular/modules/scheduler" "github.com/GoCodeAlone/workflow/capability" "github.com/GoCodeAlone/workflow/plugin" @@ -22,15 +23,15 @@ func New() *Plugin { BaseNativePlugin: plugin.BaseNativePlugin{ PluginName: "modular-compat", PluginVersion: "1.0.0", - PluginDescription: "CrisisTextLine/modular framework compatibility modules (scheduler, cache)", + PluginDescription: "CrisisTextLine/modular framework compatibility modules (scheduler, cache, jsonschema)", }, Manifest: plugin.PluginManifest{ Name: "modular-compat", Version: "1.0.0", Author: "GoCodeAlone", - Description: "CrisisTextLine/modular framework compatibility modules (scheduler, cache)", + Description: "CrisisTextLine/modular framework compatibility modules (scheduler, cache, jsonschema)", Tier: plugin.TierCore, - ModuleTypes: []string{"scheduler.modular", "cache.modular"}, + ModuleTypes: []string{"cache.modular", "jsonschema.modular", "scheduler.modular"}, Capabilities: []plugin.CapabilityDecl{ {Name: "scheduler", Role: "provider", Priority: 30}, {Name: "cache", Role: "provider", Priority: 30}, @@ -57,11 +58,14 @@ func (p *Plugin) Capabilities() []capability.Contract { // ModuleFactories returns module factories that delegate to the modular framework modules. func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { return map[string]plugin.ModuleFactory{ - "scheduler.modular": func(_ string, _ map[string]any) modular.Module { - return scheduler.NewModule() - }, "cache.modular": func(_ string, _ map[string]any) modular.Module { return cache.NewModule() }, + "jsonschema.modular": func(_ string, _ map[string]any) modular.Module { + return jsonschema.NewModule() + }, + "scheduler.modular": func(_ string, _ map[string]any) modular.Module { + return scheduler.NewModule() + }, } } diff --git a/plugins/modularcompat/plugin_test.go b/plugins/modularcompat/plugin_test.go index 257bef1c..2aa681e8 100644 --- a/plugins/modularcompat/plugin_test.go +++ b/plugins/modularcompat/plugin_test.go @@ -30,13 +30,17 @@ func TestModuleFactories(t *testing.T) { p := New() factories := p.ModuleFactories() - for _, name := range []string{"scheduler.modular", "cache.modular"} { + for _, name := range []string{ + "cache.modular", + "jsonschema.modular", + "scheduler.modular", + } { if _, ok := factories[name]; !ok { t.Errorf("missing module factory: %s", name) } } - if len(factories) != 2 { - t.Errorf("expected 2 module factories, got %d", len(factories)) + if len(factories) != 3 { + t.Errorf("expected 3 module factories, got %d", len(factories)) } } @@ -58,6 +62,15 @@ func TestCacheModuleFactory(t *testing.T) { } } +func TestJSONSchemaModuleFactory(t *testing.T) { + p := New() + factories := p.ModuleFactories() + mod := factories["jsonschema.modular"]("test-jsonschema", nil) + if mod == nil { + t.Fatal("jsonschema.modular factory returned nil") + } +} + func TestPluginLoads(t *testing.T) { p := New() loader := plugin.NewPluginLoader(capability.NewRegistry(), schema.NewModuleSchemaRegistry()) @@ -66,7 +79,7 @@ func TestPluginLoads(t *testing.T) { } modules := loader.ModuleFactories() - if len(modules) != 2 { - t.Fatalf("expected 2 module factories after load, got %d", len(modules)) + if len(modules) != 3 { + t.Fatalf("expected 3 module factories after load, got %d", len(modules)) } } diff --git a/schema/module_schema.go b/schema/module_schema.go index 13b960c1..a0f3939a 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -472,6 +472,16 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { ConfigFields: []ConfigFieldDef{}, }) + r.Register(&ModuleSchema{ + Type: "jsonschema.modular", + Label: "JSON Schema Validator", + Category: "validation", + Description: "CrisisTextLine/modular JSON Schema validation module", + Inputs: []ServiceIODef{{Name: "data", Type: "any", Description: "Data to validate against schema"}}, + Outputs: []ServiceIODef{{Name: "validator", Type: "JSONSchemaService", Description: "JSON Schema validation service"}}, + ConfigFields: []ConfigFieldDef{}, + }) + // ---- Database Category ---- r.Register(&ModuleSchema{ diff --git a/schema/schema.go b/schema/schema.go index 3dc757b1..febdfbb1 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -120,6 +120,7 @@ var coreModuleTypes = []string{ "http.router", "http.server", "http.simple_proxy", + "jsonschema.modular", "license.validator", "log.collector", "messaging.broker", From 334af9b7ec9320d3e4381b1e272a15a26bc20623 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:48:37 +0000 Subject: [PATCH 4/5] Fix CI failures and apply review feedback: CamelToSnake acronyms, test cleanup, example config keys Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- cmd/wfctl/main_test.go | 5 +++++ cmd/wfctl/template_validate_test.go | 4 ++++ example/ui-build-and-serve.yaml | 5 ++--- schema/schema_test.go | 3 ++- schema/validate.go | 21 +++++++++++++++++++-- 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/cmd/wfctl/main_test.go b/cmd/wfctl/main_test.go index ffe4f005..855aa2e1 100644 --- a/cmd/wfctl/main_test.go +++ b/cmd/wfctl/main_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/GoCodeAlone/workflow/schema" ) func writeTestConfig(t *testing.T, dir, name, content string) string { @@ -313,4 +315,7 @@ modules: if err := runValidate([]string{"--plugin-dir", pluginsDir, path}); err != nil { t.Errorf("expected valid config with --plugin-dir, got: %v", err) } + t.Cleanup(func() { + schema.UnregisterModuleType("custom.ext.validate.testonly") + }) } diff --git a/cmd/wfctl/template_validate_test.go b/cmd/wfctl/template_validate_test.go index fefd3b03..049c5dff 100644 --- a/cmd/wfctl/template_validate_test.go +++ b/cmd/wfctl/template_validate_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/schema" ) func TestRunTemplateValidateAllTemplates(t *testing.T) { @@ -333,6 +334,9 @@ modules: if err := runTemplateValidate([]string{"-plugin-dir", pluginsDir, "-config", configPath}); err != nil { t.Errorf("expected valid config with --plugin-dir, got: %v", err) } + t.Cleanup(func() { + schema.UnregisterModuleType("custom.external.module") + }) } // --- Pipeline template expression linting tests --- diff --git a/example/ui-build-and-serve.yaml b/example/ui-build-and-serve.yaml index 930d60c7..8e76bcc0 100644 --- a/example/ui-build-and-serve.yaml +++ b/example/ui-build-and-serve.yaml @@ -54,9 +54,8 @@ modules: config: root: "./ui_built" prefix: "/" - spa: true - cache_max_age: 3600 - security_headers: true + spaFallback: true + cacheMaxAge: 3600 workflows: http: diff --git a/schema/schema_test.go b/schema/schema_test.go index 81b05326..06384866 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -798,7 +798,8 @@ func TestCamelToSnake(t *testing.T) { {"address", "address"}, {"rootDir", "root_dir"}, {"spaFallback", "spa_fallback"}, - {"webhookURL", "webhook_u_r_l"}, // consecutive caps — each uppercase letter gets underscore + {"webhookURL", "webhook_url"}, // consecutive caps (acronym) treated as single word + {"HTTPRequest", "http_request"}, // leading acronym {"already_snake", "already_snake"}, } for _, c := range cases { diff --git a/schema/validate.go b/schema/validate.go index 9aa31106..21c23eb8 100644 --- a/schema/validate.go +++ b/schema/validate.go @@ -434,12 +434,29 @@ func camelToSnake(s string) string { // CamelToSnake converts a camelCase identifier to its snake_case equivalent. // For example: "contentType" → "content_type", "dbPath" → "db_path". +// It treats consecutive uppercase letters (acronyms) as a single word: +// "webhookURL" → "webhook_url", "HTTPRequest" → "http_request". func CamelToSnake(s string) string { var b strings.Builder - for i, r := range s { + + runes := []rune(s) + for i, r := range runes { if i > 0 && unicode.IsUpper(r) { - b.WriteByte('_') + prev := runes[i-1] + var next rune + if i+1 < len(runes) { + next = runes[i+1] + } + + // Insert underscore at word boundaries: + // 1. lower/digit → upper (e.g., "dbPath" → "db_path") + // 2. acronym → word, before the last upper (e.g., "HTTPRequest" → "http_request") + if unicode.IsLower(prev) || unicode.IsDigit(prev) || + (unicode.IsUpper(prev) && next != 0 && unicode.IsLower(next)) { + b.WriteByte('_') + } } + b.WriteRune(unicode.ToLower(r)) } return b.String() From 5f7513820fc3dd3630c3d1f99dedfe93a88196f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:53:01 +0000 Subject: [PATCH 5/5] Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- example/go.mod | 23 ++++------------------- example/go.sum | 47 +++-------------------------------------------- 2 files changed, 7 insertions(+), 63 deletions(-) diff --git a/example/go.mod b/example/go.mod index 42bf27cf..b02fbb93 100644 --- a/example/go.mod +++ b/example/go.mod @@ -18,24 +18,12 @@ require ( cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect cloud.google.com/go/storage v1.60.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/CrisisTextLine/modular/modules/auth v0.4.0 // indirect github.com/CrisisTextLine/modular/modules/cache v0.4.0 // indirect - github.com/CrisisTextLine/modular/modules/chimux v1.4.0 // indirect github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0 // indirect - github.com/CrisisTextLine/modular/modules/httpclient v0.5.0 // indirect - github.com/CrisisTextLine/modular/modules/httpserver v0.4.0 // indirect github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0 // indirect - github.com/CrisisTextLine/modular/modules/letsencrypt v0.4.0 // indirect - github.com/CrisisTextLine/modular/modules/logmasker v0.3.0 // indirect github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0 // indirect github.com/CrisisTextLine/modular/modules/scheduler v0.4.0 // indirect github.com/DataDog/datadog-go/v5 v5.4.0 // indirect @@ -95,12 +83,11 @@ require ( github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-acme/lego/v4 v4.26.0 // indirect - github.com/go-chi/chi/v5 v5.2.2 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gobwas/glob v0.2.3 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/golobby/cast v1.3.3 // indirect @@ -112,6 +99,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect @@ -119,6 +107,7 @@ require ( github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/hashicorp/vault/api v1.22.0 // indirect github.com/itchyny/gojq v0.12.18 // indirect @@ -134,9 +123,7 @@ require ( github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/klauspost/compress v1.18.3 // indirect - github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/miekg/dns v1.1.68 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -150,7 +137,6 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/prometheus/client_golang v1.19.1 // indirect @@ -163,6 +149,7 @@ require ( github.com/robfig/cron/v3 v3.0.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect @@ -181,14 +168,12 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.42.0 // indirect google.golang.org/api v0.265.0 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect diff --git a/example/go.sum b/example/go.sum index a61a88d7..a907e5fd 100644 --- a/example/go.sum +++ b/example/go.sum @@ -20,31 +20,8 @@ cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVz cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= -github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 h1:5YTBM8QDVIBN3sxBil89WfdAAqDZbyJTgh688DSxX5w= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0 h1:MhRfI58HblXzCtWEZCO0feHs8LweePB3s90r7WaR1KU= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0/go.mod h1:okZ+ZURbArNdlJ+ptXoyHNuOETzOl1Oww19rm8I2WLA= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 h1:zLzoX5+W2l95UJoVwiyNS4dX8vHyQ6x2xRLoBBL9wMk= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0/go.mod h1:wVEOJfGTj0oPAUGA1JuRAvz/lxXQsWW16axmHPP47Bk= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= -github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= -github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= -github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/CrisisTextLine/modular v1.11.11 h1:6rx271wWZ1r+RoPWuQRmhvpd5kmgGPAk1qYlX3kFsYs= @@ -53,22 +30,12 @@ github.com/CrisisTextLine/modular/modules/auth v0.4.0 h1:sP7CYgdJPz88M1PrfOz2knw github.com/CrisisTextLine/modular/modules/auth v0.4.0/go.mod h1:0DnUawpxdFCka4BjMhmXubQIjkF4VRwRdN6c64Jbvvo= github.com/CrisisTextLine/modular/modules/cache v0.4.0 h1:vlPXAsucSM1M0RsPly9cWyODouMLQMUwhW/wltQZHZk= github.com/CrisisTextLine/modular/modules/cache v0.4.0/go.mod h1:4irZOGXxUlgJqAnWlpMyPC3C1tM/f5145/wMThYnAsY= -github.com/CrisisTextLine/modular/modules/chimux v1.4.0 h1:lUX7SI3W25jhNzPX8TBrhAQPD8+MYVNN7kaem74WmAw= -github.com/CrisisTextLine/modular/modules/chimux v1.4.0/go.mod h1:9s5ndk4pPWtAMSi53UlQNTpOWfU4QaXwdBGhXTSkTUc= github.com/CrisisTextLine/modular/modules/eventbus v1.7.0 h1:SSeu7rjuECDgFa+iNyndn94YPQxffHxJgfR7U4psz6E= github.com/CrisisTextLine/modular/modules/eventbus v1.7.0/go.mod h1:I1tGf3DmadwyMP2NE2m6XHYl9ebXB9wBc/KZLywTR4c= github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0 h1:bDNWBparvVzXnhLxjFPJ9MDg7N4NUnNOjfn56G/CwGU= github.com/CrisisTextLine/modular/modules/eventbus/v2 v2.0.0/go.mod h1:5DmacIYrhhiN18i2OHyAVBiNKbN2jHuEv2UJoRToMg0= -github.com/CrisisTextLine/modular/modules/httpclient v0.5.0 h1:Wi9YJNNALBgmz2aU9xq8GILXXfmEJaImMrL26suA/c0= -github.com/CrisisTextLine/modular/modules/httpclient v0.5.0/go.mod h1:fxRWgjzYdWgpgkk+Li1RU8Wvhs4t0Gpl9yddFzRxowM= -github.com/CrisisTextLine/modular/modules/httpserver v0.4.0 h1:uFHZK9Pk5yy2+DxxtsAZSb8D9RkvtfV8cSfqwLc4zVw= -github.com/CrisisTextLine/modular/modules/httpserver v0.4.0/go.mod h1:Wr1VWGXhwDYTRobJWmLIyC4/DydRMEaAgA5RWvkDbkg= github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0 h1:NIhTrDgjhGwMi2D0ukGsd3n/M1W807u6Rhlqm89Sj8Q= github.com/CrisisTextLine/modular/modules/jsonschema v1.4.0/go.mod h1:TeM3mt/+1X5VmlWF4nZpgp4qCGPmAahQs5jAzuWLbOo= -github.com/CrisisTextLine/modular/modules/letsencrypt v0.4.0 h1:PomfCavLGe/jZynO4wydJQ0B8Muq1R8GqPf9Kwr4Kq0= -github.com/CrisisTextLine/modular/modules/letsencrypt v0.4.0/go.mod h1:8TUUiT4nkEQwIo2Pc5vPAq28xYZliN754KbXqxXoVXw= -github.com/CrisisTextLine/modular/modules/logmasker v0.3.0 h1:m/TXbnpLlNbiE7ZztphvbkmrWzwJX23SGYe/Bx0eb7A= -github.com/CrisisTextLine/modular/modules/logmasker v0.3.0/go.mod h1:eNVM8Hjx/9J1WDnz/2qHT/uFgqEWS+xuCzlP8xB/9rw= github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0 h1:SUJEPA61IbjdUwKdSembQTbX9rKz5v4vmyr/cbvb4tY= github.com/CrisisTextLine/modular/modules/reverseproxy/v2 v2.2.0/go.mod h1:/jVQz+0c/OSm0KcLElNAQueI5BoLd48l1KHV4Np+RO8= github.com/CrisisTextLine/modular/modules/scheduler v0.4.0 h1:PDYAD+hL7E6mM7YJey9ag1dnTTcJwsepoylxfZY8trw= @@ -221,8 +188,6 @@ github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-acme/lego/v4 v4.26.0 h1:521aEQxNstXvPQcFDDPrJiFfixcCQuvAvm35R4GbyYA= -github.com/go-acme/lego/v4 v4.26.0/go.mod h1:BQVAWgcyzW4IT9eIKHY/RxYlVhoyKyOMXOkq7jK1eEQ= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= @@ -294,9 +259,12 @@ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -331,8 +299,6 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= -github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= -github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= @@ -344,14 +310,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= -github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -391,8 +353,6 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -529,7 +489,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=