package cli import ( "fmt" "os" "path/filepath" "regexp" "strings" "github.com/charmbracelet/glamour" "github.com/j3ssie/osmedeus/v5/internal/config" "github.com/j3ssie/osmedeus/v5/internal/core" "github.com/j3ssie/osmedeus/v5/internal/parser" "github.com/j3ssie/osmedeus/v5/internal/terminal" "github.com/spf13/cobra" ) // workflowCmd represents the workflow command var workflowCmd = &cobra.Command{ Use: "workflow", Short: "Manage workflows", Long: UsageWorkflow(), RunE: func(cmd *cobra.Command, args []string) error { // Default to 'list' when no subcommand is specified return workflowListCmd.RunE(cmd, args) }, } // workflowListCmd lists available workflows var workflowListCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List available workflows", RunE: func(cmd *cobra.Command, args []string) error { cfg := config.Get() if cfg == nil { return fmt.Errorf("configuration not loaded") } loader := parser.NewLoader(cfg.WorkflowsPath) // List flows flows, err := loader.ListFlows() if err != nil { return fmt.Errorf("failed to list flows: %w", err) } // List modules modules, err := loader.ListModules() if err != nil { return fmt.Errorf("failed to list modules: %w", err) } // Table format output printer := terminal.NewPrinter() fmt.Println() title := fmt.Sprintf("Available Workflows (%s)", terminal.Gray(cfg.WorkflowsPath)) if len(filterTags) > 0 { title = fmt.Sprintf("Available Workflows - Filtered by tags: %s", terminal.Cyan(strings.Join(filterTags, ", "))) } fmt.Printf("%s %s\n", terminal.InfoSymbol(), terminal.Bold(title)) // Combined table for all workflows if len(flows) > 0 || len(modules) > 0 { // Collect all workflow data first to calculate column widths type workflowRow struct { name string colorName string // name with color codes wfType string desc string reqParams string steps string // step/module count tags string } var rows []workflowRow uniqueTags := make(map[string]bool) // Collect unique tags across all workflows // Track workflows with errors for verbose mode type workflowError struct { name string err error } var workflowErrors []workflowError // Load flows for _, f := range flows { wf, err := loader.LoadWorkflow(f) if err != nil { workflowErrors = append(workflowErrors, workflowError{name: f, err: err}) continue } // Skip if tags filter is specified and workflow doesn't match if len(filterTags) > 0 && !hasMatchingTags(wf, filterTags) { continue } desc := "-" if wf.Description != "" { desc = truncateString(wf.Description, 50) } reqParams := getRequiredParams(wf) steps := "-" // Count modules for flows if len(wf.Modules) > 0 { steps = fmt.Sprintf("%d modules", len(wf.Modules)) } tags := "-" if len(wf.Tags) > 0 { tags = truncateString(strings.Join(wf.Tags, ", "), 25) // Collect unique tags (from raw tags, not truncated) for _, tag := range wf.Tags { uniqueTags[tag] = true } } name := f colorName := f if f == "general" { name = f + " (default)" colorName = terminal.Green(name) } rows = append(rows, workflowRow{name, colorName, "flow", desc, reqParams, steps, tags}) } // Load modules for _, m := range modules { wf, err := loader.LoadWorkflow(m) if err != nil { workflowErrors = append(workflowErrors, workflowError{name: m, err: err}) continue } // Skip if tags filter is specified and workflow doesn't match if len(filterTags) > 0 && !hasMatchingTags(wf, filterTags) { continue } desc := "-" if wf.Description != "" { desc = truncateString(wf.Description, 50) } reqParams := getRequiredParams(wf) steps := "-" // Count steps for modules if len(wf.Steps) > 0 { steps = fmt.Sprintf("%d steps", len(wf.Steps)) } tags := "-" if len(wf.Tags) > 0 { tags = truncateString(strings.Join(wf.Tags, ", "), 25) // Collect unique tags (from raw tags, not truncated) for _, tag := range wf.Tags { uniqueTags[tag] = true } } rows = append(rows, workflowRow{m, m, "module", desc, reqParams, steps, tags}) } // Check if any workflows matched the filter if len(rows) == 0 && len(filterTags) > 0 { fmt.Println() printer.Warning("No workflows found with tags: %s", strings.Join(filterTags, ", ")) fmt.Println() return nil } // Calculate max widths nameWidth := len("Name") typeWidth := len("Type") descWidth := len("Description") paramsWidth := len("Required Params") stepsWidth := len("Steps") tagsWidth := len("Tags") for _, r := range rows { if len(r.name) > nameWidth { nameWidth = len(r.name) } if len(r.wfType) > typeWidth { typeWidth = len(r.wfType) } if len(r.desc) > descWidth { descWidth = len(r.desc) } if len(r.reqParams) > paramsWidth { paramsWidth = len(r.reqParams) } if len(r.steps) > stepsWidth { stepsWidth = len(r.steps) } if showTags && len(r.tags) > tagsWidth { tagsWidth = len(r.tags) } } // Print markdown table with styled headers fmt.Println() if showTags { // With Tags column fmt.Printf("| %s%s | %s%s | %s%s | %s%s | %s%s | %s%s |\n", terminal.Bold("Name"), strings.Repeat(" ", nameWidth-4), terminal.Bold("Type"), strings.Repeat(" ", typeWidth-4), terminal.Bold("Description"), strings.Repeat(" ", descWidth-11), terminal.Bold("Required Params"), strings.Repeat(" ", paramsWidth-15), terminal.Bold("Steps"), strings.Repeat(" ", stepsWidth-5), terminal.Bold("Tags"), strings.Repeat(" ", tagsWidth-4)) fmt.Printf("|-%s-|-%s-|-%s-|-%s-|-%s-|-%s-|\n", strings.Repeat("-", nameWidth), strings.Repeat("-", typeWidth), strings.Repeat("-", descWidth), strings.Repeat("-", paramsWidth), strings.Repeat("-", stepsWidth), strings.Repeat("-", tagsWidth)) } else { // Without Tags column (default) fmt.Printf("| %s%s | %s%s | %s%s | %s%s | %s%s |\n", terminal.Bold("Name"), strings.Repeat(" ", nameWidth-4), terminal.Bold("Type"), strings.Repeat(" ", typeWidth-4), terminal.Bold("Description"), strings.Repeat(" ", descWidth-11), terminal.Bold("Required Params"), strings.Repeat(" ", paramsWidth-15), terminal.Bold("Steps"), strings.Repeat(" ", stepsWidth-5)) fmt.Printf("|-%s-|-%s-|-%s-|-%s-|-%s-|\n", strings.Repeat("-", nameWidth), strings.Repeat("-", typeWidth), strings.Repeat("-", descWidth), strings.Repeat("-", paramsWidth), strings.Repeat("-", stepsWidth)) } for _, r := range rows { // Calculate padding needed (color codes don't take visual space) namePad := nameWidth - len(r.name) var colorType string if r.wfType == "flow" { colorType = terminal.Cyan(r.wfType) } else { colorType = terminal.Yellow(r.wfType) } typePad := typeWidth - len(r.wfType) colorSteps := terminal.Gray(r.steps) stepsPad := stepsWidth - len(r.steps) if showTags { colorTags := terminal.Gray(r.tags) tagsPad := tagsWidth - len(r.tags) fmt.Printf("| %s%s | %s%s | %-*s | %-*s | %s%s | %s%s |\n", r.colorName, strings.Repeat(" ", namePad), colorType, strings.Repeat(" ", typePad), descWidth, r.desc, paramsWidth, r.reqParams, colorSteps, strings.Repeat(" ", stepsPad), colorTags, strings.Repeat(" ", tagsPad)) } else { fmt.Printf("| %s%s | %s%s | %-*s | %-*s | %s%s |\n", r.colorName, strings.Repeat(" ", namePad), colorType, strings.Repeat(" ", typePad), descWidth, r.desc, paramsWidth, r.reqParams, colorSteps, strings.Repeat(" ", stepsPad)) } } fmt.Println() // Count flows and modules in filtered results flowCount := 0 moduleCount := 0 for _, r := range rows { if r.wfType == "flow" { flowCount++ } else { moduleCount++ } } // Convert unique tags map to sorted slice var tagList []string for tag := range uniqueTags { tagList = append(tagList, tag) } // Sort tags alphabetically for consistent display if len(tagList) > 1 { for i := 0; i < len(tagList)-1; i++ { for j := i + 1; j < len(tagList); j++ { if tagList[i] > tagList[j] { tagList[i], tagList[j] = tagList[j], tagList[i] } } } } // Summary with colors if len(filterTags) > 0 { fmt.Printf("◆ Matching: %s flows, %s modules (filtered by tags: %s)\n", terminal.Green(fmt.Sprintf("%d", flowCount)), terminal.Yellow(fmt.Sprintf("%d", moduleCount)), terminal.Cyan(strings.Join(filterTags, ", "))) } else { fmt.Printf("◆ Total: %s flows, %s modules, %s unique tags\n", terminal.Green(fmt.Sprintf("%d", flowCount)), terminal.Yellow(fmt.Sprintf("%d", moduleCount)), terminal.Cyan(fmt.Sprintf("%d", len(tagList)))) } // Show available tags if len(tagList) > 0 { tagsDisplay := strings.Join(tagList, ", ") if len(tagsDisplay) > 80 { tagsDisplay = tagsDisplay[:77] + "..." } fmt.Printf("◇ Available tags: %s\n", terminal.Gray(tagsDisplay)) } // View workflow details hint binaryPath := os.Args[0] fmt.Println() fmt.Println("◌ " + terminal.Bold("View workflow details:")) fmt.Printf(" %s workflow show %s\n", terminal.Cyan(binaryPath), terminal.Yellow("")) // Validate workflow hint fmt.Println() fmt.Println("◌ " + terminal.Bold("Validate workflow:")) fmt.Printf(" %s workflow validate %s\n", terminal.Cyan(binaryPath), terminal.Yellow("")) // Filter by tags hint fmt.Println() fmt.Println("◌ " + terminal.Bold("Filter by tags:")) fmt.Printf(" %s workflow ls --tags %s\n", terminal.Cyan(binaryPath), terminal.Yellow("recon,fast")) fmt.Printf(" %s workflow ls --show-tags\n", terminal.Cyan(binaryPath)) // Example run usage fmt.Println() fmt.Println("◌ " + terminal.Bold("Example Run Usage:")) fmt.Println() fmt.Printf(" %s run -f %s -t \n", terminal.Cyan(binaryPath), terminal.Yellow("")) fmt.Printf(" %s run -f general -T list_of_targets.txt\n", terminal.Cyan(binaryPath)) fmt.Printf(" %s run --threads-hold 10 -t sample.com\n", terminal.Cyan(binaryPath)) fmt.Printf(" %s run -t sample.com -x %s\n", terminal.Cyan(binaryPath), terminal.Yellow("")) fmt.Printf(" %s run -m %s -t --params %s\n", terminal.Cyan(binaryPath), terminal.Yellow(""), terminal.Gray("")) fmt.Println() // Show workflow errors if verbose mode if showVerbose && len(workflowErrors) > 0 { fmt.Printf("%s Workflows with Errors (%d):\n", terminal.WarningSymbol(), len(workflowErrors)) for _, we := range workflowErrors { fmt.Printf(" %s\n", terminal.Yellow(we.name)) fmt.Printf(" └─ %s\n", terminal.Gray(we.err.Error())) } fmt.Println() } } else { printer.Warning("No workflows found in %s", cfg.WorkflowsPath) } return nil }, } // truncateString truncates a string to maxLen and adds "..." if needed func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen-3] + "..." } // getRequiredParams returns a comma-separated list of required parameter names func getRequiredParams(wf *core.Workflow) string { var required []string for _, p := range wf.Params { if p.Required { required = append(required, p.Name) } } if len(required) == 0 { return "-" } return strings.Join(required, ", ") } // stripAnsi removes ANSI escape codes for length calculation func stripAnsi(s string) string { re := regexp.MustCompile(`\x1b\[[0-9;]*m`) return re.ReplaceAllString(s, "") } // isToggleParam detects boolean/toggle parameters by: // - Type == "bool" // - Name patterns: enableX, enable_X, skipX, skip_X, disableX, useX, verboseX // - Default value is bool (true/false) func isToggleParam(p core.Param) bool { // Check explicit type if p.Type == "bool" { return true } // Check name patterns (case-insensitive) name := strings.ToLower(p.Name) togglePrefixes := []string{"enable", "skip", "disable", "use", "verbose"} for _, prefix := range togglePrefixes { if strings.HasPrefix(name, prefix) { return true } // Also check with underscore: enable_xxx, skip_xxx if strings.HasPrefix(name, prefix+"_") { return true } } // Check if default value is boolean if p.Default != nil { switch p.Default.(type) { case bool: return true } // Also check string representation defaultStr := strings.ToLower(p.DefaultString()) if defaultStr == "true" || defaultStr == "false" { return true } } return false } // isSpeedControlParam detects performance/speed parameters by: // - Name contains: threads, timeout, rate, concurrency, delay, limit, workers, parallel, batch, interval, retry // - Name ends with: depth, parallel // - Default value matches time pattern: \d+[hms] func isSpeedControlParam(p core.Param) bool { name := strings.ToLower(p.Name) speedPatterns := []string{ "threads", "timeout", "rate", "concurrency", "delay", "limit", "workers", "parallel", "batch", "interval", "retry", } for _, pattern := range speedPatterns { if strings.Contains(name, pattern) { return true } } // Check suffix patterns for depth and parallel speedSuffixes := []string{"depth", "parallel"} for _, suffix := range speedSuffixes { if strings.HasSuffix(name, suffix) { return true } } // Check for time pattern in default value (e.g., 8h, 30m, 1800) if p.Default != nil { defaultStr := p.DefaultString() // Match patterns like: 8h, 30m, 1800, 60s timePatternRegex := regexp.MustCompile(`^\d+[hms]?$`) if timePatternRegex.MatchString(defaultStr) { // Also verify it's numeric or has time suffix if len(defaultStr) > 0 { lastChar := defaultStr[len(defaultStr)-1] // If it ends with h, m, or s, it's a time value if lastChar == 'h' || lastChar == 'm' || lastChar == 's' { return true } // If purely numeric with reasonable size, could be timeout/limit if p.Type == "int" || p.Type == "" { // Check if numeric only numericRegex := regexp.MustCompile(`^\d+$`) if numericRegex.MatchString(defaultStr) { // Large numbers (>100) are likely timeouts/limits val := 0 _, _ = fmt.Sscanf(defaultStr, "%d", &val) if val > 100 { return true } } } } } } return false } // isConfigParam detects configuration parameters by: // - Name ends with: Config, config, Cfg, cfg func isConfigParam(p core.Param) bool { name := strings.ToLower(p.Name) configSuffixes := []string{"config", "cfg"} for _, suffix := range configSuffixes { if strings.HasSuffix(name, suffix) { return true } } return false } // categorizeParams groups params into Toggle, Speed, Config, and General categories func categorizeParams(params []core.Param) (toggle, speed, config, general []core.Param) { for _, p := range params { if isToggleParam(p) { toggle = append(toggle, p) } else if isSpeedControlParam(p) { speed = append(speed, p) } else if isConfigParam(p) { config = append(config, p) } else { general = append(general, p) } } return } // printToggleParams prints toggle parameters with green highlighting func printToggleParams(params []core.Param) { if len(params) == 0 { return } fmt.Println() fmt.Println("◐ " + terminal.Bold("Toggle Parameters:")) var rows [][]string for _, p := range params { required := terminal.Gray("no") if p.Required { required = terminal.Green("yes") } defaultVal := p.DefaultString() if defaultVal == "" { defaultVal = "-" } // Color the default value based on true/false coloredDefault := defaultVal if strings.ToLower(defaultVal) == "true" { coloredDefault = terminal.Green(defaultVal) } else if strings.ToLower(defaultVal) == "false" { coloredDefault = terminal.Gray(defaultVal) } rows = append(rows, []string{terminal.Green(p.Name), coloredDefault, required}) } printMarkdownTable([]string{"Name", "Default", "Required"}, rows) } // printSpeedControlParams prints speed/performance parameters with yellow highlighting func printSpeedControlParams(params []core.Param) { if len(params) == 0 { return } fmt.Println() fmt.Println("◎ " + terminal.Bold("Speed Control Parameters:")) var rows [][]string for _, p := range params { required := terminal.Gray("no") if p.Required { required = terminal.Green("yes") } defaultVal := p.DefaultString() if defaultVal == "" { defaultVal = "-" } // Color numeric values in yellow coloredDefault := terminal.Yellow(defaultVal) rows = append(rows, []string{terminal.Yellow(p.Name), coloredDefault, required}) } printMarkdownTable([]string{"Name", "Default", "Required"}, rows) } // printConfigParams prints config parameters with magenta highlighting func printConfigParams(params []core.Param) { if len(params) == 0 { return } fmt.Println() fmt.Println("⚙ " + terminal.Bold("Config Parameters:")) var rows [][]string for _, p := range params { required := terminal.Gray("no") if p.Required { required = terminal.Green("yes") } defaultVal := p.DefaultString() if defaultVal == "" { defaultVal = "-" } coloredDefault := terminal.Magenta(defaultVal) if defaultVal == "-" { coloredDefault = defaultVal } rows = append(rows, []string{terminal.Magenta(p.Name), coloredDefault, required}) } printMarkdownTable([]string{"Name", "Default", "Required"}, rows) } // printGeneralParams prints general parameters with cyan highlighting func printGeneralParams(params []core.Param) { if len(params) == 0 { return } fmt.Println() fmt.Println("● " + terminal.Bold("General Parameters:")) var rows [][]string for _, p := range params { required := terminal.Gray("no") if p.Required { required = terminal.Green("yes") } defaultVal := p.DefaultString() if defaultVal == "" { defaultVal = "-" } coloredDefault := terminal.Cyan(defaultVal) if defaultVal == "-" { coloredDefault = defaultVal } rows = append(rows, []string{terminal.Cyan(p.Name), coloredDefault, required}) } printMarkdownTable([]string{"Name", "Default", "Required"}, rows) } // printMarkdownTable prints an aligned markdown table (supports colored cells) func printMarkdownTable(headers []string, rows [][]string) { // Calculate column widths (using display length, not byte length) widths := make([]int, len(headers)) for i, h := range headers { widths[i] = len(stripAnsi(h)) } for _, row := range rows { for i, cell := range row { displayLen := len(stripAnsi(cell)) if i < len(widths) && displayLen > widths[i] { widths[i] = displayLen } } } // Print header fmt.Print("|") for i, h := range headers { fmt.Printf(" %-*s |", widths[i], h) } fmt.Println() // Print separator fmt.Print("|") for _, w := range widths { fmt.Printf("-%s-|", strings.Repeat("-", w)) } fmt.Println() // Print rows (with ANSI-aware padding) for _, row := range rows { fmt.Print("|") for i := range headers { cell := "" if i < len(row) { cell = row[i] } // Calculate padding needed (display width vs actual string length) displayLen := len(stripAnsi(cell)) padding := widths[i] - displayLen fmt.Printf(" %s%s |", cell, strings.Repeat(" ", padding)) } fmt.Println() } } // wrapText wraps text to maxWidth characters, preserving existing newlines func wrapText(text string, maxWidth int) []string { if maxWidth <= 0 { return []string{text} } var result []string // Split on existing newlines first lines := strings.Split(text, "\n") for _, line := range lines { if len(stripAnsi(line)) <= maxWidth { result = append(result, line) continue } // Wrap long lines remaining := line for len(stripAnsi(remaining)) > maxWidth { // Find break point - try to break at space displayLen := 0 lastSpace := -1 byteIdx := 0 for byteIdx < len(remaining) && displayLen < maxWidth { if remaining[byteIdx] == '\x1b' { // Skip ANSI escape sequence for byteIdx < len(remaining) && remaining[byteIdx] != 'm' { byteIdx++ } if byteIdx < len(remaining) { byteIdx++ } continue } if remaining[byteIdx] == ' ' { lastSpace = byteIdx } displayLen++ byteIdx++ } breakPoint := byteIdx // Prefer breaking at space if found if lastSpace > 0 && lastSpace > breakPoint/2 { breakPoint = lastSpace } result = append(result, remaining[:breakPoint]) remaining = strings.TrimLeft(remaining[breakPoint:], " ") } if remaining != "" { result = append(result, remaining) } } return result } // printMarkdownTableWithWidth prints a table with column width wrapping func printMarkdownTableWithWidth(headers []string, rows [][]string, maxWidth int) { // First pass: wrap all cells and calculate column widths type wrappedRow struct { cells [][]string // Each cell is a slice of lines maxHeight int } var wrappedRows []wrappedRow widths := make([]int, len(headers)) // Initialize widths with header lengths for i, h := range headers { widths[i] = len(stripAnsi(h)) } // Wrap each cell and track widths for _, row := range rows { wr := wrappedRow{cells: make([][]string, len(headers))} for i := range headers { cell := "" if i < len(row) { cell = row[i] } wrapped := wrapText(cell, maxWidth) wr.cells[i] = wrapped if len(wrapped) > wr.maxHeight { wr.maxHeight = len(wrapped) } // Track max width for this column for _, line := range wrapped { lineWidth := len(stripAnsi(line)) if lineWidth > widths[i] { widths[i] = lineWidth } } } wrappedRows = append(wrappedRows, wr) } // Print header fmt.Print("|") for i, h := range headers { fmt.Printf(" %-*s |", widths[i], h) } fmt.Println() // Print separator fmt.Print("|") for _, w := range widths { fmt.Printf("-%s-|", strings.Repeat("-", w)) } fmt.Println() // Print rows (with multi-line support) for _, wr := range wrappedRows { for lineIdx := 0; lineIdx < wr.maxHeight; lineIdx++ { fmt.Print("|") for colIdx := range headers { cell := "" if lineIdx < len(wr.cells[colIdx]) { cell = wr.cells[colIdx][lineIdx] } // Calculate padding needed (display width vs actual string length) displayLen := len(stripAnsi(cell)) padding := widths[colIdx] - displayLen fmt.Printf(" %s%s |", cell, strings.Repeat(" ", padding)) } fmt.Println() } } } // workflowShowCmd shows workflow details var workflowShowCmd = &cobra.Command{ Use: "show [name]", Short: "Show workflow details", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg := config.Get() if cfg == nil { return fmt.Errorf("configuration not loaded") } printer := terminal.NewPrinter() loader := parser.NewLoader(cfg.WorkflowsPath) workflow, err := loader.LoadWorkflow(args[0]) if err != nil { // Print detailed error with formatting fmt.Println() printer.Error("Failed to load workflow: %s", args[0]) fmt.Println() fmt.Println(err.Error()) fmt.Println() return err } // Show raw YAML with syntax highlighting when --yaml flag is set if showYaml { content, err := os.ReadFile(workflow.FilePath) if err != nil { return fmt.Errorf("failed to read workflow file: %w", err) } // Render with Glamour for syntax highlighting markdown := "```yaml\n" + string(content) + "\n```" renderer, err := glamour.NewTermRenderer( glamour.WithAutoStyle(), glamour.WithWordWrap(120), ) if err == nil { rendered, renderErr := renderer.Render(markdown) if renderErr == nil { fmt.Print(rendered) return nil } } // Fallback to plain YAML if rendering fails fmt.Println(string(content)) return nil } // Default: Table format output // Metadata section fmt.Println() fmt.Println("❯ " + terminal.Bold("Metadata:")) printer.KeyValue("Name", workflow.Name) printer.KeyValue("Kind", terminal.TypeBadge(string(workflow.Kind))) printer.KeyValue("Description", workflow.Description) printer.KeyValue("File", terminal.Gray(workflow.FilePath)) // Show parameters (categorized) if len(workflow.Params) > 0 { toggle, speed, config, general := categorizeParams(workflow.Params) printToggleParams(toggle) printSpeedControlParams(speed) printConfigParams(config) printGeneralParams(general) } // Show steps (for modules) if workflow.IsModule() && len(workflow.Steps) > 0 { fmt.Println() fmt.Println("◼ " + terminal.Bold("Steps:")) var rows [][]string for i, s := range workflow.Steps { coloredType := terminal.TypeBadge(string(s.Type)) rows = append(rows, []string{fmt.Sprintf("%d", i+1), s.Name, coloredType}) } printMarkdownTable([]string{"#", "Name", "Type"}, rows) } // Show modules (for flows) if workflow.IsFlow() && len(workflow.Modules) > 0 { fmt.Println() fmt.Println("◼ " + terminal.Bold("Modules:")) var rows [][]string for i, m := range workflow.Modules { deps := strings.Join(m.DependsOn, ", ") if deps == "" { deps = "-" } rows = append(rows, []string{fmt.Sprintf("%d", i+1), m.Name, m.Path, deps}) } printMarkdownTable([]string{"#", "Name", "Path", "Depends On"}, rows) } // Show triggers if len(workflow.Triggers) > 0 { fmt.Println() fmt.Println("◼ " + terminal.Bold("Triggers:")) var rows [][]string for _, t := range workflow.Triggers { status := terminal.Gray("disabled") if t.Enabled { status = terminal.Green("enabled") } rows = append(rows, []string{t.Name, string(t.On), status}) } printMarkdownTable([]string{"Name", "Type", "Status"}, rows) } // Show builtin variables fmt.Println() fmt.Println("◆ " + terminal.Bold("Builtin Variables:")) if showVerbose { // Verbose: show full aligned markdown table with default values and descriptions // Get actual default values from config where possible baseFolder := "~/osmedeus-base" binariesPath := "~/osmedeus-base/external-binaries" dataPath := "~/osmedeus-base/data" workspacesPath := "~/workspaces-osmedeus" workflowsPath := cfg.WorkflowsPath if cfg.BaseFolder != "" { baseFolder = cfg.BaseFolder } if cfg.BinariesPath != "" { binariesPath = cfg.BinariesPath } if cfg.DataPath != "" { dataPath = cfg.DataPath } if cfg.WorkspacesPath != "" { workspacesPath = cfg.WorkspacesPath } // Truncate paths for display truncatePath := func(p string, maxLen int) string { if len(p) <= maxLen { return p } return "..." + p[len(p)-maxLen+3:] } builtinVars := [][]string{ // Path variables {terminal.Cyan("{{BaseFolder}}"), "Base installation folder", terminal.Gray(truncatePath(baseFolder, 28))}, {terminal.Cyan("{{Binaries}}"), "Path to binaries", terminal.Gray(truncatePath(binariesPath, 28))}, {terminal.Cyan("{{Data}}"), "Path to data files", terminal.Gray(truncatePath(dataPath, 28))}, {terminal.Cyan("{{ExternalConfigs}}"), "Path to external configs", terminal.Gray("{{BaseFolder}}/configs")}, {terminal.Cyan("{{ExternalScripts}}"), "Path to external scripts", terminal.Gray("{{BaseFolder}}/scripts")}, {terminal.Cyan("{{Workspaces}}"), "Path to workspaces", terminal.Gray(truncatePath(workspacesPath, 28))}, {terminal.Cyan("{{Workflows}}"), "Path to workflows", terminal.Gray(truncatePath(workflowsPath, 28))}, {terminal.Cyan("{{ExternalMarkdowns}}"), "Path to markdown templates", terminal.Gray("{{BaseFolder}}/markdown-report-templates")}, {terminal.Cyan("{{ExternalAgents}}"), "Path to agent configs", terminal.Gray("{{BaseFolder}}/external-agent-configs")}, // Target variables {terminal.Cyan("{{Target}}"), "Current scan target", terminal.Yellow("")}, {terminal.Cyan("{{TargetFile}}"), "File containing targets", terminal.Yellow("")}, {terminal.Cyan("{{TargetSpace}}"), "Sanitized target path", terminal.Yellow("")}, {terminal.Cyan("{{Output}}"), "Output directory for target", terminal.Gray("{{Workspaces}}/{{TargetSpace}}")}, // Target type heuristics {terminal.Cyan("{{TargetType}}"), "Target type (url/domain/ip/cidr/file)", terminal.Yellow("")}, {terminal.Cyan("{{TargetRootDomain}}"), "Root domain (domain/URL targets)", terminal.Yellow("")}, {terminal.Cyan("{{TargetTLD}}"), "Top-level domain (domain/URL targets)", terminal.Yellow("")}, {terminal.Cyan("{{TargetSLD}}"), "Second-level domain (domain/URL)", terminal.Yellow("")}, {terminal.Cyan("{{Org}}"), "Alias for TargetSLD", terminal.Yellow("")}, {terminal.Cyan("{{TargetBaseURL}}"), "Base URL (URL targets only)", terminal.Yellow("")}, {terminal.Cyan("{{TargetRootURL}}"), "Root URL (URL targets only)", terminal.Yellow("")}, {terminal.Cyan("{{TargetHostname}}"), "Hostname (URL targets only)", terminal.Yellow("")}, {terminal.Cyan("{{TargetHost}}"), "Host with port (URL targets only)", terminal.Yellow("")}, {terminal.Cyan("{{TargetPort}}"), "Port number (URL targets only)", terminal.Yellow("")}, {terminal.Cyan("{{TargetPath}}"), "URL path (URL targets only)", terminal.Yellow("")}, {terminal.Cyan("{{TargetScheme}}"), "URL scheme (URL targets only)", terminal.Yellow("")}, {terminal.Cyan("{{TargetIsWildcard}}"), "Is wildcard (domain targets only)", terminal.Yellow("")}, {terminal.Cyan("{{TargetResolvedIP}}"), "Resolved IP (domain targets only)", terminal.Yellow("")}, {terminal.Cyan("{{TargetStatusCode}}"), "HTTP status (URL targets only)", terminal.Yellow("")}, {terminal.Cyan("{{TargetContentLength}}"), "Content length (URL targets only)", terminal.Yellow("")}, // State files {terminal.Cyan("{{StateExecutionLog}}"), "Path to execution log", terminal.Gray("{{Output}}/run-execution.log")}, {terminal.Cyan("{{StateCompletedFile}}"), "Path to run completed JSON", terminal.Gray("{{Output}}/run-completed.json")}, {terminal.Cyan("{{StateFile}}"), "Path to run state JSON", terminal.Gray("{{Output}}/run-state.json")}, {terminal.Cyan("{{StateWorkflowFile}}"), "Path to workflow YAML", terminal.Gray("{{Output}}/run-workflow.yaml")}, {terminal.Cyan("{{StateWorkflowFolder}}"), "Path to workflow modules", terminal.Gray("{{Output}}/run-modules")}, // Thread/performance variables {terminal.Cyan("{{threads}}"), "Thread count (tactic based)", terminal.Yellow("10 (default tactic)")}, {terminal.Cyan("{{baseThreads}}"), "Base thread count", terminal.Yellow("10")}, // Metadata variables {terminal.Cyan("{{Version}}"), "Osmedeus version", terminal.Gray("")}, {terminal.Cyan("{{TaskID}}"), "Unique task identifier (8 chars)", terminal.Yellow("")}, {terminal.Cyan("{{TaskDate}}"), "Task date (YYYY-MM-DD)", terminal.Yellow("")}, {terminal.Cyan("{{Today}}"), "Current date (YYYY-MM-DD)", terminal.Yellow("")}, {terminal.Cyan("{{TimeStamp}}"), "Unix timestamp", terminal.Yellow("")}, {terminal.Cyan("{{CurrentTime}}"), "Current time (ISO 8601)", terminal.Yellow("")}, {terminal.Cyan("{{RandomString}}"), "Random 8-char alphanumeric", terminal.Yellow("")}, } printMarkdownTable([]string{"Variable", "Description", "Default Value"}, builtinVars) } else { // Compact: show variables in columns vars := []string{ "{{BaseFolder}}", "{{Binaries}}", "{{Data}}", "{{Workspaces}}", "{{Target}}", "{{Output}}", "{{TaskID}}", "{{Today}}", "{{threads}}", "{{Version}}", "{{RandomString}}", } for i, v := range vars { fmt.Printf(" %s", terminal.Cyan(v)) if (i+1)%4 == 0 { fmt.Println() } } fmt.Println() fmt.Println() fmt.Printf(" %s\n", terminal.Gray("Tip: Use --verbose to show all variables with descriptions and default values")) } fmt.Println() return nil }, } // workflowValidateCmd validates a workflow var workflowValidateCmd = &cobra.Command{ Use: "validate [name|path|folder]", Aliases: []string{"val"}, Short: "Validate workflow(s) - accepts workflow name, file path, or folder", Long: `Validate workflow YAML file(s). Accepts: - Workflow name (looks up in workflows directory) - Path to a YAML file - Path to a folder (recursively validates all workflow YAMLs) Examples: osmedeus workflow validate test-echo osmedeus workflow validate ./my-workflow.yaml osmedeus workflow validate /path/to/workflows/ osmedeus workflow validate . --fail-fast`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg := config.Get() if cfg == nil { return fmt.Errorf("configuration not loaded") } printer := terminal.NewPrinter() input := args[0] inputType, resolvedPath := classifyInput(input) switch inputType { case "file": return validateFile(resolvedPath, cfg, printer) case "folder": return validateFolder(resolvedPath, cfg, printer, validateFailFast) case "name": return validateByName(input, cfg, printer) } return nil }, } var showVerbose bool var filterTags []string var showYaml bool var showTags bool var validateFailFast bool func init() { workflowShowCmd.Flags().BoolVarP(&showVerbose, "verbose", "v", false, "show detailed variable descriptions") workflowShowCmd.Flags().BoolVar(&showYaml, "yaml", false, "show raw YAML instead of table format") workflowListCmd.Flags().StringSliceVar(&filterTags, "tags", []string{}, "filter workflows by tags (comma-separated)") workflowListCmd.Flags().BoolVar(&showTags, "show-tags", false, "show tags column in output") workflowListCmd.Flags().BoolVarP(&showVerbose, "verbose", "v", false, "show workflows with errors") workflowValidateCmd.Flags().BoolVar(&validateFailFast, "fail-fast", false, "stop on first validation failure") workflowCmd.AddCommand(workflowListCmd) workflowCmd.AddCommand(workflowShowCmd) workflowCmd.AddCommand(workflowValidateCmd) } // hasMatchingTags checks if a workflow has any of the specified tags func hasMatchingTags(wf *core.Workflow, tags []string) bool { if len(tags) == 0 { return true } for _, filterTag := range tags { for _, wfTag := range wf.Tags { if strings.EqualFold(wfTag, filterTag) { return true } } } return false } // classifyInput determines if input is a file path, folder path, or workflow name func classifyInput(input string) (inputType string, resolvedPath string) { if info, err := os.Stat(input); err == nil { absPath, _ := filepath.Abs(input) if info.IsDir() { return "folder", absPath } return "file", absPath } // Check if it looks like a path if strings.Contains(input, string(filepath.Separator)) || strings.Contains(input, "/") || strings.HasSuffix(input, ".yaml") || strings.HasSuffix(input, ".yml") { absPath, _ := filepath.Abs(input) return "file", absPath } return "name", input } // isWorkflowYAML checks if file contains kind: module or kind: flow func isWorkflowYAML(path string) bool { content, err := os.ReadFile(path) if err != nil { return false } contentStr := string(content) if !strings.Contains(contentStr, "kind:") { return false } kindPattern := regexp.MustCompile(`(?m)^kind:\s*['"]?(module|flow)['"]?\s*$`) return kindPattern.Match(content) } // findWorkflowFiles recursively finds workflow YAML files in directory func findWorkflowFiles(dir string) ([]string, error) { var files []string err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // Skip inaccessible files } if info.IsDir() { return nil } if !strings.HasSuffix(path, ".yaml") && !strings.HasSuffix(path, ".yml") { return nil } if isWorkflowYAML(path) { files = append(files, path) } return nil }) return files, err } // ValidationResult holds the result of validating a single workflow type ValidationResult struct { Path string Name string Kind string Status string // "valid", "failed", "warning" Error error } // validateSingleFile validates a single workflow file func validateSingleFile(path string, cfg *config.Config) ValidationResult { result := ValidationResult{Path: path, Status: "failed"} p := parser.NewParser() workflow, err := p.Parse(path) if err != nil { result.Error = err return result } result.Name = workflow.Name result.Kind = string(workflow.Kind) if err := p.Validate(workflow); err != nil { result.Error = err return result } depChecker := parser.NewDependencyChecker() if workflow.Dependencies != nil { if err := depChecker.CheckCommands(workflow.Dependencies.Commands, cfg.BinariesPath); err != nil { result.Error = err result.Status = "warning" return result } } result.Status = "valid" return result } // validateByName validates workflow by name (original behavior) func validateByName(name string, cfg *config.Config, printer *terminal.Printer) error { loader := parser.NewLoader(cfg.WorkflowsPath) workflow, err := loader.LoadWorkflow(name) if err != nil { fmt.Println() printer.Error("Failed to load workflow: %s", name) fmt.Println() fmt.Println(err.Error()) fmt.Println() return err } if err := parser.Validate(workflow); err != nil { printer.Error("Validation failed: %s", err) return err } depChecker := parser.NewDependencyChecker() if workflow.Dependencies != nil { if err := depChecker.CheckCommands(workflow.Dependencies.Commands, cfg.BinariesPath); err != nil { printer.Warning("Dependency warning: %s", err) } } printer.Success("Workflow '%s' is valid", workflow.Name) return nil } // validateFile validates a single workflow file func validateFile(path string, cfg *config.Config, printer *terminal.Printer) error { if _, err := os.Stat(path); os.IsNotExist(err) { return fmt.Errorf("file not found: %s", path) } if !isWorkflowYAML(path) { printer.Warning("File does not contain 'kind: module' or 'kind: flow': %s", path) return fmt.Errorf("not a workflow file") } result := validateSingleFile(path, cfg) if result.Status == "valid" || result.Status == "warning" { printer.Success("Workflow '%s' (%s) is valid", result.Name, result.Kind) if result.Status == "warning" && result.Error != nil { printer.Warning("Dependency warning: %s", result.Error) } return nil } printer.Error("Validation failed for %s: %s", path, result.Error) return result.Error } // validateFolder validates all workflow files in a folder func validateFolder(dir string, cfg *config.Config, printer *terminal.Printer, failFast bool) error { fmt.Println() printer.Info("Scanning for workflow files in: %s", terminal.Cyan(dir)) files, err := findWorkflowFiles(dir) if err != nil { return fmt.Errorf("failed to scan directory: %w", err) } if len(files) == 0 { printer.Warning("No workflow YAML files found in %s", dir) return nil } printer.Info("Found %d workflow file(s)", len(files)) fmt.Println() var results []ValidationResult validCount, failedCount, warningCount := 0, 0, 0 for _, file := range files { result := validateSingleFile(file, cfg) results = append(results, result) switch result.Status { case "valid": validCount++ case "warning": warningCount++ case "failed": failedCount++ if failFast { printValidationTable(results, dir) return fmt.Errorf("validation failed") } } } printValidationTable(results, dir) fmt.Println() printer.Info("Summary: %s valid, %s failed, %s warnings", terminal.Green(fmt.Sprintf("%d", validCount)), terminal.Red(fmt.Sprintf("%d", failedCount)), terminal.Yellow(fmt.Sprintf("%d", warningCount))) if failedCount > 0 { return fmt.Errorf("%d workflow(s) failed validation", failedCount) } return nil } // printValidationTable prints validation results as a table func printValidationTable(results []ValidationResult, baseDir string) { var rows [][]string for _, r := range results { relPath, _ := filepath.Rel(baseDir, r.Path) if relPath == "" { relPath = r.Path } kind := r.Kind if kind == "" { kind = "-" } statusColor := terminal.Green if r.Status == "failed" { statusColor = terminal.Red } else if r.Status == "warning" { statusColor = terminal.Yellow } errMsg := "-" if r.Error != nil { errMsg = truncateString(r.Error.Error(), 40) } rows = append(rows, []string{ relPath, terminal.TypeBadge(kind), statusColor(r.Status), errMsg, }) } printMarkdownTable([]string{"File", "Kind", "Status", "Details"}, rows) }