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:
j3ssie
2026-02-17 15:19:49 +07:00
parent d01bd326df
commit 8d413aecb9
31 changed files with 1438 additions and 425 deletions
+8
View File
@@ -222,6 +222,14 @@ osmedeus worker set <id> <field> <value> # Update worker metadata
osmedeus worker queue list # List queued tasks
osmedeus worker queue new -f <flow> -t <target> # Queue task for delayed execution
osmedeus worker queue run --concurrency 5 # Process queued tasks
osmedeus assets # List discovered assets
osmedeus assets -w <workspace> # 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 <workspace> # 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
+27
View File
@@ -2086,6 +2086,33 @@ osmedeus worker queue new -m <module> -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
+5
View File
@@ -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"
+73
View File
@@ -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"
+73
View File
@@ -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"
+48
View File
@@ -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
+45
View File
@@ -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.
+8 -2
View File
@@ -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
+12 -2
View File
@@ -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=
+16 -15
View File
@@ -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
+119 -1
View File
@@ -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
}
+20 -12
View File
@@ -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
+10 -9
View File
@@ -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
+4 -2
View File
@@ -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(),
}
+1 -1
View File
@@ -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)
+11 -1
View File
@@ -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),
)
}
+47 -3
View File
@@ -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)
+106
View File
@@ -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)
}
}
+7 -1
View File
@@ -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:
+23 -8
View File
@@ -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
+1 -1
View File
@@ -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)
+193
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+70
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+2 -2
View File
@@ -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")
}