mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-09-30 21:44:57 +02:00
feat: table rendering, cron schedules, and assets command
- Refactor table display using tablewriter with box-drawing characters and proper Unicode width handling - Add --as-cron flag to register cron schedules instead of executing immediately - Implement new 'osmedeus assets' command for asset querying with filtering and statistics - Enhance workflow parameter display with categorization and per-module summaries - Improve column width handling and parameter documentation
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/j3ssie/osmedeus/v5/internal/config"
|
||||
"github.com/j3ssie/osmedeus/v5/internal/database"
|
||||
"github.com/j3ssie/osmedeus/v5/internal/terminal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
assetsWorkspace string
|
||||
assetsSource string
|
||||
assetsType string
|
||||
assetsStats bool
|
||||
assetsLimit int
|
||||
assetsOffset int
|
||||
assetsColumns string
|
||||
assetsExcludeColumns string
|
||||
assetsAll bool
|
||||
)
|
||||
|
||||
var assetsCmd = &cobra.Command{
|
||||
Use: "assets [search]",
|
||||
Aliases: []string{"asset"},
|
||||
Short: "Query and list discovered assets",
|
||||
Long: UsageAssets(),
|
||||
RunE: runAssets,
|
||||
}
|
||||
|
||||
func init() {
|
||||
assetsCmd.Flags().StringVarP(&assetsWorkspace, "workspace", "w", "", "filter by workspace name")
|
||||
assetsCmd.Flags().StringVar(&assetsSource, "source", "", "filter by source field (e.g., httpx, subfinder)")
|
||||
assetsCmd.Flags().StringVar(&assetsType, "type", "", "filter by asset_type field (e.g., web, subdomain)")
|
||||
assetsCmd.Flags().BoolVar(&assetsStats, "stats", false, "show asset statistics (unique technologies, sources, remarks, types)")
|
||||
assetsCmd.Flags().IntVar(&assetsLimit, "limit", 50, "maximum number of records to return")
|
||||
assetsCmd.Flags().IntVar(&assetsOffset, "offset", 0, "number of records to skip (for pagination)")
|
||||
assetsCmd.Flags().StringVar(&assetsColumns, "columns", "", "comma-separated columns to display")
|
||||
assetsCmd.Flags().StringVar(&assetsExcludeColumns, "exclude-columns", "", "comma-separated columns to exclude from output")
|
||||
assetsCmd.Flags().BoolVar(&assetsAll, "all", false, "show all columns including hidden ones (id, timestamps)")
|
||||
}
|
||||
|
||||
func runAssets(cmd *cobra.Command, args []string) error {
|
||||
if disableDB {
|
||||
return fmt.Errorf("assets command unavailable: --disable-db flag is set")
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("configuration not loaded")
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
_, err := database.Connect(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
defer func() { _ = database.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := database.Migrate(ctx); err != nil {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
if assetsStats {
|
||||
return runAssetsStats(ctx)
|
||||
}
|
||||
return runAssetsList(ctx, args)
|
||||
}
|
||||
|
||||
func runAssetsList(ctx context.Context, args []string) error {
|
||||
// Validate pagination
|
||||
if assetsLimit <= 0 {
|
||||
assetsLimit = 50
|
||||
}
|
||||
if assetsLimit > 10000 {
|
||||
assetsLimit = 10000
|
||||
}
|
||||
if assetsOffset < 0 {
|
||||
assetsOffset = 0
|
||||
}
|
||||
|
||||
// Build filters
|
||||
filters := make(map[string]string)
|
||||
if assetsSource != "" {
|
||||
filters["source"] = assetsSource
|
||||
}
|
||||
if assetsType != "" {
|
||||
filters["asset_type"] = assetsType
|
||||
}
|
||||
if assetsWorkspace != "" {
|
||||
filters["workspace"] = assetsWorkspace
|
||||
}
|
||||
|
||||
// Search term from positional arg
|
||||
search := ""
|
||||
if len(args) > 0 {
|
||||
search = args[0]
|
||||
}
|
||||
|
||||
records, err := database.GetTableRecords(ctx, "assets", assetsOffset, assetsLimit, filters, search, database.AssetHeavyColumns)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get assets: %w", err)
|
||||
}
|
||||
|
||||
// JSON output
|
||||
if globalJSON {
|
||||
jsonBytes, err := json.Marshal(records.Records)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to format records: %w", err)
|
||||
}
|
||||
fmt.Println(string(jsonBytes))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Table output
|
||||
printer := terminal.NewPrinter()
|
||||
|
||||
requestedColumns := parseColumns(assetsColumns)
|
||||
columns := getEffectiveColumns("assets", requestedColumns, assetsAll)
|
||||
excludeColumns := parseExcludeColumns(assetsExcludeColumns)
|
||||
hideDefaultColumns := !assetsAll && len(requestedColumns) == 0 && tableDefaultColumns["assets"] == nil
|
||||
|
||||
startRecord := records.Offset + 1
|
||||
endRecord := records.Offset + assetsLimit
|
||||
if endRecord > records.TotalCount {
|
||||
endRecord = records.TotalCount
|
||||
}
|
||||
if records.TotalCount == 0 {
|
||||
startRecord = 0
|
||||
}
|
||||
|
||||
printer.Info("Assets")
|
||||
fmt.Printf("Showing records %d-%d of %d\n\n", startRecord, endRecord, records.TotalCount)
|
||||
|
||||
renderTableWithTablewriter("assets", records.Records, columns, globalWidth, hideDefaultColumns, excludeColumns)
|
||||
|
||||
if records.TotalCount > endRecord {
|
||||
nextOffset := records.Offset + records.Limit
|
||||
printer.Info("Next page: osmedeus assets --offset %d --limit %d", nextOffset, assetsLimit)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAssetsStats(ctx context.Context) error {
|
||||
stats, err := database.GetAssetStats(ctx, assetsWorkspace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get asset stats: %w", err)
|
||||
}
|
||||
|
||||
// JSON output
|
||||
if globalJSON {
|
||||
jsonBytes, err := json.Marshal(stats)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to format stats: %w", err)
|
||||
}
|
||||
fmt.Println(string(jsonBytes))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Table output
|
||||
printer := terminal.NewPrinter()
|
||||
if assetsWorkspace != "" {
|
||||
printer.Info("Asset Statistics (workspace: %s)", assetsWorkspace)
|
||||
} else {
|
||||
printer.Info("Asset Statistics")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
printStatCategory("Technologies", stats.Technologies)
|
||||
printStatCategory("Sources", stats.Sources)
|
||||
printStatCategory("Remarks", stats.Remarks)
|
||||
printStatCategory("Asset Types", stats.AssetTypes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printStatCategory(name string, items []string) {
|
||||
fmt.Printf("%s (%d):\n", terminal.Bold(name), len(items))
|
||||
if len(items) == 0 {
|
||||
fmt.Println(" (none)")
|
||||
} else {
|
||||
for _, item := range items {
|
||||
fmt.Printf(" %s %s\n", terminal.SymbolBullet, strings.TrimSpace(item))
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
+3
-113
@@ -7,13 +7,11 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/j3ssie/osmedeus/v5/internal/client"
|
||||
"github.com/j3ssie/osmedeus/v5/internal/terminal"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -121,7 +119,7 @@ func outputJSON(data interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// printClientTable prints data in a styled markdown table format
|
||||
// printClientTable prints data using the same tablewriter renderer as `db ls`
|
||||
func printClientTable(tableName string, data interface{}, columns []string, pagination client.Pagination, recordCount int) {
|
||||
printer := terminal.NewPrinter()
|
||||
printer.Info("Table: %s", tableName)
|
||||
@@ -134,115 +132,7 @@ func printClientTable(tableName string, data interface{}, columns []string, pagi
|
||||
}
|
||||
fmt.Printf("Showing records %d-%d of %d\n\n", startRecord, endRecord, pagination.Total)
|
||||
|
||||
// Format as markdown table
|
||||
tableStr := formatClientMarkdownTable(data, columns, globalWidth)
|
||||
|
||||
// Render with glamour
|
||||
renderer, err := glamour.NewTermRenderer(
|
||||
glamour.WithAutoStyle(),
|
||||
glamour.WithWordWrap(0),
|
||||
)
|
||||
if err == nil {
|
||||
rendered, renderErr := renderer.Render(tableStr)
|
||||
if renderErr == nil {
|
||||
fmt.Print(rendered)
|
||||
} else {
|
||||
fmt.Println(tableStr)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(tableStr)
|
||||
}
|
||||
}
|
||||
|
||||
// formatClientMarkdownTable formats records as a markdown table
|
||||
func formatClientMarkdownTable(records interface{}, columns []string, maxWidth int) string {
|
||||
// Convert records to []map[string]interface{}
|
||||
jsonBytes, _ := json.Marshal(records)
|
||||
var data []map[string]interface{}
|
||||
if err := json.Unmarshal(jsonBytes, &data); err != nil {
|
||||
return "No records found."
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return "No records found."
|
||||
}
|
||||
|
||||
// Use specified columns or extract from first record
|
||||
var headers []string
|
||||
if len(columns) > 0 {
|
||||
headers = columns
|
||||
} else {
|
||||
for key := range data[0] {
|
||||
headers = append(headers, key)
|
||||
}
|
||||
sort.Strings(headers)
|
||||
}
|
||||
|
||||
// Build markdown table
|
||||
var sb strings.Builder
|
||||
|
||||
// Header row
|
||||
sb.WriteString("| ")
|
||||
sb.WriteString(strings.Join(headers, " | "))
|
||||
sb.WriteString(" |\n")
|
||||
|
||||
// Separator row
|
||||
sb.WriteString("|")
|
||||
for range headers {
|
||||
sb.WriteString(" --- |")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Data rows
|
||||
for _, row := range data {
|
||||
sb.WriteString("| ")
|
||||
for i, h := range headers {
|
||||
val := formatClientTableValue(row[h], maxWidth, h)
|
||||
if i > 0 {
|
||||
sb.WriteString(" | ")
|
||||
}
|
||||
sb.WriteString(val)
|
||||
}
|
||||
sb.WriteString(" |\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatClientTableValue converts a value to string for markdown display
|
||||
func formatClientTableValue(v interface{}, maxWidth int, columnName string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var s string
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
// Escape pipe characters and newlines
|
||||
s = strings.ReplaceAll(val, "|", "\\|")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Compact JSON for complex types
|
||||
b, _ := json.Marshal(val)
|
||||
s = string(b)
|
||||
default:
|
||||
s = fmt.Sprintf("%v", val)
|
||||
}
|
||||
|
||||
// Apply width limit
|
||||
if maxWidth > 0 && len(s) > maxWidth {
|
||||
if maxWidth > 3 {
|
||||
s = s[:maxWidth-3] + "..."
|
||||
} else {
|
||||
s = s[:maxWidth]
|
||||
}
|
||||
}
|
||||
|
||||
// Colorize status column
|
||||
if columnName == "status" {
|
||||
return terminal.ColorizeStatus(s)
|
||||
}
|
||||
return s
|
||||
renderTableWithTablewriter(tableName, data, columns, globalWidth, false, nil)
|
||||
}
|
||||
|
||||
// validFetchTables lists all valid table names for the fetch command
|
||||
@@ -256,7 +146,7 @@ var clientTableDefaultColumns = map[string][]string{
|
||||
"runs": {"run_uuid", "workflow_name", "target", "status", "completed_steps", "total_steps"},
|
||||
"step_results": {"step_name", "step_type", "status", "duration_ms", "command"},
|
||||
"artifacts": {"name", "artifact_path", "artifact_type", "size_bytes"},
|
||||
"assets": {"asset_value", "host_ip", "title", "status_code"},
|
||||
"assets": {"asset_value", "url", "status_code", "content_length", "title"},
|
||||
"event_logs": {"topic", "source", "processed", "workspace"},
|
||||
"schedules": {"name", "workflow_name", "trigger_type", "schedule", "is_enabled"},
|
||||
"workspaces": {"name", "data_source", "total_assets"},
|
||||
|
||||
+112
-63
@@ -11,10 +11,12 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/j3ssie/osmedeus/v5/internal/config"
|
||||
"github.com/j3ssie/osmedeus/v5/internal/database"
|
||||
"github.com/j3ssie/osmedeus/v5/internal/terminal"
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/olekukonko/tablewriter/renderer"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -45,13 +47,24 @@ var tableDefaultColumns = map[string][]string{
|
||||
"runs": {"run_uuid", "workflow_name", "target", "workspace", "trigger_type", "status", "completed_steps", "total_steps"},
|
||||
"step_results": {"step_name", "step_type", "status", "duration_ms", "command"},
|
||||
"artifacts": {"name", "path", "type", "size_bytes", "line_count"},
|
||||
"assets": {"asset_value", "url", "status_code", "content_length", "title"},
|
||||
"assets": {"asset_value", "status_code", "title", "tech", "host_ip", "source", "asset_type", "url"},
|
||||
"event_logs": {"topic", "source", "processed", "data_type", "workspace", "data"},
|
||||
"schedules": {"name", "workflow_name", "workflow_kind", "target", "trigger_type", "schedule", "is_enabled", "run_count"},
|
||||
"workspaces": {"name", "data_source", "total_assets", "total_ips", "total_vulns", "risk_score"},
|
||||
"vulnerabilities": {"vuln_title", "severity", "confidence", "asset_value", "last_seen_at", "workspace"},
|
||||
}
|
||||
|
||||
// tableColumnMinWidths defines per-column minimum widths for specific tables (column_name → min_width)
|
||||
var tableColumnMinWidths = map[string]map[string]int{
|
||||
"schedules": {
|
||||
"name": 30,
|
||||
"schedule": 20,
|
||||
},
|
||||
"assets": {
|
||||
"title": 30,
|
||||
},
|
||||
}
|
||||
|
||||
// dbCmd - parent command for database management
|
||||
var dbCmd = &cobra.Command{
|
||||
Use: "db",
|
||||
@@ -614,24 +627,8 @@ func listTableRecordsOnce(ctx context.Context, cfg *config.Config, printer *term
|
||||
printer.Info("Table: %s", records.Table)
|
||||
fmt.Printf("Showing records %d-%d of %d\n\n", startRecord, endRecord, records.TotalCount)
|
||||
|
||||
// Output as markdown table
|
||||
tableStr := formatAsMarkdownTable(records.Records, columns, globalWidth, hideDefaultColumns, excludeColumns)
|
||||
|
||||
// Render with glamour for styled markdown table
|
||||
renderer, err := glamour.NewTermRenderer(
|
||||
glamour.WithAutoStyle(),
|
||||
glamour.WithWordWrap(0),
|
||||
)
|
||||
if err == nil {
|
||||
rendered, renderErr := renderer.Render(tableStr)
|
||||
if renderErr == nil {
|
||||
fmt.Print(rendered)
|
||||
} else {
|
||||
fmt.Println(tableStr)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(tableStr)
|
||||
}
|
||||
// Render table using tablewriter
|
||||
renderTableWithTablewriter(dbTable, records.Records, columns, globalWidth, hideDefaultColumns, excludeColumns)
|
||||
|
||||
// Show pagination hints
|
||||
if records.TotalCount > endRecord {
|
||||
@@ -722,23 +719,24 @@ func isHiddenColumn(col string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// formatAsMarkdownTable formats records as a markdown table
|
||||
func formatAsMarkdownTable(records interface{}, columns []string, maxWidth int, hideDefaultColumns bool, excludeColumns map[string]bool) string {
|
||||
// renderTableWithTablewriter renders records directly to stdout using tablewriter
|
||||
func renderTableWithTablewriter(tableName string, records interface{}, columns []string, maxWidth int, hideDefaultColumns bool, excludeColumns map[string]bool) {
|
||||
// Convert records to []map[string]interface{}
|
||||
jsonBytes, _ := json.Marshal(records)
|
||||
var data []map[string]interface{}
|
||||
if err := json.Unmarshal(jsonBytes, &data); err != nil {
|
||||
return "No records found."
|
||||
fmt.Println("No records found.")
|
||||
return
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return "No records found."
|
||||
fmt.Println("No records found.")
|
||||
return
|
||||
}
|
||||
|
||||
// Get headers (all keys or selected columns)
|
||||
var headers []string
|
||||
if len(columns) > 0 {
|
||||
// Filter out excluded columns from specified columns
|
||||
for _, col := range columns {
|
||||
if !excludeColumns[col] {
|
||||
headers = append(headers, col)
|
||||
@@ -746,11 +744,9 @@ func formatAsMarkdownTable(records interface{}, columns []string, maxWidth int,
|
||||
}
|
||||
} else {
|
||||
for key := range data[0] {
|
||||
// Skip hidden columns if hideDefaultColumns is true
|
||||
if hideDefaultColumns && isHiddenColumn(key) {
|
||||
continue
|
||||
}
|
||||
// Skip excluded columns
|
||||
if excludeColumns[key] {
|
||||
continue
|
||||
}
|
||||
@@ -759,39 +755,75 @@ func formatAsMarkdownTable(records interface{}, columns []string, maxWidth int,
|
||||
sort.Strings(headers)
|
||||
}
|
||||
|
||||
// Build markdown table
|
||||
var sb strings.Builder
|
||||
|
||||
// Header row
|
||||
sb.WriteString("| ")
|
||||
sb.WriteString(strings.Join(headers, " | "))
|
||||
sb.WriteString(" |\n")
|
||||
|
||||
// Separator row
|
||||
sb.WriteString("|")
|
||||
for range headers {
|
||||
sb.WriteString(" --- |")
|
||||
// Build tablewriter options
|
||||
opts := []tablewriter.Option{
|
||||
tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{
|
||||
Borders: tw.Border{
|
||||
Left: tw.Off,
|
||||
Right: tw.Off,
|
||||
Top: tw.Off,
|
||||
Bottom: tw.Off,
|
||||
},
|
||||
Settings: tw.Settings{
|
||||
Separators: tw.Separators{
|
||||
BetweenColumns: tw.On,
|
||||
BetweenRows: tw.Off,
|
||||
},
|
||||
Lines: tw.Lines{
|
||||
ShowHeaderLine: tw.On,
|
||||
ShowTop: tw.Off,
|
||||
ShowBottom: tw.Off,
|
||||
ShowFooterLine: tw.Off,
|
||||
},
|
||||
},
|
||||
Symbols: tw.NewSymbols(tw.StyleLight),
|
||||
})),
|
||||
tablewriter.WithHeaderAlignment(tw.AlignLeft),
|
||||
tablewriter.WithRowAlignment(tw.AlignLeft),
|
||||
tablewriter.WithHeaderAutoFormat(tw.Off),
|
||||
tablewriter.WithHeaderAutoWrap(tw.WrapNone),
|
||||
tablewriter.WithTrimSpace(tw.On),
|
||||
}
|
||||
if maxWidth > 0 {
|
||||
opts = append(opts, tablewriter.WithMaxWidth(maxWidth))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Data rows
|
||||
for _, row := range data {
|
||||
sb.WriteString("| ")
|
||||
// Apply per-column minimum widths if defined for this table
|
||||
if colWidths, ok := tableColumnMinWidths[tableName]; ok {
|
||||
widths := tw.NewMapper[int, int]()
|
||||
for i, h := range headers {
|
||||
val := formatTableValue(row[h], maxWidth)
|
||||
if i > 0 {
|
||||
sb.WriteString(" | ")
|
||||
if minW, exists := colWidths[h]; exists {
|
||||
widths[i] = minW
|
||||
}
|
||||
sb.WriteString(val)
|
||||
}
|
||||
sb.WriteString(" |\n")
|
||||
if len(widths) > 0 {
|
||||
opts = append(opts, tablewriter.WithColumnWidths(widths))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
table := tablewriter.NewTable(os.Stdout, opts...)
|
||||
|
||||
// Convert headers to []any for variadic call
|
||||
headerArgs := make([]any, len(headers))
|
||||
for i, h := range headers {
|
||||
headerArgs[i] = h
|
||||
}
|
||||
table.Header(headerArgs...)
|
||||
|
||||
// Add data rows
|
||||
for _, row := range data {
|
||||
rowArgs := make([]any, len(headers))
|
||||
for i, h := range headers {
|
||||
rowArgs[i] = formatTableValue(row[h], h)
|
||||
}
|
||||
_ = table.Append(rowArgs...)
|
||||
}
|
||||
|
||||
_ = table.Render()
|
||||
}
|
||||
|
||||
// formatTableValue converts a value to string for markdown display
|
||||
func formatTableValue(v interface{}, maxWidth int) string {
|
||||
// formatTableValue converts a value to string for table display and applies column-specific coloring
|
||||
func formatTableValue(v interface{}, columnName string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -799,24 +831,41 @@ func formatTableValue(v interface{}, maxWidth int) string {
|
||||
var s string
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
// Escape pipe characters and newlines
|
||||
s = strings.ReplaceAll(val, "|", "\\|")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Compact JSON for complex types
|
||||
s = strings.ReplaceAll(val, "\n", " ")
|
||||
case []interface{}:
|
||||
// Format arrays as comma-separated values without brackets/quotes
|
||||
parts := make([]string, 0, len(val))
|
||||
for _, item := range val {
|
||||
parts = append(parts, fmt.Sprintf("%v", item))
|
||||
}
|
||||
s = strings.Join(parts, ", ")
|
||||
case map[string]interface{}:
|
||||
b, _ := json.Marshal(val)
|
||||
s = string(b)
|
||||
default:
|
||||
s = fmt.Sprintf("%v", val)
|
||||
}
|
||||
|
||||
// Apply width limit
|
||||
if maxWidth > 0 && len(s) > maxWidth {
|
||||
if maxWidth > 3 {
|
||||
s = s[:maxWidth-3] + "..."
|
||||
} else {
|
||||
s = s[:maxWidth]
|
||||
}
|
||||
// Apply column-specific coloring
|
||||
switch columnName {
|
||||
case "status":
|
||||
s = terminal.ColorizeStatus(s)
|
||||
case "trigger_type":
|
||||
s = terminal.ColorizeTriggerType(s)
|
||||
case "is_enabled":
|
||||
s = terminal.ColorizeEnabled(s)
|
||||
case "workflow_kind":
|
||||
s = terminal.ColorizeWorkflowKind(s)
|
||||
case "schedule":
|
||||
s = terminal.ColorizeSchedule(s)
|
||||
case "status_code":
|
||||
s = terminal.ColorizeStatusCode(s)
|
||||
case "source":
|
||||
s = terminal.ColorizeSource(s)
|
||||
case "asset_type":
|
||||
s = terminal.ColorizeAssetType(s)
|
||||
case "tech":
|
||||
s = terminal.Gray(s)
|
||||
}
|
||||
|
||||
return s
|
||||
|
||||
+2
-1
@@ -285,7 +285,7 @@ func init() {
|
||||
// Global flags available to all subcommands
|
||||
rootCmd.PersistentFlags().BoolVar(&globalForce, "force", false, "skip confirmation prompts and force operations")
|
||||
rootCmd.PersistentFlags().BoolVar(&globalJSON, "json", false, "output in JSON format")
|
||||
rootCmd.PersistentFlags().IntVar(&globalWidth, "width", 80, "max column width for table display (0 = no limit)")
|
||||
rootCmd.PersistentFlags().IntVar(&globalWidth, "width", 70, "max column width for table display (0 = no limit)")
|
||||
|
||||
// Suppress usage display and default error output (we handle errors in Execute())
|
||||
rootCmd.SilenceUsage = true
|
||||
@@ -318,6 +318,7 @@ func init() {
|
||||
rootCmd.AddCommand(evalCmd)
|
||||
rootCmd.AddCommand(clientCmd)
|
||||
rootCmd.AddCommand(uninstallCmd)
|
||||
rootCmd.AddCommand(assetsCmd)
|
||||
}
|
||||
|
||||
// installRequiredBinaries installs all required binaries from the registry.
|
||||
|
||||
+137
-3
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/j3ssie/osmedeus/v5/internal/logger"
|
||||
"github.com/j3ssie/osmedeus/v5/internal/parser"
|
||||
"github.com/j3ssie/osmedeus/v5/internal/terminal"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -67,6 +68,7 @@ var (
|
||||
queueRunProcess bool
|
||||
webhookRun bool
|
||||
webhookAuthKey string
|
||||
cronSchedule string
|
||||
|
||||
// Chunk mode flags
|
||||
chunkSize int
|
||||
@@ -157,6 +159,9 @@ func init() {
|
||||
// Webhook flags
|
||||
runCmd.Flags().BoolVar(&webhookRun, "as-webhook", false, "register a webhook trigger for this run instead of executing immediately")
|
||||
runCmd.Flags().StringVar(&webhookAuthKey, "webhook-auth-key", "", "optional authentication key for the webhook trigger")
|
||||
|
||||
// Cron schedule flag
|
||||
runCmd.Flags().StringVar(&cronSchedule, "as-cron", "", "create a cron schedule instead of executing (e.g., '0 2 * * *' for daily at 2am)")
|
||||
}
|
||||
|
||||
// captureExplicitFlags records which CLI flags were explicitly set by the user
|
||||
@@ -531,6 +536,11 @@ func runRun(cmd *cobra.Command, args []string) error {
|
||||
return runWebhookRun(cfg, allTargets, printer)
|
||||
}
|
||||
|
||||
// Handle cron schedule creation mode
|
||||
if cronSchedule != "" {
|
||||
return runCronSchedule(cfg, allTargets, printer)
|
||||
}
|
||||
|
||||
// Handle queue mode
|
||||
if queueRun {
|
||||
return runQueuedRun(cfg, allTargets, printer)
|
||||
@@ -937,6 +947,33 @@ func executeRunForTargetWithContext(ctx context.Context, workflow *core.Workflow
|
||||
fmt.Printf(" %s baseThreads: %s\n", terminal.Gray("│"), terminal.Gray(fmt.Sprintf("%d", baseThreads)))
|
||||
fmt.Printf(" %s Today: %s\n", terminal.Gray("│"), terminal.Gray(time.Now().Format("2006-01-02")))
|
||||
fmt.Println(terminal.Yellow(separator))
|
||||
|
||||
// Show parameter tables in dry-run
|
||||
if workflow.IsFlow() && loader != nil && len(workflow.Modules) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println("◆ " + terminal.Bold("Module Parameters:"))
|
||||
for _, m := range workflow.Modules {
|
||||
if m.Path == "" {
|
||||
continue
|
||||
}
|
||||
mod, err := loader.LoadWorkflow(m.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mToggle, mSpeed, _, _ := core.CategorizeParams(mod.Params)
|
||||
if len(mToggle)+len(mSpeed) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(" ◼ " + terminal.Bold(m.Name) + ":")
|
||||
printSpeedControlParams(mSpeed)
|
||||
printToggleParams(mToggle)
|
||||
}
|
||||
} else if len(workflow.Params) > 0 {
|
||||
toggle, speed, _, _ := categorizeParams(workflow.Params)
|
||||
printSpeedControlParams(speed)
|
||||
printToggleParams(toggle)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
@@ -1485,18 +1522,32 @@ func printResultSummary(result *core.WorkflowResult) {
|
||||
printer.KeyValue("Status", terminal.StatusBadge(string(result.Status)))
|
||||
printer.KeyValue("Duration", formatDuration(result.EndTime.Sub(result.StartTime)))
|
||||
|
||||
if len(result.ModuleResults) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println(terminal.ResultSymbol() + " " + terminal.Bold("Module Results:"))
|
||||
var rows [][]string
|
||||
for _, mod := range result.ModuleResults {
|
||||
rows = append(rows, []string{
|
||||
terminal.StepSymbol(string(mod.Status)),
|
||||
mod.ModuleName,
|
||||
terminal.Magenta(formatDuration(mod.Duration)),
|
||||
})
|
||||
}
|
||||
printMarkdownTable([]string{terminal.Bold("Status"), terminal.Bold("Module"), terminal.Bold("Duration")}, rows, "clc")
|
||||
}
|
||||
|
||||
if len(result.Steps) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println(terminal.ResultSymbol() + " " + terminal.Bold("Step Results:"))
|
||||
var rows [][]string
|
||||
for _, step := range result.Steps {
|
||||
rows = append(rows, []string{
|
||||
terminal.PaddedStepSymbol(string(step.Status)),
|
||||
terminal.StepSymbol(string(step.Status)),
|
||||
step.StepName,
|
||||
formatDuration(step.Duration),
|
||||
terminal.Magenta(formatDuration(step.Duration)),
|
||||
})
|
||||
}
|
||||
printMarkdownTable([]string{"Status", "Step", "Duration"}, rows)
|
||||
printMarkdownTable([]string{terminal.Bold("Status"), terminal.Bold("Step"), terminal.Bold("Duration")}, rows, "clc")
|
||||
}
|
||||
|
||||
if len(result.Artifacts) > 0 {
|
||||
@@ -2089,6 +2140,89 @@ func runWebhookRun(cfg *config.Config, allTargets []string, printer *terminal.Pr
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCronSchedule creates cron schedule records in the database instead of executing immediately.
|
||||
func runCronSchedule(cfg *config.Config, allTargets []string, printer *terminal.Printer) error {
|
||||
// Determine workflow name and kind
|
||||
workflowName := flowName
|
||||
workflowKind := "flow"
|
||||
if workflowName == "" && len(moduleNames) > 0 {
|
||||
workflowName = moduleNames[0]
|
||||
workflowKind = "module"
|
||||
}
|
||||
|
||||
if workflowName == "" {
|
||||
return fmt.Errorf("workflow name required (use -f or -m)")
|
||||
}
|
||||
|
||||
// Validate cron expression
|
||||
cronParser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
schedule, err := cronParser.Parse(cronSchedule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid cron expression %q: %w\n Examples: '0 2 * * *' (daily 2am), '0 */6 * * *' (every 6h), '0 0 * * 1' (weekly Monday)", cronSchedule, err)
|
||||
}
|
||||
|
||||
// Compute next run time
|
||||
nextRun := schedule.Next(time.Now())
|
||||
|
||||
// Parse additional params
|
||||
params := make(map[string]interface{})
|
||||
for _, flag := range paramFlags {
|
||||
parts := strings.SplitN(flag, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
params[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Connect to database and migrate
|
||||
_, err = database.Connect(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
if err := database.Migrate(ctx); err != nil {
|
||||
return fmt.Errorf("failed to migrate database: %w", err)
|
||||
}
|
||||
|
||||
printer.Section("Creating Cron Schedules")
|
||||
|
||||
var created int
|
||||
for _, target := range allTargets {
|
||||
scheduleName := fmt.Sprintf("cron-%s-%s", workflowName, sanitizeTargetForWorkspace(target))
|
||||
|
||||
sched, err := database.CreateSchedule(ctx, database.CreateScheduleInput{
|
||||
Name: scheduleName,
|
||||
WorkflowName: workflowName,
|
||||
WorkflowKind: workflowKind,
|
||||
Target: target,
|
||||
Params: params,
|
||||
TriggerType: "cron",
|
||||
Schedule: cronSchedule,
|
||||
Enabled: true,
|
||||
NextRun: &nextRun,
|
||||
})
|
||||
if err != nil {
|
||||
printer.Error("Failed to create schedule for %s: %s", target, err)
|
||||
continue
|
||||
}
|
||||
|
||||
created++
|
||||
printer.Success("Schedule created: %s -> %s",
|
||||
terminal.Green(target),
|
||||
terminal.Yellow(workflowName))
|
||||
printer.Info(" ID: %s", terminal.Cyan(sched.ID))
|
||||
printer.Info(" Schedule: %s", terminal.Cyan(cronSchedule))
|
||||
printer.Info(" Next run: %s", terminal.Cyan(nextRun.Format(time.RFC3339)))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
printer.Info("Created %d cron schedule(s)", created)
|
||||
printer.Info("Use '%s' to list schedules", terminal.Cyan("osmedeus db ls --table schedules"))
|
||||
printer.Info("Run '%s' to activate the scheduler", terminal.Cyan("osmedeus serve"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runQueuedRun(cfg *config.Config, allTargets []string, printer *terminal.Printer) error {
|
||||
// Determine workflow name and kind
|
||||
workflowName := flowName
|
||||
|
||||
@@ -127,6 +127,21 @@ func UsageRun() string {
|
||||
` + terminal.Green("# Process queued tasks with concurrency") + `
|
||||
osmedeus run ` + terminal.Yellow("--queue-run") + ` ` + terminal.Yellow("--concurrency") + ` 3
|
||||
|
||||
` + terminal.Green("# Register a webhook trigger (no execution)") + `
|
||||
osmedeus run ` + terminal.Yellow("--as-webhook") + ` ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-t") + ` example.com
|
||||
|
||||
` + terminal.Green("# Register a webhook with auth key") + `
|
||||
osmedeus run ` + terminal.Yellow("--as-webhook") + ` ` + terminal.Yellow("--webhook-auth-key") + ` mykey ` + terminal.Yellow("-f") + ` general ` + terminal.Yellow("-t") + ` example.com
|
||||
|
||||
` + terminal.Green("# Create a cron schedule (daily at 2am)") + `
|
||||
osmedeus run ` + terminal.Yellow("--as-cron") + ` '0 2 * * *' ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-t") + ` example.com
|
||||
|
||||
` + terminal.Green("# Create a cron schedule (every 6 hours) for a flow") + `
|
||||
osmedeus run ` + terminal.Yellow("--as-cron") + ` '0 */6 * * *' ` + terminal.Yellow("-f") + ` general ` + terminal.Yellow("-t") + ` example.com
|
||||
|
||||
` + terminal.Green("# Create cron schedules for multiple targets") + `
|
||||
osmedeus run ` + terminal.Yellow("--as-cron") + ` '0 0 * * 1' ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-T") + ` targets.txt
|
||||
|
||||
` + docsFooter()
|
||||
}
|
||||
|
||||
@@ -841,6 +856,12 @@ func UsageAllExamples() string {
|
||||
` + terminal.Green("# Split into 4 equal chunks and run chunk 0") + `
|
||||
osmedeus run ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-T") + ` targets.txt ` + terminal.Yellow("--chunk-count") + ` 4 ` + terminal.Yellow("--chunk-part") + ` 0
|
||||
|
||||
` + terminal.Green("# Register a webhook trigger") + `
|
||||
osmedeus run ` + terminal.Yellow("--as-webhook") + ` ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-t") + ` example.com
|
||||
|
||||
` + terminal.Green("# Create a cron schedule (daily at 2am)") + `
|
||||
osmedeus run ` + terminal.Yellow("--as-cron") + ` '0 2 * * *' ` + terminal.Yellow("-m") + ` recon ` + terminal.Yellow("-t") + ` example.com
|
||||
|
||||
` + terminal.BoldYellow("★ Function Eval (Powerful Scripting)") + `
|
||||
` + terminal.Green("# Print markdown file") + `
|
||||
osmedeus func e 'print_markdown_from_file("README.md")'
|
||||
@@ -1013,6 +1034,11 @@ func UsageFullExample() string {
|
||||
` + terminal.Yellow("-D, --distributed-run") + ` Submit run to distributed worker queue
|
||||
` + terminal.Yellow("--redis-url") + ` Redis connection URL for distributed mode
|
||||
|
||||
` + terminal.Cyan(" Schedule & Trigger:") + `
|
||||
` + terminal.Yellow("--as-webhook") + ` Register a webhook trigger instead of executing
|
||||
` + terminal.Yellow("--webhook-auth-key") + ` Authentication key for the webhook trigger
|
||||
` + terminal.Yellow("--as-cron") + ` Create a cron schedule instead of executing (e.g., '0 2 * * *')
|
||||
|
||||
` + terminal.BoldYellow("SERVE COMMAND") + ` - Start REST API server
|
||||
` + terminal.Gray("───────────────────────────────────────────────────────────────────") + `
|
||||
osmedeus serve [flags]
|
||||
@@ -1288,6 +1314,50 @@ func UsageUninstall() string {
|
||||
` + docsFooter()
|
||||
}
|
||||
|
||||
// UsageAssets returns the Long description for the assets command
|
||||
func UsageAssets() string {
|
||||
return terminal.BoldCyan("◆ Description") + `
|
||||
Query and list discovered assets from the database.
|
||||
A shortcut for ` + terminal.Yellow("osmedeus db ls -t assets") + ` with first-class support
|
||||
for fuzzy search, source/type filtering, and asset statistics.
|
||||
|
||||
` + terminal.BoldCyan("▷ Examples") + `
|
||||
` + terminal.Green("# List all assets (default columns)") + `
|
||||
osmedeus assets
|
||||
|
||||
` + terminal.Green("# Fuzzy search across asset fields") + `
|
||||
osmedeus assets example.com
|
||||
|
||||
` + terminal.Green("# Filter by workspace") + `
|
||||
osmedeus assets ` + terminal.Yellow("-w") + ` myworkspace
|
||||
|
||||
` + terminal.Green("# Filter by source") + `
|
||||
osmedeus assets ` + terminal.Yellow("--source") + ` httpx
|
||||
|
||||
` + terminal.Green("# Filter by asset type") + `
|
||||
osmedeus assets ` + terminal.Yellow("--type") + ` web
|
||||
|
||||
` + terminal.Green("# Combined filters") + `
|
||||
osmedeus assets ` + terminal.Yellow("--source") + ` httpx ` + terminal.Yellow("--type") + ` web
|
||||
|
||||
` + terminal.Green("# Show asset statistics") + `
|
||||
osmedeus assets ` + terminal.Yellow("--stats") + `
|
||||
|
||||
` + terminal.Green("# Stats filtered by workspace") + `
|
||||
osmedeus assets ` + terminal.Yellow("--stats") + ` ` + terminal.Yellow("-w") + ` myworkspace
|
||||
|
||||
` + terminal.Green("# With pagination") + `
|
||||
osmedeus assets example.com ` + terminal.Yellow("--limit") + ` 100
|
||||
|
||||
` + terminal.Green("# JSON output") + `
|
||||
osmedeus assets example.com ` + terminal.Yellow("--json") + `
|
||||
|
||||
` + terminal.Green("# Custom columns") + `
|
||||
osmedeus assets ` + terminal.Yellow("--columns") + ` "asset_value,url,status_code"
|
||||
|
||||
` + docsFooter()
|
||||
}
|
||||
|
||||
// docsFooter returns the documentation footer
|
||||
func docsFooter() string {
|
||||
return terminal.HiCyan("📖 Documentation: ") + terminal.HiWhite(core.DOCS) + "\n"
|
||||
|
||||
+6
-6
@@ -201,18 +201,18 @@ var workerStatusCmd = &cobra.Command{
|
||||
// Determine active headers
|
||||
headers := resolveWorkerColumns(allHeaders, defaultHeaders, workerColumns, workerExcludeColumns)
|
||||
|
||||
// Map headers to column indices and project rows
|
||||
// Map headers to column indices and build records for tablewriter
|
||||
colIndices := mapHeaderIndices(allHeaders, headers)
|
||||
var rows [][]string
|
||||
var records []map[string]interface{}
|
||||
for _, row := range allRows {
|
||||
projected := make([]string, len(colIndices))
|
||||
rec := make(map[string]interface{}, len(headers))
|
||||
for i, idx := range colIndices {
|
||||
projected[i] = row.display[idx]
|
||||
rec[headers[i]] = row.display[idx]
|
||||
}
|
||||
rows = append(rows, projected)
|
||||
records = append(records, rec)
|
||||
}
|
||||
|
||||
printMarkdownTable(headers, rows)
|
||||
renderTableWithTablewriter("", records, headers, globalWidth, false, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
+12
-12
@@ -391,26 +391,26 @@ func runWorkerQueueList(cmd *cobra.Command, args []string) error {
|
||||
// Display table
|
||||
printer.Section("Queued Tasks")
|
||||
|
||||
headers := []string{"ID", "UUID", "Workflow", "Target", "Status", "Priority", "IsFile", "Created"}
|
||||
var rows [][]string
|
||||
columns := []string{"id", "uuid", "workflow", "target", "status", "priority", "is_file", "created"}
|
||||
var records []map[string]interface{}
|
||||
for _, r := range runs {
|
||||
isFileStr := ""
|
||||
if r.InputIsFile {
|
||||
isFileStr = "yes"
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("%d", r.ID),
|
||||
truncateUUID(r.RunUUID),
|
||||
r.WorkflowName,
|
||||
truncateQueueStr(r.Target, 40),
|
||||
colorizeQueueStatus(r.Status),
|
||||
r.RunPriority,
|
||||
isFileStr,
|
||||
r.CreatedAt.Format("2006-01-02 15:04"),
|
||||
records = append(records, map[string]interface{}{
|
||||
"id": fmt.Sprintf("%d", r.ID),
|
||||
"uuid": truncateUUID(r.RunUUID),
|
||||
"workflow": r.WorkflowName,
|
||||
"target": truncateQueueStr(r.Target, 40),
|
||||
"status": r.Status,
|
||||
"priority": r.RunPriority,
|
||||
"is_file": isFileStr,
|
||||
"created": r.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
|
||||
printMarkdownTable(headers, rows)
|
||||
renderTableWithTablewriter("", records, columns, globalWidth, false, nil)
|
||||
fmt.Println()
|
||||
printer.Info("Total: %d queued task(s)", len(runs))
|
||||
return nil
|
||||
|
||||
+237
-167
@@ -13,7 +13,9 @@ import (
|
||||
"github.com/j3ssie/osmedeus/v5/internal/linter"
|
||||
"github.com/j3ssie/osmedeus/v5/internal/parser"
|
||||
"github.com/j3ssie/osmedeus/v5/internal/terminal"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// workflowCmd represents the workflow command
|
||||
@@ -555,130 +557,15 @@ func stripAnsi(s string) string {
|
||||
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
|
||||
// displayWidth returns the visual display width of a string, ignoring ANSI codes.
|
||||
// Unlike len(), this correctly handles multi-byte Unicode characters like ✔ (3 bytes, 1 display char).
|
||||
func displayWidth(s string) int {
|
||||
return runewidth.StringWidth(stripAnsi(s))
|
||||
}
|
||||
|
||||
// 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
|
||||
// categorizeParams is a convenience wrapper around core.CategorizeParams
|
||||
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
|
||||
return core.CategorizeParams(params)
|
||||
}
|
||||
|
||||
// printToggleParams prints toggle parameters with green highlighting
|
||||
@@ -734,6 +621,26 @@ func printSpeedControlParams(params []core.Param) {
|
||||
printMarkdownTable([]string{"Name", "Default", "Required"}, rows)
|
||||
}
|
||||
|
||||
// printToggleParamsGrouped prints all toggle parameters across modules in a single table
|
||||
func printToggleParamsGrouped(rows [][]string) {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("◐ " + terminal.Bold("Toggle Parameters:"))
|
||||
printMarkdownTable([]string{"Module", "Name", "Default", "Required"}, rows)
|
||||
}
|
||||
|
||||
// printSpeedControlParamsGrouped prints all speed control parameters across modules in a single table
|
||||
func printSpeedControlParamsGrouped(rows [][]string) {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("◎ " + terminal.Bold("Speed Control Parameters:"))
|
||||
printMarkdownTable([]string{"Module", "Name", "Default", "Required"}, rows)
|
||||
}
|
||||
|
||||
// printConfigParams prints config parameters with magenta highlighting
|
||||
func printConfigParams(params []core.Param) {
|
||||
if len(params) == 0 {
|
||||
@@ -783,56 +690,79 @@ func printGeneralParams(params []core.Param) {
|
||||
}
|
||||
rows = append(rows, []string{terminal.Cyan(p.Name), coloredDefault, required})
|
||||
}
|
||||
printMarkdownTable([]string{"Name", "Default", "Required"}, rows)
|
||||
// Use width-aware table to wrap long default values
|
||||
// Reserve: ~42 for Name col, ~10 for Required col, ~8 for separators/padding
|
||||
maxDefaultWidth := 60 // sensible default
|
||||
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 80 {
|
||||
maxDefaultWidth = w - 60
|
||||
if maxDefaultWidth < 30 {
|
||||
maxDefaultWidth = 30
|
||||
}
|
||||
}
|
||||
printMarkdownTableWithWidth([]string{"Name", "Default", "Required"}, rows, maxDefaultWidth)
|
||||
}
|
||||
|
||||
// printMarkdownTable prints an aligned markdown table (supports colored cells)
|
||||
func printMarkdownTable(headers []string, rows [][]string) {
|
||||
// Calculate column widths (using display length, not byte length)
|
||||
// printMarkdownTable prints a box-drawing table with left-aligned columns (supports colored cells).
|
||||
// The optional align parameter is accepted for backward compatibility but ignored.
|
||||
func printMarkdownTable(headers []string, rows [][]string, align ...string) {
|
||||
// Calculate column widths (using display width, not byte length)
|
||||
widths := make([]int, len(headers))
|
||||
for i, h := range headers {
|
||||
widths[i] = len(stripAnsi(h))
|
||||
widths[i] = displayWidth(h)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for i, cell := range row {
|
||||
displayLen := len(stripAnsi(cell))
|
||||
if i < len(widths) && displayLen > widths[i] {
|
||||
widths[i] = displayLen
|
||||
dw := displayWidth(cell)
|
||||
if i < len(widths) && dw > widths[i] {
|
||||
widths[i] = dw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print header
|
||||
fmt.Print("|")
|
||||
for i, h := range headers {
|
||||
fmt.Printf(" %-*s |", widths[i], h)
|
||||
printAlignedCell(h, widths[i])
|
||||
if i < len(headers)-1 {
|
||||
fmt.Print("│")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Print separator
|
||||
fmt.Print("|")
|
||||
for _, w := range widths {
|
||||
fmt.Printf("-%s-|", strings.Repeat("-", w))
|
||||
// Print separator with box-drawing characters
|
||||
for i, w := range widths {
|
||||
fmt.Print(strings.Repeat("─", w+2))
|
||||
if i < len(widths)-1 {
|
||||
fmt.Print("┼")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Print rows (with ANSI-aware padding)
|
||||
// Print rows
|
||||
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))
|
||||
printAlignedCell(cell, widths[i])
|
||||
if i < len(headers)-1 {
|
||||
fmt.Print("│")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// printAlignedCell prints a single left-aligned table cell within colWidth.
|
||||
func printAlignedCell(cell string, colWidth int) {
|
||||
dw := displayWidth(cell)
|
||||
pad := colWidth - dw
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
fmt.Printf(" %s%s ", cell, strings.Repeat(" ", pad))
|
||||
}
|
||||
|
||||
// wrapText wraps text to maxWidth characters, preserving existing newlines
|
||||
func wrapText(text string, maxWidth int) []string {
|
||||
if maxWidth <= 0 {
|
||||
@@ -892,7 +822,7 @@ func wrapText(text string, maxWidth int) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// printMarkdownTableWithWidth prints a table with column width wrapping
|
||||
// printMarkdownTableWithWidth prints a box-drawing 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 {
|
||||
@@ -903,9 +833,9 @@ func printMarkdownTableWithWidth(headers []string, rows [][]string, maxWidth int
|
||||
var wrappedRows []wrappedRow
|
||||
widths := make([]int, len(headers))
|
||||
|
||||
// Initialize widths with header lengths
|
||||
// Initialize widths with header display widths
|
||||
for i, h := range headers {
|
||||
widths[i] = len(stripAnsi(h))
|
||||
widths[i] = displayWidth(h)
|
||||
}
|
||||
|
||||
// Wrap each cell and track widths
|
||||
@@ -923,9 +853,9 @@ func printMarkdownTableWithWidth(headers []string, rows [][]string, maxWidth int
|
||||
}
|
||||
// Track max width for this column
|
||||
for _, line := range wrapped {
|
||||
lineWidth := len(stripAnsi(line))
|
||||
if lineWidth > widths[i] {
|
||||
widths[i] = lineWidth
|
||||
lw := displayWidth(line)
|
||||
if lw > widths[i] {
|
||||
widths[i] = lw
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -933,32 +863,35 @@ func printMarkdownTableWithWidth(headers []string, rows [][]string, maxWidth int
|
||||
}
|
||||
|
||||
// Print header
|
||||
fmt.Print("|")
|
||||
for i, h := range headers {
|
||||
fmt.Printf(" %-*s |", widths[i], h)
|
||||
printAlignedCell(h, widths[i])
|
||||
if i < len(headers)-1 {
|
||||
fmt.Print("│")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Print separator
|
||||
fmt.Print("|")
|
||||
for _, w := range widths {
|
||||
fmt.Printf("-%s-|", strings.Repeat("-", w))
|
||||
// Print separator with box-drawing characters
|
||||
for i, w := range widths {
|
||||
fmt.Print(strings.Repeat("─", w+2))
|
||||
if i < len(widths)-1 {
|
||||
fmt.Print("┼")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Print rows (with multi-line support)
|
||||
// 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))
|
||||
printAlignedCell(cell, widths[colIdx])
|
||||
if colIdx < len(headers)-1 {
|
||||
fmt.Print("│")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -1024,23 +957,87 @@ var workflowShowCmd = &cobra.Command{
|
||||
printer.KeyValue("Description", workflow.Description)
|
||||
printer.KeyValue("File", terminal.Gray(workflow.FilePath))
|
||||
|
||||
// For flows, show aggregated module param counts before Usage
|
||||
if workflow.IsFlow() && len(workflow.Modules) > 0 {
|
||||
var totalSpeed, totalToggle, totalCfg, totalGeneral int
|
||||
for _, m := range workflow.Modules {
|
||||
if m.Path == "" {
|
||||
continue
|
||||
}
|
||||
mod, modErr := loader.LoadWorkflow(m.Path)
|
||||
if modErr != nil {
|
||||
continue
|
||||
}
|
||||
mToggle, mSpeed, mCfg, mGeneral := core.CategorizeParams(mod.Params)
|
||||
totalSpeed += len(mSpeed)
|
||||
totalToggle += len(mToggle)
|
||||
totalCfg += len(mCfg)
|
||||
totalGeneral += len(mGeneral)
|
||||
}
|
||||
var aggParts []string
|
||||
if totalSpeed > 0 {
|
||||
aggParts = append(aggParts, fmt.Sprintf("%s speed control", terminal.Magenta(fmt.Sprintf("%d", totalSpeed))))
|
||||
}
|
||||
if totalToggle > 0 {
|
||||
aggParts = append(aggParts, fmt.Sprintf("%s toggle", terminal.Magenta(fmt.Sprintf("%d", totalToggle))))
|
||||
}
|
||||
if totalCfg > 0 {
|
||||
aggParts = append(aggParts, fmt.Sprintf("%s config", terminal.Magenta(fmt.Sprintf("%d", totalCfg))))
|
||||
}
|
||||
if totalGeneral > 0 {
|
||||
aggParts = append(aggParts, fmt.Sprintf("%s general", terminal.Magenta(fmt.Sprintf("%d", totalGeneral))))
|
||||
}
|
||||
if len(aggParts) > 0 {
|
||||
printer.KeyValue("Params", strings.Join(aggParts, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// Show help info
|
||||
if workflow.Help != nil {
|
||||
if workflow.Help.Usage != "" {
|
||||
printer.KeyValue("Usage", terminal.Cyan(workflow.Help.Usage))
|
||||
if strings.Contains(workflow.Help.Usage, "\n") {
|
||||
fmt.Println(" " + terminal.Gray("Usage") + ":")
|
||||
for _, line := range strings.Split(strings.TrimRight(workflow.Help.Usage, "\n "), "\n") {
|
||||
fmt.Println(" " + terminal.Cyan(line))
|
||||
}
|
||||
} else {
|
||||
printer.KeyValue("Usage", terminal.Cyan(workflow.Help.Usage))
|
||||
}
|
||||
}
|
||||
if len(workflow.Help.ExampleTargets) > 0 {
|
||||
printer.KeyValue("Example Targets", terminal.Yellow(strings.Join(workflow.Help.ExampleTargets, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// Show parameters (categorized)
|
||||
// Show parameters
|
||||
if len(workflow.Params) > 0 {
|
||||
toggle, speed, config, general := categorizeParams(workflow.Params)
|
||||
printToggleParams(toggle)
|
||||
printSpeedControlParams(speed)
|
||||
printConfigParams(config)
|
||||
printGeneralParams(general)
|
||||
toggle, speed, cfg, general := categorizeParams(workflow.Params)
|
||||
|
||||
// Always show summary counts
|
||||
var parts []string
|
||||
if len(speed) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%s speed control", terminal.Magenta(fmt.Sprintf("%d", len(speed)))))
|
||||
}
|
||||
if len(toggle) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%s toggle", terminal.Magenta(fmt.Sprintf("%d", len(toggle)))))
|
||||
}
|
||||
if len(cfg) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%s config", terminal.Magenta(fmt.Sprintf("%d", len(cfg)))))
|
||||
}
|
||||
if len(general) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%s general", terminal.Magenta(fmt.Sprintf("%d", len(general)))))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
printer.KeyValue("Params", strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
// Show full categorized tables only in verbose mode
|
||||
if showVerbose {
|
||||
printToggleParams(toggle)
|
||||
printSpeedControlParams(speed)
|
||||
printConfigParams(cfg)
|
||||
printGeneralParams(general)
|
||||
}
|
||||
}
|
||||
|
||||
// Show steps (for modules)
|
||||
@@ -1068,6 +1065,79 @@ var workflowShowCmd = &cobra.Command{
|
||||
rows = append(rows, []string{fmt.Sprintf("%d", i+1), m.Name, m.Path, deps})
|
||||
}
|
||||
printMarkdownTable([]string{"#", "Name", "Path", "Depends On"}, rows)
|
||||
|
||||
// Load each module and show params
|
||||
fmt.Println()
|
||||
fmt.Println("◆ " + terminal.Bold("Module Parameters:"))
|
||||
|
||||
if showVerbose {
|
||||
// Verbose: collect all params across modules, then render grouped tables
|
||||
var allSpeedRows [][]string
|
||||
var allToggleRows [][]string
|
||||
for _, m := range workflow.Modules {
|
||||
if m.Path == "" {
|
||||
continue
|
||||
}
|
||||
mod, err := loader.LoadWorkflow(m.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mToggle, mSpeed, _, _ := core.CategorizeParams(mod.Params)
|
||||
for _, p := range mSpeed {
|
||||
required := terminal.Gray("no")
|
||||
if p.Required {
|
||||
required = terminal.Green("yes")
|
||||
}
|
||||
defaultVal := p.DefaultString()
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
}
|
||||
allSpeedRows = append(allSpeedRows, []string{terminal.HiBlue(m.Name), terminal.HiBlue(p.Name), terminal.HiBlue(defaultVal), required})
|
||||
}
|
||||
for _, p := range mToggle {
|
||||
required := terminal.Gray("no")
|
||||
if p.Required {
|
||||
required = terminal.Green("yes")
|
||||
}
|
||||
defaultVal := p.DefaultString()
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
}
|
||||
coloredDefault := defaultVal
|
||||
if strings.ToLower(defaultVal) == "true" {
|
||||
coloredDefault = terminal.Green(defaultVal)
|
||||
} else if strings.ToLower(defaultVal) == "false" {
|
||||
coloredDefault = terminal.Gray(defaultVal)
|
||||
}
|
||||
allToggleRows = append(allToggleRows, []string{terminal.HiCyan(m.Name), terminal.HiCyan(p.Name), coloredDefault, required})
|
||||
}
|
||||
}
|
||||
printSpeedControlParamsGrouped(allSpeedRows)
|
||||
printToggleParamsGrouped(allToggleRows)
|
||||
} else {
|
||||
// Summary: show counts per module
|
||||
for _, m := range workflow.Modules {
|
||||
if m.Path == "" {
|
||||
continue
|
||||
}
|
||||
mod, err := loader.LoadWorkflow(m.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mToggle, mSpeed, _, _ := core.CategorizeParams(mod.Params)
|
||||
if len(mToggle)+len(mSpeed) == 0 {
|
||||
continue
|
||||
}
|
||||
var mParts []string
|
||||
if len(mSpeed) > 0 {
|
||||
mParts = append(mParts, fmt.Sprintf("%s speed control", terminal.Magenta(fmt.Sprintf("%d", len(mSpeed)))))
|
||||
}
|
||||
if len(mToggle) > 0 {
|
||||
mParts = append(mParts, fmt.Sprintf("%s toggle", terminal.Magenta(fmt.Sprintf("%d", len(mToggle)))))
|
||||
}
|
||||
printer.KeyValue(" "+m.Name, strings.Join(mParts, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show triggers
|
||||
@@ -1192,7 +1262,7 @@ var workflowShowCmd = &cobra.Command{
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s%s\n", terminal.BoldCyan(terminal.SymbolLightning), terminal.Gray("Tip: Use --verbose to show all variables with descriptions and default values or --yaml to see raw workflow file"))
|
||||
fmt.Printf("%s %s\n", terminal.BoldCyan(terminal.SymbolLightning), terminal.Gray("Tip: Use --verbose to show all params, variables with descriptions and default values or --yaml to see raw workflow file"))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
Reference in New Issue
Block a user