From 8d413aecb9739b03aecad9638875d76b33cb683b Mon Sep 17 00:00:00 2001 From: j3ssie Date: Tue, 17 Feb 2026 15:19:49 +0700 Subject: [PATCH] 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 --- CLAUDE.md | 8 + HACKING.md | 27 +++ README.md | 5 + docs/api-swagger/docs.go | 73 ++++++ docs/api-swagger/swagger.json | 73 ++++++ docs/api-swagger/swagger.yaml | 48 ++++ docs/api/assets.mdx | 45 ++++ go.mod | 10 +- go.sum | 14 +- internal/client/types.go | 31 +-- internal/core/param.go | 120 +++++++++- internal/core/types.go | 32 ++- internal/database/models.go | 19 +- internal/database/seed.go | 6 +- internal/database/stats.go | 2 +- internal/executor/dispatcher.go | 12 +- internal/executor/executor.go | 50 +++- internal/terminal/colors.go | 106 +++++++++ internal/terminal/db_tui.go | 8 +- internal/terminal/printer.go | 31 ++- internal/terminal/symbols.go | 2 +- pkg/cli/assets.go | 193 +++++++++++++++ pkg/cli/client.go | 116 +-------- pkg/cli/db.go | 175 +++++++++----- pkg/cli/root.go | 3 +- pkg/cli/run.go | 140 ++++++++++- pkg/cli/usage.go | 70 ++++++ pkg/cli/worker.go | 12 +- pkg/cli/worker_queue.go | 24 +- pkg/cli/workflow.go | 404 +++++++++++++++++++------------- test/e2e/function_test.go | 4 +- 31 files changed, 1438 insertions(+), 425 deletions(-) create mode 100644 pkg/cli/assets.go diff --git a/CLAUDE.md b/CLAUDE.md index da8e8d6..6bd3a03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,6 +222,14 @@ osmedeus worker set # Update worker metadata osmedeus worker queue list # List queued tasks osmedeus worker queue new -f -t # Queue task for delayed execution osmedeus worker queue run --concurrency 5 # Process queued tasks +osmedeus assets # List discovered assets +osmedeus assets -w # Filter assets by workspace +osmedeus assets --source httpx --type web # Filter by source and type +osmedeus assets --stats # Show asset statistics +osmedeus assets --stats -w # Stats filtered by workspace +osmedeus assets --columns url,title,status_code # Custom columns +osmedeus assets --limit 100 --offset 50 # Pagination +osmedeus assets --json # JSON output ``` ### Event Trigger Input Syntax diff --git a/HACKING.md b/HACKING.md index c056bf8..4d51f76 100644 --- a/HACKING.md +++ b/HACKING.md @@ -2086,6 +2086,33 @@ osmedeus worker queue new -m -T targets.txt -p key=value osmedeus worker queue run --concurrency 5 # Process queued tasks ``` +### Asset Query Commands + +```bash +# List assets (paginated table output) +osmedeus assets +osmedeus assets -w example.com # Filter by workspace +osmedeus assets --source httpx # Filter by source +osmedeus assets --type web # Filter by asset type +osmedeus assets "api.example" # Search by keyword + +# Customize output columns +osmedeus assets --columns url,title,status_code +osmedeus assets --exclude-columns raw_json_data,raw_response +osmedeus assets --all # Show all columns including hidden ones + +# Pagination +osmedeus assets --limit 100 --offset 50 + +# Asset statistics (unique technologies, sources, remarks, types) +osmedeus assets --stats +osmedeus assets --stats -w example.com # Stats for specific workspace + +# JSON output (for scripting) +osmedeus assets --json +osmedeus assets --stats --json +``` + ### Debugging Tips - Use `osmedeus --usage-example` to see comprehensive examples for all commands diff --git a/README.md b/README.md index 7c618e9..7f23c9b 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,11 @@ osmedeus serve # List available workflows osmedeus workflow list +# Query discovered assets +osmedeus assets -w example.com # List assets for workspace +osmedeus assets --stats # Show unique technologies, sources, types +osmedeus assets --source httpx --type web --json # Filter and output as JSON + # Query database tables osmedeus db list --table runs osmedeus db list --table event_logs --search "nuclei" diff --git a/docs/api-swagger/docs.go b/docs/api-swagger/docs.go index dec4305..5097ad7 100644 --- a/docs/api-swagger/docs.go +++ b/docs/api-swagger/docs.go @@ -227,6 +227,46 @@ const docTemplate = `{ } } }, + "/osm/api/asset-stats": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get unique values for technologies, sources, remarks, and asset types across all assets or filtered by workspace", + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Get asset statistics", + "parameters": [ + { + "type": "string", + "description": "Filter by workspace name", + "name": "workspace", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Asset statistics", + "schema": { + "$ref": "#/definitions/database.AssetStatsData" + } + }, + "500": { + "description": "Failed to get stats", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/osm/api/assets": { "get": { "security": [ @@ -3261,6 +3301,35 @@ const docTemplate = `{ } } }, + "database.AssetStatsData": { + "type": "object", + "properties": { + "asset_types": { + "type": "array", + "items": { + "type": "string" + } + }, + "remarks": { + "type": "array", + "items": { + "type": "string" + } + }, + "sources": { + "type": "array", + "items": { + "type": "string" + } + }, + "technologies": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "database.RunStats": { "type": "object", "properties": { @@ -3431,6 +3500,10 @@ const docTemplate = `{ "description": "Enable scheduled execution", "type": "boolean" }, + "skip_validation": { + "description": "Validation", + "type": "boolean" + }, "ssh_host": { "description": "SSH host when runner_type=ssh", "type": "string" diff --git a/docs/api-swagger/swagger.json b/docs/api-swagger/swagger.json index a824d07..78a0797 100644 --- a/docs/api-swagger/swagger.json +++ b/docs/api-swagger/swagger.json @@ -216,6 +216,46 @@ } } }, + "/osm/api/asset-stats": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get unique values for technologies, sources, remarks, and asset types across all assets or filtered by workspace", + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Get asset statistics", + "parameters": [ + { + "type": "string", + "description": "Filter by workspace name", + "name": "workspace", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Asset statistics", + "schema": { + "$ref": "#/definitions/database.AssetStatsData" + } + }, + "500": { + "description": "Failed to get stats", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/osm/api/assets": { "get": { "security": [ @@ -3250,6 +3290,35 @@ } } }, + "database.AssetStatsData": { + "type": "object", + "properties": { + "asset_types": { + "type": "array", + "items": { + "type": "string" + } + }, + "remarks": { + "type": "array", + "items": { + "type": "string" + } + }, + "sources": { + "type": "array", + "items": { + "type": "string" + } + }, + "technologies": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "database.RunStats": { "type": "object", "properties": { @@ -3420,6 +3489,10 @@ "description": "Enable scheduled execution", "type": "boolean" }, + "skip_validation": { + "description": "Validation", + "type": "boolean" + }, "ssh_host": { "description": "SSH host when runner_type=ssh", "type": "string" diff --git a/docs/api-swagger/swagger.yaml b/docs/api-swagger/swagger.yaml index 30439ea..7693493 100644 --- a/docs/api-swagger/swagger.yaml +++ b/docs/api-swagger/swagger.yaml @@ -76,6 +76,25 @@ definitions: total: type: integer type: object + database.AssetStatsData: + properties: + asset_types: + items: + type: string + type: array + remarks: + items: + type: string + type: array + sources: + items: + type: string + type: array + technologies: + items: + type: string + type: array + type: object database.RunStats: properties: completed: @@ -192,6 +211,9 @@ definitions: schedule_enabled: description: Enable scheduled execution type: boolean + skip_validation: + description: Validation + type: boolean ssh_host: description: SSH host when runner_type=ssh type: string @@ -633,6 +655,32 @@ paths: summary: Download workspace artifact tags: - Artifacts + /osm/api/asset-stats: + get: + description: Get unique values for technologies, sources, remarks, and asset + types across all assets or filtered by workspace + parameters: + - description: Filter by workspace name + in: query + name: workspace + type: string + produces: + - application/json + responses: + "200": + description: Asset statistics + schema: + $ref: '#/definitions/database.AssetStatsData' + "500": + description: Failed to get stats + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Get asset statistics + tags: + - Stats /osm/api/assets: get: description: Get a paginated list of assets with optional filtering diff --git a/docs/api/assets.mdx b/docs/api/assets.mdx index 371d775..f0ce08a 100644 --- a/docs/api/assets.mdx +++ b/docs/api/assets.mdx @@ -133,6 +133,51 @@ curl "http://localhost:8002/osm/api/assets?workspace=example.com&offset=50&limit --- +## Get Asset Statistics + +Get unique values for technologies, sources, remarks, and asset types across all assets. + +**Get all asset stats:** +```bash +curl http://localhost:8002/osm/api/asset-stats \ + -H "Authorization: Bearer $TOKEN" +``` + +**Filter by workspace:** +```bash +curl "http://localhost:8002/osm/api/asset-stats?workspace=example.com" \ + -H "Authorization: Bearer $TOKEN" +``` + +**Response:** +```json +{ + "data": { + "technologies": ["nginx/1.21.0", "nodejs", "php/8.1", "wordpress"], + "sources": ["httpx", "nuclei"], + "remarks": ["admin-panel", "production"], + "asset_types": ["http", "dns", "subdomain"] + } +} +``` + +**Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `workspace` | string | No | Filter stats by workspace name | + +**Response Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `technologies` | array | Unique detected technologies across all matching assets | +| `sources` | array | Unique discovery sources (httpx, nuclei, etc.) | +| `remarks` | array | Unique custom labels/tags/remarks | +| `asset_types` | array | Unique asset type classifications (http, dns, subdomain, ip, etc.) | + +--- + ## List Asset Diff Snapshots Get a paginated list of stored asset diff snapshots. These snapshots capture changes in assets over time. diff --git a/go.mod b/go.mod index 86001ab..09f16b3 100644 --- a/go.mod +++ b/go.mod @@ -27,14 +27,17 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/itchyny/gojq v0.12.18 github.com/json-iterator/go v1.1.12 + github.com/mattn/go-runewidth v0.0.19 github.com/mattn/go-sqlite3 v1.14.32 github.com/minio/minio-go/v7 v7.0.97 + github.com/olekukonko/tablewriter v1.1.3 github.com/orivej/go-nix v0.0.0-20180830055821-dae45d921a44 github.com/pkg/sftp v1.13.9 github.com/prometheus/client_golang v1.23.2 github.com/pulumi/pulumi-digitalocean/sdk/v4 v4.57.0 github.com/pulumi/pulumi/sdk/v3 v3.220.0 github.com/redis/rueidis v1.0.70 + github.com/robfig/cron/v3 v3.0.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag v1.16.6 @@ -81,6 +84,7 @@ require ( github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cheggaaa/pb v1.0.29 // indirect + github.com/clipperhouse/displaywidth v0.6.2 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect @@ -92,6 +96,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fatih/color v1.18.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect @@ -134,7 +139,6 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/minio/crc64nvme v1.1.0 // indirect github.com/minio/md5-simd v1.1.2 // indirect @@ -151,6 +155,9 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/nxadm/tail v1.4.11 // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect github.com/opentracing/basictracer-go v1.1.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pgavlin/fx v0.1.6 // indirect @@ -167,7 +174,6 @@ require ( github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/xid v1.6.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect diff --git a/go.sum b/go.sum index 8fdbbd1..28db7f5 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo= github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30= +github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= +github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= @@ -127,8 +129,8 @@ github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FM github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= @@ -326,6 +328,14 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= +github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= +github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/opentracing/basictracer-go v1.1.0 h1:Oa1fTSBvAl8pa3U+IJYqrKm0NALwH9OsgwOqDv4xJW0= diff --git a/internal/client/types.go b/internal/client/types.go index 2a8df7f..280f51e 100644 --- a/internal/client/types.go +++ b/internal/client/types.go @@ -114,21 +114,22 @@ type AssetsResponse struct { // Asset represents an asset from the API type Asset struct { - ID int64 `json:"id"` - Workspace string `json:"workspace"` - AssetType string `json:"asset_type"` - AssetValue string `json:"asset_value"` - URL string `json:"url,omitempty"` - Title string `json:"title,omitempty"` - StatusCode int `json:"status_code,omitempty"` - HostIP string `json:"host_ip,omitempty"` - TechStack []string `json:"tech_stack,omitempty"` - ContentType string `json:"content_type,omitempty"` - IsCDN bool `json:"is_cdn,omitempty"` - IsCloud bool `json:"is_cloud,omitempty"` - IsWAF bool `json:"is_waf,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Workspace string `json:"workspace"` + AssetType string `json:"asset_type"` + AssetValue string `json:"asset_value"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + StatusCode int `json:"status_code,omitempty"` + ContentLength int `json:"content_length,omitempty"` + HostIP string `json:"host_ip,omitempty"` + TechStack []string `json:"tech_stack,omitempty"` + ContentType string `json:"content_type,omitempty"` + IsCDN bool `json:"is_cdn,omitempty"` + IsCloud bool `json:"is_cloud,omitempty"` + IsWAF bool `json:"is_waf,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // WorkspacesResponse represents the response from listing workspaces diff --git a/internal/core/param.go b/internal/core/param.go index 13d244d..6691540 100644 --- a/internal/core/param.go +++ b/internal/core/param.go @@ -1,6 +1,10 @@ package core -import "fmt" +import ( + "fmt" + "regexp" + "strings" +) // Param represents a workflow parameter type Param struct { @@ -58,3 +62,117 @@ func (p *Param) DefaultBool() bool { return false } } + +// 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 Param) bool { + if p.Type == "bool" { + return true + } + + name := strings.ToLower(p.Name) + togglePrefixes := []string{"enable", "skip", "disable", "use", "verbose"} + for _, prefix := range togglePrefixes { + if strings.HasPrefix(name, prefix) { + return true + } + if strings.HasPrefix(name, prefix+"_") { + return true + } + } + + if p.Default != nil { + switch p.Default.(type) { + case bool: + return true + } + 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 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 + } + } + + speedSuffixes := []string{"depth", "parallel"} + for _, suffix := range speedSuffixes { + if strings.HasSuffix(name, suffix) { + return true + } + } + + if p.Default != nil { + defaultStr := p.DefaultString() + timePatternRegex := regexp.MustCompile(`^\d+[hms]?$`) + if timePatternRegex.MatchString(defaultStr) { + if len(defaultStr) > 0 { + lastChar := defaultStr[len(defaultStr)-1] + if lastChar == 'h' || lastChar == 'm' || lastChar == 's' { + return true + } + if p.Type == "int" || p.Type == "" { + numericRegex := regexp.MustCompile(`^\d+$`) + if numericRegex.MatchString(defaultStr) { + 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 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 []Param) (toggle, speed, config, general []Param) { + for _, p := range params { + switch { + case IsToggleParam(p): + toggle = append(toggle, p) + case IsSpeedControlParam(p): + speed = append(speed, p) + case IsConfigParam(p): + config = append(config, p) + default: + general = append(general, p) + } + } + return +} diff --git a/internal/core/types.go b/internal/core/types.go index 4912513..43b497f 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -128,20 +128,28 @@ type StepResult struct { LogFile string } +// ModuleResult holds per-module execution result for flow workflows +type ModuleResult struct { + ModuleName string + Status RunStatus + Duration time.Duration +} + // WorkflowResult holds workflow execution result type WorkflowResult struct { - WorkflowName string - WorkflowKind WorkflowKind - RunUUID string - Target string - Status RunStatus - StartTime time.Time - EndTime time.Time - Steps []*StepResult - Artifacts []string - Exports map[string]interface{} - Error error - Message string // Optional message (e.g., for skipped status) + WorkflowName string + WorkflowKind WorkflowKind + RunUUID string + Target string + Status RunStatus + StartTime time.Time + EndTime time.Time + Steps []*StepResult + ModuleResults []*ModuleResult // Per-module results for flow workflows + Artifacts []string + Exports map[string]interface{} + Error error + Message string // Optional message (e.g., for skipped status) } // Event represents a system event for triggers diff --git a/internal/database/models.go b/internal/database/models.go index a5f9130..d1a6448 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -181,15 +181,16 @@ type Schedule struct { Params map[string]interface{} `bun:"params,type:json" json:"params,omitempty"` // Workflow parameters TriggerName string `bun:"trigger_name,notnull" json:"trigger_name"` TriggerType string `bun:"trigger_type,notnull" json:"trigger_type"` - Schedule string `bun:"schedule" json:"schedule,omitempty"` - EventTopic string `bun:"event_topic" json:"event_topic,omitempty"` - WatchPath string `bun:"watch_path" json:"watch_path,omitempty"` - IsEnabled bool `bun:"is_enabled,default:true" json:"is_enabled"` - LastRun *time.Time `bun:"last_run" json:"last_run,omitempty"` - NextRun *time.Time `bun:"next_run" json:"next_run,omitempty"` - RunCount int `bun:"run_count,default:0" json:"run_count"` - CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"` - UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp" json:"updated_at"` + // Cron expression for time-based schedules, or event topic for event-based triggers + Schedule string `bun:"schedule" json:"schedule,omitempty"` + EventTopic string `bun:"event_topic" json:"event_topic,omitempty"` + WatchPath string `bun:"watch_path" json:"watch_path,omitempty"` + IsEnabled bool `bun:"is_enabled,default:true" json:"is_enabled"` + LastRun *time.Time `bun:"last_run" json:"last_run,omitempty"` + NextRun *time.Time `bun:"next_run" json:"next_run,omitempty"` + RunCount int `bun:"run_count,default:0" json:"run_count"` + CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"created_at"` + UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp" json:"updated_at"` } // Event topic constants diff --git a/internal/database/seed.go b/internal/database/seed.go index 84e0347..415ffb1 100644 --- a/internal/database/seed.go +++ b/internal/database/seed.go @@ -2899,7 +2899,7 @@ var tableDisplayColumns = map[string][]string{ "runs": {"run_uuid", "workflow_name", "target", "workspace", "trigger_type", "status", "completed_steps", "total_steps", "started_at"}, "step_results": {"step_name", "step_type", "status", "duration_ms", "command"}, "artifacts": {"name", "artifact_path", "artifact_type", "content_type", "size_bytes", "line_count"}, - "assets": {"asset_value", "host_ip", "title", "status_code", "last_seen_at", "url"}, + "assets": {"asset_value", "status_code", "title", "tech", "host_ip", "source", "asset_type", "url"}, "event_logs": {"topic", "name", "source", "workspace", "created_at"}, "schedules": {"name", "workflow_name", "workflow_kind", "target", "trigger_type", "schedule", "is_enabled"}, "workspaces": {"name", "data_source", "total_assets", "total_ips", "total_vulns", "risk_score", "last_run"}, @@ -2921,7 +2921,7 @@ var tableAllColumns = map[string][]string{ "content_type", "size_bytes", "line_count", "description", "created_at"}, "assets": {"id", "workspace", "asset_value", "url", "input", "scheme", "method", "path", "status_code", "content_type", "content_length", "title", "words", - "lines", "host_ip", "dns_records", "tls", "asset_type", "technologies", + "lines", "host_ip", "dns_records", "tls", "asset_type", "tech", "response_time", "remarks", "language", "size", "loc", "blob_content", "source", "raw_json_data", "raw_response", "screenshot_base64_data", "external_url", "last_seen_at", "created_at", "updated_at"}, "event_logs": {"id", "topic", "event_id", "name", "source_type", "source", "data_type", "data", @@ -3499,6 +3499,7 @@ type CreateScheduleInput struct { EventTopic string // Event topic (for event trigger) WatchPath string // Watch path (for watch trigger) Enabled bool + NextRun *time.Time // Initial next run time (computed from cron expression) } // UpdateScheduleInput holds input for updating a schedule @@ -3591,6 +3592,7 @@ func CreateSchedule(ctx context.Context, input CreateScheduleInput) (*Schedule, EventTopic: input.EventTopic, WatchPath: input.WatchPath, IsEnabled: input.Enabled, + NextRun: input.NextRun, CreatedAt: time.Now(), UpdatedAt: time.Now(), } diff --git a/internal/database/stats.go b/internal/database/stats.go index 7471618..5be66bd 100644 --- a/internal/database/stats.go +++ b/internal/database/stats.go @@ -215,7 +215,7 @@ func GetAssetStats(ctx context.Context, workspace string) (*AssetStatsData, erro var assets []Asset query := db.NewSelect(). Model(&assets). - Column("tech", "source", "remarks", "asset_type") + Column("technologies", "source", "remarks", "asset_type") if workspace != "" { query = query.Where("workspace = ?", workspace) diff --git a/internal/executor/dispatcher.go b/internal/executor/dispatcher.go index a30fdff..bd7c3c8 100644 --- a/internal/executor/dispatcher.go +++ b/internal/executor/dispatcher.go @@ -11,6 +11,7 @@ import ( "github.com/j3ssie/osmedeus/v5/internal/logger" "github.com/j3ssie/osmedeus/v5/internal/runner" "github.com/j3ssie/osmedeus/v5/internal/template" + "github.com/j3ssie/osmedeus/v5/internal/terminal" "go.uber.org/zap" ) @@ -26,6 +27,7 @@ type StepDispatcher struct { dryRun bool runner runner.Runner enableBatch bool // Enable batch template rendering + printer *terminal.Printer // Keep direct references to executors that need special configuration bashExecutor *BashExecutor llmExecutor *LLMExecutor @@ -43,6 +45,11 @@ func (d *StepDispatcher) SetSilent(silent bool) { d.agentExecutor.SetSilent(silent) } +// SetPrinter sets the terminal printer for user-facing messages +func (d *StepDispatcher) SetPrinter(p *terminal.Printer) { + d.printer = p +} + // SetRunner sets the runner for command execution func (d *StepDispatcher) SetRunner(r runner.Runner) { d.runner = r @@ -153,7 +160,10 @@ func (d *StepDispatcher) Dispatch(ctx context.Context, step *core.Step, execCtx // Log step message if provided if renderedStep.Log != "" { - log.Info(renderedStep.Log, + if d.printer != nil { + d.printer.Info("%s", renderedStep.Log) + } + log.Debug(renderedStep.Log, zap.String("step", step.Name), ) } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index edbddec..d27193e 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -77,12 +77,15 @@ type Executor struct { // NewExecutor creates a new workflow executor func NewExecutor() *Executor { + p := terminal.NewPrinter() + sd := NewStepDispatcher() + sd.SetPrinter(p) return &Executor{ templateEngine: template.NewEngine(), functionRegistry: functions.NewRegistry(), - stepDispatcher: NewStepDispatcher(), + stepDispatcher: sd, logger: logger.Get(), - printer: terminal.NewPrinter(), + printer: p, showSpinner: false, // Disabled by default, enabled with --spinner } } @@ -1197,7 +1200,8 @@ func (e *Executor) ExecuteModule(ctx context.Context, module *core.Workflow, par } printDryRunHeader(module.Name, string(core.KindModule), params["target"], tactic, len(module.Steps), execCtx) } else if e.progressBar == nil { - e.printer.WorkflowInfo(module.Name, module.Description, module.Tags, string(module.Runner), len(module.Steps)) + toggle, speed, _, _ := core.CategorizeParams(module.Params) + e.printer.WorkflowInfo(module.Name, module.Description, module.Tags, string(module.Runner), len(module.Steps), len(toggle), len(speed)) } // Show target space folder location @@ -1953,6 +1957,10 @@ func (e *Executor) ExecuteFlow(ctx context.Context, flow *core.Workflow, params if isModuleExcluded(modRef.Name, excludeList) || isFuzzyModuleExcluded(modRef.Name, fuzzyExcludeList) { execCtx.Logger.Info("Skipping excluded module", zap.String("module", modRef.Name)) executed[modRef.Name] = true + result.ModuleResults = append(result.ModuleResults, &core.ModuleResult{ + ModuleName: modRef.Name, + Status: core.RunStatusSkipped, + }) // Unblock dependents even for excluded modules for _, dependent := range dependents[modRef.Name] { inDegree[dependent]-- @@ -1969,6 +1977,10 @@ func (e *Executor) ExecuteFlow(ctx context.Context, flow *core.Workflow, params if err != nil { execCtx.Logger.Warn("Condition evaluation failed", zap.Error(err)) executed[modRef.Name] = true + result.ModuleResults = append(result.ModuleResults, &core.ModuleResult{ + ModuleName: modRef.Name, + Status: core.RunStatusSkipped, + }) // Unblock dependents even for skipped modules for _, dependent := range dependents[modRef.Name] { inDegree[dependent]-- @@ -1980,6 +1992,10 @@ func (e *Executor) ExecuteFlow(ctx context.Context, flow *core.Workflow, params } if !ok { executed[modRef.Name] = true + result.ModuleResults = append(result.ModuleResults, &core.ModuleResult{ + ModuleName: modRef.Name, + Status: core.RunStatusSkipped, + }) // Unblock dependents even for skipped modules for _, dependent := range dependents[modRef.Name] { inDegree[dependent]-- @@ -2071,13 +2087,20 @@ func (e *Executor) ExecuteFlow(ctx context.Context, flow *core.Workflow, params } // Execute the module + moduleStart := time.Now() moduleResult, err := e.ExecuteModule(ctx, module, mergedParams, cfg) + moduleDuration := time.Since(moduleStart) if err != nil { // Check for context cancellation FIRST (interrupt/timeout) if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { if e.progressBar != nil { e.progressBar.Abort() } + result.ModuleResults = append(result.ModuleResults, &core.ModuleResult{ + ModuleName: modRef.Name, + Status: core.RunStatusCancelled, + Duration: moduleDuration, + }) result.Status = core.RunStatusCancelled result.EndTime = time.Now() execCtx.Logger.Warn("Flow execution cancelled", zap.Error(err)) @@ -2093,6 +2116,11 @@ func (e *Executor) ExecuteFlow(ctx context.Context, flow *core.Workflow, params for _, action := range modRef.OnError { e.handleModuleAction(action, execCtx) if action.Action == "abort" { + result.ModuleResults = append(result.ModuleResults, &core.ModuleResult{ + ModuleName: modRef.Name, + Status: core.RunStatusFailed, + Duration: moduleDuration, + }) result.Status = core.RunStatusFailed result.Error = fmt.Errorf("module %s failed: %w", modRef.Name, err) result.EndTime = time.Now() @@ -2102,6 +2130,11 @@ func (e *Executor) ExecuteFlow(ctx context.Context, flow *core.Workflow, params } // If no abort action, mark as executed and continue executed[modRef.Name] = true + result.ModuleResults = append(result.ModuleResults, &core.ModuleResult{ + ModuleName: modRef.Name, + Status: core.RunStatusFailed, + Duration: moduleDuration, + }) // Unblock dependents even for failed modules (if not aborted) for _, dependent := range dependents[modRef.Name] { inDegree[dependent]-- @@ -2126,6 +2159,17 @@ func (e *Executor) ExecuteFlow(ctx context.Context, flow *core.Workflow, params } } + // Record module result + modStatus := core.RunStatusCompleted + if moduleResult != nil { + modStatus = moduleResult.Status + } + result.ModuleResults = append(result.ModuleResults, &core.ModuleResult{ + ModuleName: modRef.Name, + Status: modStatus, + Duration: moduleDuration, + }) + executed[modRef.Name] = true // Unblock dependent modules (Kahn's algorithm propagation) diff --git a/internal/terminal/colors.go b/internal/terminal/colors.go index ef6c3f0..f8ecee1 100644 --- a/internal/terminal/colors.go +++ b/internal/terminal/colors.go @@ -205,3 +205,109 @@ func ColorizeStatus(status string) string { return status } } + +// ColorizeTriggerType applies ANSI color codes to trigger_type values for table display +func ColorizeTriggerType(triggerType string) string { + switch strings.ToLower(triggerType) { + case "cli": + return Cyan(triggerType) + case "cron": + return Yellow(triggerType) + case "event": + return Magenta(triggerType) + case "webhook": + return Blue(triggerType) + case "manual": + return Gray(triggerType) + default: + return triggerType + } +} + +// ColorizeEnabled applies ANSI color codes to boolean enabled/disabled values +func ColorizeEnabled(val string) string { + switch strings.ToLower(val) { + case "true", "yes", "1": + return Green(val) + case "false", "no", "0": + return Red(val) + default: + return val + } +} + +// ColorizeWorkflowKind applies ANSI color codes to workflow kind values +func ColorizeWorkflowKind(kind string) string { + switch strings.ToLower(kind) { + case "flow": + return Blue(kind) + case "module": + return Cyan(kind) + default: + return kind + } +} + +// ColorizeSchedule applies ANSI color codes to schedule/cron expressions +func ColorizeSchedule(schedule string) string { + if schedule == "" { + return schedule + } + return Gray(schedule) +} + +// ColorizeStatusCode applies ANSI color codes to HTTP status codes +func ColorizeStatusCode(code string) string { + if code == "" || code == "0" { + return Gray(code) + } + if len(code) >= 1 { + switch code[0] { + case '2': + return Green(code) + case '3': + return Cyan(code) + case '4': + return Yellow(code) + case '5': + return Red(code) + } + } + return code +} + +// ColorizeSource applies ANSI color codes to asset source values +func ColorizeSource(source string) string { + if source == "" { + return Gray(source) + } + switch strings.ToLower(source) { + case "httpx": + return Cyan(source) + case "subfinder", "amass": + return Blue(source) + case "nmap", "rustscan": + return Magenta(source) + default: + return Teal(source) + } +} + +// ColorizeAssetType applies ANSI color codes to asset type values +func ColorizeAssetType(assetType string) string { + if assetType == "" { + return Gray(assetType) + } + switch strings.ToLower(assetType) { + case "web": + return Green(assetType) + case "subdomain": + return Blue(assetType) + case "ip": + return Magenta(assetType) + case "cidr": + return Yellow(assetType) + default: + return Cyan(assetType) + } +} diff --git a/internal/terminal/db_tui.go b/internal/terminal/db_tui.go index 380356f..c12b726 100644 --- a/internal/terminal/db_tui.go +++ b/internal/terminal/db_tui.go @@ -691,7 +691,13 @@ func formatCellValue(v interface{}, maxLen int) string { switch val := v.(type) { case string: s = val - case []interface{}, map[string]interface{}: + case []interface{}: + 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: diff --git a/internal/terminal/printer.go b/internal/terminal/printer.go index 848e3c7..344ea08 100644 --- a/internal/terminal/printer.go +++ b/internal/terminal/printer.go @@ -221,15 +221,17 @@ func (p *Printer) StepSkippedWithCommand(stepName, typeSymbol string) { } // WorkflowInfo prints workflow metadata (for normal mode) -func (p *Printer) WorkflowInfo(name, description string, tags []string, runnerType string, totalSteps int) { +func (p *Printer) WorkflowInfo(name, description string, tags []string, runnerType string, totalSteps int, toggleCount int, speedCount int) { if IsCIMode() { printJSONL(map[string]interface{}{ - "type": "workflow_start", - "workflow": name, - "description": description, - "tags": tags, - "runner": runnerType, - "total_steps": totalSteps, + "type": "workflow_start", + "workflow": name, + "description": description, + "tags": tags, + "runner": runnerType, + "total_steps": totalSteps, + "speed_params": speedCount, + "toggle_params": toggleCount, }) return } @@ -243,7 +245,20 @@ func (p *Printer) WorkflowInfo(name, description string, tags []string, runnerTy if runnerType != "" && runnerType != "host" { _, _ = fmt.Fprintf(os.Stdout, " %s %s\n", Gray("Runner:"), runnerType) } - _, _ = fmt.Fprintf(os.Stdout, " %s %d\n\n", Gray("Total Steps:"), totalSteps) + _, _ = fmt.Fprintf(os.Stdout, " %s %d\n", Gray("Total Steps:"), totalSteps) + + if speedCount > 0 || toggleCount > 0 { + var parts []string + if speedCount > 0 { + parts = append(parts, fmt.Sprintf("%d speed control", speedCount)) + } + if toggleCount > 0 { + parts = append(parts, fmt.Sprintf("%d toggle", toggleCount)) + } + _, _ = fmt.Fprintf(os.Stdout, " %s %s\n", Gray("Params:"), strings.Join(parts, ", ")) + } + + _, _ = fmt.Fprintf(os.Stdout, " %s %s\n\n", Gray("Tip:"), Cyan("osmedeus workflow view "+name)) } // Section prints a section header with symbol diff --git a/internal/terminal/symbols.go b/internal/terminal/symbols.go index d2eb730..0a665e2 100644 --- a/internal/terminal/symbols.go +++ b/internal/terminal/symbols.go @@ -49,7 +49,7 @@ func StepSymbol(status string) string { return Gray(SymbolPending) case "running": return Cyan(SymbolRunning) - case "success": + case "success", "completed": return Green(SymbolSuccess) case "failed": return Red(SymbolFailed) diff --git a/pkg/cli/assets.go b/pkg/cli/assets.go new file mode 100644 index 0000000..4717d21 --- /dev/null +++ b/pkg/cli/assets.go @@ -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() +} diff --git a/pkg/cli/client.go b/pkg/cli/client.go index 9f95929..5f4fbcc 100644 --- a/pkg/cli/client.go +++ b/pkg/cli/client.go @@ -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"}, diff --git a/pkg/cli/db.go b/pkg/cli/db.go index afae300..379ed77 100644 --- a/pkg/cli/db.go +++ b/pkg/cli/db.go @@ -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 diff --git a/pkg/cli/root.go b/pkg/cli/root.go index ebdda97..669d667 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -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. diff --git a/pkg/cli/run.go b/pkg/cli/run.go index 3873d19..7abe88d 100644 --- a/pkg/cli/run.go +++ b/pkg/cli/run.go @@ -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 diff --git a/pkg/cli/usage.go b/pkg/cli/usage.go index 076c99b..24c414d 100644 --- a/pkg/cli/usage.go +++ b/pkg/cli/usage.go @@ -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" diff --git a/pkg/cli/worker.go b/pkg/cli/worker.go index 1debbf9..25bfcc6 100644 --- a/pkg/cli/worker.go +++ b/pkg/cli/worker.go @@ -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 }, } diff --git a/pkg/cli/worker_queue.go b/pkg/cli/worker_queue.go index 61a5ee7..e0d4cb1 100644 --- a/pkg/cli/worker_queue.go +++ b/pkg/cli/worker_queue.go @@ -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 diff --git a/pkg/cli/workflow.go b/pkg/cli/workflow.go index 4148b5e..f964954 100644 --- a/pkg/cli/workflow.go +++ b/pkg/cli/workflow.go @@ -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() diff --git a/test/e2e/function_test.go b/test/e2e/function_test.go index ec6eca4..b146073 100644 --- a/test/e2e/function_test.go +++ b/test/e2e/function_test.go @@ -15,8 +15,8 @@ func TestFunction_List(t *testing.T) { require.NoError(t, err) log.Info("Asserting stdout contains function categories") - assert.Contains(t, stdout, "| File") - assert.Contains(t, stdout, "| String") + assert.Contains(t, stdout, "File") + assert.Contains(t, stdout, "String") log.Success("function list displays all function categories") }