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..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 { @@ -185,6 +187,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 +284,38 @@ 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) + } + t.Cleanup(func() { + schema.UnregisterModuleType("custom.ext.validate.testonly") + }) +} 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 ee58e761..97bf929d 100644 --- a/cmd/wfctl/template_validate.go +++ b/cmd/wfctl/template_validate.go @@ -11,6 +11,7 @@ import ( "text/template" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/schema" "gopkg.in/yaml.v3" ) @@ -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++ - // Warn on unknown config fields + // 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 8c4c6413..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) { @@ -236,6 +237,108 @@ func TestRunTemplateUnknownSubcommand(t *testing.T) { } } +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) + } + t.Cleanup(func() { + schema.UnregisterModuleType("custom.external.module") + }) +} + // --- Pipeline template expression linting tests --- func TestValidateConfigWithValidStepRefs(t *testing.T) { 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/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= 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/module_schema.go b/schema/module_schema.go index 0303a018..a0f3939a 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -1026,7 +1026,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"}, @@ -1037,7 +1037,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 064a8478..4f8ea0f6 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -5,6 +5,9 @@ package schema import ( "encoding/json" + "fmt" + "os" + "path/filepath" "sort" "sync" ) @@ -368,6 +371,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 +} + // moduleIfThen builds an if/then conditional schema for a specific module type // that adds per-type config property validation. func moduleIfThen(moduleType string, ms *ModuleSchema) *Schema { diff --git a/schema/schema_test.go b/schema/schema_test.go index c8e9b150..06384866 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,173 @@ 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_url"}, // consecutive caps (acronym) treated as single word + {"HTTPRequest", "http_request"}, // leading acronym + {"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..21c23eb8 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,39 @@ 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". +// 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 + + runes := []rune(s) + for i, r := range runes { + if i > 0 && unicode.IsUpper(r) { + 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() +}