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