mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-22 15:42:27 +02:00
- Add query subcommands (vulns, runs, steps) with JSON output and flexible filtering - Enhance assets command with --where, --search, --value filters (fuzzy matching) - Expand asset searchable columns (status_code, content_type, title, dns_records, tls, tech) - Add run status and run cancel subcommands with process termination - Support control-plane and PID-based cancellation with database updates - Implement fuzzyFilters in GetTableRecords for case-insensitive substring matching
1045 lines
30 KiB
Go
1045 lines
30 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"golang.org/x/term"
|
|
|
|
"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"
|
|
)
|
|
|
|
var (
|
|
dbTable string
|
|
dbOffset int
|
|
dbLimit int
|
|
dbNoTUI bool
|
|
dbWhere []string
|
|
dbColumns string
|
|
dbSearch string
|
|
dbAll bool
|
|
dbIndexForce bool
|
|
dbListColumns bool
|
|
dbExcludeColumns string
|
|
dbRefresh string
|
|
dbClear bool
|
|
dbListTables bool
|
|
dbIncludeHeavy bool
|
|
dbCleanWS bool
|
|
)
|
|
|
|
// defaultHiddenColumns are columns hidden by default for all tables
|
|
var defaultHiddenColumns = []string{"id", "created_at", "updated_at", "completed_at"}
|
|
|
|
// tableDefaultColumns defines default columns for specific tables
|
|
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", "status_code", "title", "tech", "host_ip", "source", "asset_type"},
|
|
"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,
|
|
},
|
|
}
|
|
|
|
// tableColumnWeights defines per-column weights for surplus width distribution (default weight = 1)
|
|
var tableColumnWeights = map[string]map[string]int{
|
|
"assets": {
|
|
"asset_value": 8,
|
|
"title": 3,
|
|
"tech": 3,
|
|
"host_ip": 2,
|
|
},
|
|
}
|
|
|
|
// tableColumnDisplayNames maps internal column names to shorter display names
|
|
var tableColumnDisplayNames = map[string]map[string]string{
|
|
"assets": {
|
|
"status_code": "status",
|
|
},
|
|
}
|
|
|
|
// dbCmd - parent command for database management
|
|
var dbCmd = &cobra.Command{
|
|
Use: "db",
|
|
Short: "Database management commands",
|
|
Long: UsageDB(),
|
|
RunE: runDBList,
|
|
}
|
|
|
|
// dbSeedCmd - seed database with sample data
|
|
var dbSeedCmd = &cobra.Command{
|
|
Use: "seed",
|
|
Short: "Seed database with sample data",
|
|
Long: UsageDBSeed(),
|
|
RunE: runDBSeed,
|
|
}
|
|
|
|
// dbCleanCmd - clean all data from database
|
|
var dbCleanCmd = &cobra.Command{
|
|
Use: "clean",
|
|
Short: "Remove all data from database",
|
|
Long: UsageDBClean(),
|
|
RunE: runDBClean,
|
|
}
|
|
|
|
// dbMigrateCmd - run database migrations
|
|
var dbMigrateCmd = &cobra.Command{
|
|
Use: "migrate",
|
|
Short: "Run database migrations",
|
|
Long: UsageDBMigrate(),
|
|
RunE: runDBMigrate,
|
|
}
|
|
|
|
// dbListCmd - list database tables
|
|
var dbListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Aliases: []string{"ls"},
|
|
Short: "List database tables and row counts",
|
|
Long: UsageDBList(),
|
|
RunE: runDBList,
|
|
}
|
|
|
|
// dbIndexCmd - parent command for indexing resources
|
|
var dbIndexCmd = &cobra.Command{
|
|
Use: "index",
|
|
Short: "Index resources from filesystem to database",
|
|
Long: `Index resources from the filesystem into the database for faster querying.`,
|
|
}
|
|
|
|
// dbIndexWorkflowCmd - index workflows from filesystem
|
|
var dbIndexWorkflowCmd = &cobra.Command{
|
|
Use: "workflow",
|
|
Short: "Index workflows from filesystem to database",
|
|
Long: `Scan the workflows directory and index all workflows into the database.
|
|
This enables faster workflow listing and filtering by tags, kind, etc.
|
|
|
|
The command will:
|
|
- Add new workflows found on disk
|
|
- Update workflows that have changed (checksum mismatch)
|
|
- Remove workflows from DB that no longer exist on disk
|
|
|
|
Use --force to re-index all workflows regardless of checksum.`,
|
|
RunE: runIndexWorkflows,
|
|
}
|
|
|
|
func init() {
|
|
// Note: --force flag is now global (defined in root.go)
|
|
|
|
// Use persistent flags on dbCmd so they work on both `db` and `db ls`
|
|
dbCmd.PersistentFlags().StringVarP(&dbTable, "table", "t", "", "table name to list records from (runs, step_results, artifacts, assets, event_logs, schedules, workspaces)")
|
|
dbCmd.PersistentFlags().IntVar(&dbOffset, "offset", 0, "number of records to skip (for pagination)")
|
|
dbCmd.PersistentFlags().IntVar(&dbLimit, "limit", 50, "maximum number of records to return")
|
|
// Note: --json and --width flags are now global (defined in root.go)
|
|
dbCmd.PersistentFlags().BoolVar(&dbNoTUI, "no-tui", false, "disable interactive TUI mode, use plain text output")
|
|
dbCmd.PersistentFlags().StringArrayVar(&dbWhere, "where", nil, "filter records (key=value format, can be repeated) - only with --no-tui")
|
|
dbCmd.PersistentFlags().StringVar(&dbColumns, "columns", "", "comma-separated columns to display (default: all) - only with --no-tui")
|
|
dbCmd.PersistentFlags().StringVar(&dbSearch, "search", "", "search all columns for substring (case-insensitive) - only with --no-tui")
|
|
dbCmd.PersistentFlags().BoolVar(&dbAll, "all", false, "show all columns including hidden ones (id, timestamps) - only with --no-tui")
|
|
dbCmd.PersistentFlags().BoolVar(&dbListColumns, "list-columns", false, "list all available columns for the specified table")
|
|
dbCmd.PersistentFlags().StringVar(&dbExcludeColumns, "exclude-columns", "", "comma-separated column names to exclude from output")
|
|
dbCmd.PersistentFlags().StringVar(&dbRefresh, "refresh", "", "auto-refresh interval (e.g., 5s, 1m, 30s)")
|
|
dbCmd.PersistentFlags().BoolVar(&dbListTables, "list", false, "list all available table names")
|
|
dbCmd.PersistentFlags().BoolVar(&dbClear, "clear", false, "clear all records from the specified table (requires --table and --force)")
|
|
dbCmd.PersistentFlags().BoolVar(&dbIncludeHeavy, "include-heavy", false, "include large fields (raw_response, screenshot, blob_content) in output")
|
|
|
|
dbCleanCmd.Flags().BoolVar(&dbCleanWS, "clean-ws", false, "also remove workspace data directory (e.g. ~/workspaces-osmedeus)")
|
|
|
|
dbIndexWorkflowCmd.Flags().BoolVar(&dbIndexForce, "force", false, "force re-index all workflows regardless of checksum")
|
|
|
|
dbIndexCmd.AddCommand(dbIndexWorkflowCmd)
|
|
|
|
dbCmd.AddCommand(dbSeedCmd)
|
|
dbCmd.AddCommand(dbCleanCmd)
|
|
dbCmd.AddCommand(dbMigrateCmd)
|
|
dbCmd.AddCommand(dbListCmd)
|
|
dbCmd.AddCommand(dbIndexCmd)
|
|
}
|
|
|
|
// runDBSeed seeds the database with sample data
|
|
func runDBSeed(cmd *cobra.Command, args []string) error {
|
|
if disableDB {
|
|
return fmt.Errorf("database commands unavailable: --disable-db flag is set")
|
|
}
|
|
|
|
printer := terminal.NewPrinter()
|
|
cfg := config.Get()
|
|
|
|
if cfg == nil {
|
|
return fmt.Errorf("configuration not loaded")
|
|
}
|
|
|
|
printer.Info("Connecting to database...")
|
|
|
|
// Connect to database
|
|
db, err := database.Connect(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to database: %w", err)
|
|
}
|
|
defer func() { _ = database.Close() }()
|
|
|
|
// Run migrations first to ensure tables exist
|
|
ctx := context.Background()
|
|
if err := database.Migrate(ctx); err != nil {
|
|
return fmt.Errorf("failed to run migrations: %w", err)
|
|
}
|
|
|
|
printer.Info("Seeding database with sample data...")
|
|
|
|
// Seed the database
|
|
if err := database.SeedDatabase(ctx); err != nil {
|
|
return fmt.Errorf("failed to seed database: %w", err)
|
|
}
|
|
|
|
printer.Success("Database seeded successfully")
|
|
printer.Info("Database: %s", getDatabaseInfo(cfg, db))
|
|
|
|
return nil
|
|
}
|
|
|
|
// runDBClean removes all data from the database
|
|
func runDBClean(cmd *cobra.Command, args []string) error {
|
|
if disableDB {
|
|
return fmt.Errorf("database commands unavailable: --disable-db flag is set")
|
|
}
|
|
|
|
printer := terminal.NewPrinter()
|
|
cfg := config.Get()
|
|
|
|
if cfg == nil {
|
|
return fmt.Errorf("configuration not loaded")
|
|
}
|
|
|
|
if !globalForce {
|
|
printer.Warning("This will delete ALL data from the database!")
|
|
printer.Warning("Use --force to skip this confirmation")
|
|
return fmt.Errorf("operation aborted: use --force to confirm")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
if cfg.IsSQLite() {
|
|
// SQLite: Delete the file and recreate
|
|
dbPath := cfg.GetDBPath()
|
|
|
|
// Close existing connection if any
|
|
_ = database.Close()
|
|
|
|
// Delete the database file
|
|
if err := os.Remove(dbPath); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to delete database file: %w", err)
|
|
}
|
|
printer.Info("Deleted database file: %s", dbPath)
|
|
|
|
// Reconnect and migrate
|
|
_, err := database.Connect(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to reconnect to database: %w", err)
|
|
}
|
|
defer func() { _ = database.Close() }()
|
|
|
|
if err := database.Migrate(ctx); err != nil {
|
|
return fmt.Errorf("failed to run migrations: %w", err)
|
|
}
|
|
printer.Success("Database recreated with fresh schema")
|
|
} else {
|
|
// PostgreSQL: Clean tables and run migrate
|
|
printer.Info("Connecting to database...")
|
|
|
|
db, err := database.Connect(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to database: %w", err)
|
|
}
|
|
defer func() { _ = database.Close() }()
|
|
|
|
printer.Info("Cleaning database...")
|
|
if err := database.CleanDatabase(ctx); err != nil {
|
|
return fmt.Errorf("failed to clean database: %w", err)
|
|
}
|
|
|
|
printer.Info("Running migrations...")
|
|
if err := database.Migrate(ctx); err != nil {
|
|
return fmt.Errorf("failed to run migrations: %w", err)
|
|
}
|
|
|
|
printer.Success("Database cleaned and schema updated")
|
|
printer.Info("Database: %s", getDatabaseInfo(cfg, db))
|
|
}
|
|
|
|
// Clean workspace data directory if --clean-ws is set
|
|
if dbCleanWS {
|
|
wsPath := cfg.GetWorkspacesDir()
|
|
if wsPath == "" {
|
|
printer.Warning("Workspaces path not configured, skipping workspace cleanup")
|
|
} else {
|
|
printer.Info("Removing workspace data: %s", wsPath)
|
|
if err := os.RemoveAll(wsPath); err != nil {
|
|
return fmt.Errorf("failed to remove workspaces directory: %w", err)
|
|
}
|
|
// Recreate the empty directory
|
|
if err := os.MkdirAll(wsPath, 0755); err != nil {
|
|
return fmt.Errorf("failed to recreate workspaces directory: %w", err)
|
|
}
|
|
printer.Success("Workspace data cleaned: %s", wsPath)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// runDBMigrate runs database migrations
|
|
func runDBMigrate(cmd *cobra.Command, args []string) error {
|
|
if disableDB {
|
|
return fmt.Errorf("database commands unavailable: --disable-db flag is set")
|
|
}
|
|
|
|
printer := terminal.NewPrinter()
|
|
cfg := config.Get()
|
|
|
|
if cfg == nil {
|
|
return fmt.Errorf("configuration not loaded")
|
|
}
|
|
|
|
printer.Info("Connecting to database...")
|
|
|
|
// Connect to database
|
|
db, err := database.Connect(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to database: %w", err)
|
|
}
|
|
defer func() { _ = database.Close() }()
|
|
|
|
ctx := context.Background()
|
|
|
|
printer.Info("Running migrations...")
|
|
|
|
// Run migrations
|
|
if err := database.Migrate(ctx); err != nil {
|
|
return fmt.Errorf("failed to run migrations: %w", err)
|
|
}
|
|
|
|
printer.Success("Database migrations completed")
|
|
printer.Info("Database: %s", getDatabaseInfo(cfg, db))
|
|
|
|
return nil
|
|
}
|
|
|
|
// runDBList lists all database tables with row counts or records from a specific table
|
|
func runDBList(cmd *cobra.Command, args []string) error {
|
|
if disableDB {
|
|
return fmt.Errorf("database commands unavailable: --disable-db flag is set")
|
|
}
|
|
|
|
printer := terminal.NewPrinter()
|
|
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()
|
|
|
|
// Ensure tables exist
|
|
if err := database.Migrate(ctx); err != nil {
|
|
return fmt.Errorf("failed to run migrations: %w", err)
|
|
}
|
|
|
|
// Handle --list flag (list table names only)
|
|
if dbListTables {
|
|
tables := database.ValidTableNames()
|
|
for _, t := range tables {
|
|
fmt.Println(t)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Handle --clear flag
|
|
if dbClear {
|
|
if dbTable == "" {
|
|
return fmt.Errorf("--clear requires --table/-t flag")
|
|
}
|
|
if !globalForce {
|
|
printer.Warning("This will delete ALL records from table '%s'!", dbTable)
|
|
printer.Warning("Use --force to confirm")
|
|
return fmt.Errorf("operation aborted: use --force to confirm")
|
|
}
|
|
if err := database.ClearTable(ctx, dbTable); err != nil {
|
|
return fmt.Errorf("failed to clear table: %w", err)
|
|
}
|
|
printer.Success("Cleared all records from table '%s'", dbTable)
|
|
return nil
|
|
}
|
|
|
|
// Handle --list-columns flag
|
|
if dbListColumns {
|
|
if dbTable == "" {
|
|
return fmt.Errorf("--list-columns requires --table/-t flag")
|
|
}
|
|
columns := database.GetAllTableColumns(dbTable)
|
|
if len(columns) == 0 {
|
|
return fmt.Errorf("unknown table or no columns found: %s", dbTable)
|
|
}
|
|
printer.Info("Available columns for table '%s':", dbTable)
|
|
for _, col := range columns {
|
|
fmt.Printf(" %s\n", col)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// JSON mode bypasses TUI entirely
|
|
if globalJSON {
|
|
if dbTable != "" {
|
|
return listTableRecordsJSON(ctx)
|
|
}
|
|
return listAllTablesJSON(ctx)
|
|
}
|
|
|
|
// No-TUI mode or specific table with flags: use plain text output
|
|
if dbNoTUI || dbTable != "" {
|
|
if dbTable != "" {
|
|
return listTableRecords(ctx, cfg, printer)
|
|
}
|
|
return listAllTables(ctx, cfg, printer)
|
|
}
|
|
|
|
// Default: use interactive TUI
|
|
return runDBListTUI(ctx)
|
|
}
|
|
|
|
// runDBListTUI starts the interactive database TUI
|
|
func runDBListTUI(ctx context.Context) error {
|
|
// Get all tables
|
|
tables, err := database.ListTables(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list tables: %w", err)
|
|
}
|
|
|
|
// Convert to terminal.TableInfo
|
|
tuiTables := make([]terminal.TableInfo, len(tables))
|
|
for i, t := range tables {
|
|
tuiTables[i] = terminal.TableInfo{
|
|
Name: t.Name,
|
|
RowCount: t.RowCount,
|
|
}
|
|
}
|
|
|
|
// Record fetcher wraps database.GetTableRecords
|
|
recordFetcher := func(ctx context.Context, tableName string, offset, limit int, filters map[string]string, search string) (*terminal.TableRecords, error) {
|
|
excludeCols := getDefaultExcludeColumns(tableName)
|
|
result, err := database.GetTableRecords(ctx, tableName, offset, limit, filters, nil, search, excludeCols)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &terminal.TableRecords{
|
|
Table: result.Table,
|
|
TotalCount: result.TotalCount,
|
|
Offset: result.Offset,
|
|
Limit: result.Limit,
|
|
Records: result.Records,
|
|
}, nil
|
|
}
|
|
|
|
// Column fetcher wraps database.GetTableColumns (for display)
|
|
columnFetcher := func(tableName string) []string {
|
|
return database.GetTableColumns(tableName)
|
|
}
|
|
|
|
// All columns fetcher wraps database.GetAllTableColumns (for column selection)
|
|
allColumnsFetcher := func(tableName string) []string {
|
|
return database.GetAllTableColumns(tableName)
|
|
}
|
|
|
|
// Create and run TUI (pass dbLimit for page size)
|
|
tui := terminal.NewDBTUI(tuiTables, recordFetcher, columnFetcher, allColumnsFetcher, dbLimit)
|
|
return tui.Run()
|
|
}
|
|
|
|
// listAllTablesJSON outputs all tables as JSON
|
|
func listAllTablesJSON(ctx context.Context) error {
|
|
tables, err := database.ListTables(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list tables: %w", err)
|
|
}
|
|
jsonBytes, err := json.Marshal(tables)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to format tables: %w", err)
|
|
}
|
|
fmt.Println(string(jsonBytes))
|
|
return nil
|
|
}
|
|
|
|
// listTableRecordsJSON outputs table records as JSON only
|
|
func listTableRecordsJSON(ctx context.Context) error {
|
|
// Validate limit
|
|
if dbLimit <= 0 {
|
|
dbLimit = 50
|
|
}
|
|
if dbLimit > 10000 {
|
|
dbLimit = 10000
|
|
}
|
|
if dbOffset < 0 {
|
|
dbOffset = 0
|
|
}
|
|
|
|
filters := parseWhereFilters(dbWhere)
|
|
excludeCols := getDefaultExcludeColumns(dbTable)
|
|
records, err := database.GetTableRecords(ctx, dbTable, dbOffset, dbLimit, filters, nil, dbSearch, excludeCols)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get records: %w", err)
|
|
}
|
|
|
|
jsonBytes, err := json.Marshal(records.Records)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to format records: %w", err)
|
|
}
|
|
fmt.Println(string(jsonBytes))
|
|
return nil
|
|
}
|
|
|
|
// listAllTables lists all database tables with their row counts
|
|
func listAllTables(ctx context.Context, cfg *config.Config, printer *terminal.Printer) error {
|
|
tables, err := database.ListTables(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list tables: %w", err)
|
|
}
|
|
|
|
printer.Info("Database: %s", getDatabaseInfo(cfg, nil))
|
|
fmt.Println()
|
|
fmt.Printf("%-20s %s\n", "Table", "Rows")
|
|
fmt.Println("─────────────────────────────")
|
|
for _, t := range tables {
|
|
fmt.Printf("%-20s %d\n", t.Name, t.RowCount)
|
|
}
|
|
fmt.Println()
|
|
printer.Info("Use --table <name> to list records from a specific table")
|
|
|
|
return nil
|
|
}
|
|
|
|
// runDBRefreshLoop continuously refreshes the table display at the specified interval
|
|
func runDBRefreshLoop(ctx context.Context, cfg *config.Config, printer *terminal.Printer, interval time.Duration) error {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
defer signal.Stop(sigChan)
|
|
|
|
for {
|
|
fmt.Print("\033[2J\033[H") // Clear screen
|
|
if err := listTableRecordsOnce(ctx, cfg, printer); err != nil {
|
|
printer.Error("Query failed: %s", err)
|
|
}
|
|
fmt.Printf("\n%s Refreshing every %s. Press Ctrl+C to stop.\n", terminal.Gray("⟳"), interval)
|
|
|
|
select {
|
|
case <-ticker.C:
|
|
continue
|
|
case <-sigChan:
|
|
fmt.Print("\033[2J\033[H")
|
|
printer.Info("Refresh stopped")
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
// listTableRecords lists records from a specific table with pagination
|
|
func listTableRecords(ctx context.Context, cfg *config.Config, printer *terminal.Printer) error {
|
|
// Check if refresh mode is enabled
|
|
if dbRefresh != "" {
|
|
interval, err := time.ParseDuration(dbRefresh)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid refresh interval: %w", err)
|
|
}
|
|
if interval < time.Second {
|
|
return fmt.Errorf("refresh interval must be at least 1s")
|
|
}
|
|
return runDBRefreshLoop(ctx, cfg, printer, interval)
|
|
}
|
|
return listTableRecordsOnce(ctx, cfg, printer)
|
|
}
|
|
|
|
// listTableRecordsOnce performs a single query and displays the results
|
|
func listTableRecordsOnce(ctx context.Context, cfg *config.Config, printer *terminal.Printer) error {
|
|
// Validate limit
|
|
if dbLimit <= 0 {
|
|
dbLimit = 50
|
|
}
|
|
if dbLimit > 10000 {
|
|
dbLimit = 10000
|
|
}
|
|
|
|
// Validate offset
|
|
if dbOffset < 0 {
|
|
dbOffset = 0
|
|
}
|
|
|
|
// Parse filters, columns, and exclude columns
|
|
filters := parseWhereFilters(dbWhere)
|
|
requestedColumns := parseColumns(dbColumns)
|
|
columns := getEffectiveColumns(dbTable, requestedColumns, dbAll)
|
|
excludeColumns := parseExcludeColumns(dbExcludeColumns)
|
|
|
|
// Determine if we should hide default columns (when no specific columns requested and not --all)
|
|
hideDefaultColumns := !dbAll && len(requestedColumns) == 0 && tableDefaultColumns[dbTable] == nil
|
|
|
|
sqlExcludeCols := getDefaultExcludeColumns(dbTable)
|
|
records, err := database.GetTableRecords(ctx, dbTable, dbOffset, dbLimit, filters, nil, dbSearch, sqlExcludeCols)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get records: %w", err)
|
|
}
|
|
|
|
// JSON-only output mode
|
|
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
|
|
}
|
|
|
|
// Calculate pagination info
|
|
startRecord := records.Offset + 1
|
|
endRecord := records.Offset + dbLimit
|
|
if endRecord > records.TotalCount {
|
|
endRecord = records.TotalCount
|
|
}
|
|
if records.TotalCount == 0 {
|
|
startRecord = 0
|
|
}
|
|
|
|
statsLine := fmt.Sprintf("%s Table: %s | Showing %s-%s of %s",
|
|
terminal.InfoSymbol(),
|
|
records.Table,
|
|
terminal.HiCyan(fmt.Sprintf("%d", startRecord)),
|
|
terminal.HiCyan(fmt.Sprintf("%d", endRecord)),
|
|
terminal.HiCyan(fmt.Sprintf("%d", records.TotalCount)),
|
|
)
|
|
if records.TotalCount > endRecord {
|
|
nextOffset := records.Offset + records.Limit
|
|
statsLine += fmt.Sprintf(" | Next: osmedeus db list -t %s --offset %s --limit %s",
|
|
dbTable,
|
|
terminal.HiCyan(fmt.Sprintf("%d", nextOffset)),
|
|
terminal.HiCyan(fmt.Sprintf("%d", dbLimit)),
|
|
)
|
|
}
|
|
fmt.Println(statsLine)
|
|
fmt.Println()
|
|
|
|
// Render table using tablewriter
|
|
renderTableWithTablewriter(dbTable, records.Records, columns, globalWidth, hideDefaultColumns, excludeColumns)
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseWhereFilters parses --where flags into a map
|
|
func parseWhereFilters(whereFlags []string) map[string]string {
|
|
filters := make(map[string]string)
|
|
for _, w := range whereFlags {
|
|
parts := strings.SplitN(w, "=", 2)
|
|
if len(parts) == 2 {
|
|
filters[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
|
}
|
|
}
|
|
return filters
|
|
}
|
|
|
|
// parseColumns parses --columns flag into a slice
|
|
func parseColumns(columnsFlag string) []string {
|
|
if columnsFlag == "" {
|
|
return nil
|
|
}
|
|
cols := strings.Split(columnsFlag, ",")
|
|
for i := range cols {
|
|
cols[i] = strings.TrimSpace(cols[i])
|
|
}
|
|
return cols
|
|
}
|
|
|
|
// parseExcludeColumns parses --exclude-columns flag into a map for O(1) lookup
|
|
func parseExcludeColumns(excludeFlag string) map[string]bool {
|
|
excludeMap := make(map[string]bool)
|
|
if excludeFlag == "" {
|
|
return excludeMap
|
|
}
|
|
cols := strings.Split(excludeFlag, ",")
|
|
for _, col := range cols {
|
|
excludeMap[strings.TrimSpace(col)] = true
|
|
}
|
|
return excludeMap
|
|
}
|
|
|
|
// getDefaultExcludeColumns returns heavy columns to exclude for a given table.
|
|
// Returns nil if --include-heavy is set or the table has no heavy columns.
|
|
func getDefaultExcludeColumns(tableName string) []string {
|
|
if dbIncludeHeavy {
|
|
return nil
|
|
}
|
|
if tableName == "assets" {
|
|
return database.AssetHeavyColumns
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getEffectiveColumns determines which columns to display based on flags and table defaults
|
|
func getEffectiveColumns(tableName string, requestedColumns []string, showAll bool) []string {
|
|
// If user specified columns, use them as-is
|
|
if len(requestedColumns) > 0 {
|
|
return requestedColumns
|
|
}
|
|
|
|
// If --all flag, return nil (show all columns)
|
|
if showAll {
|
|
return nil
|
|
}
|
|
|
|
// Check for table-specific defaults
|
|
if defaults, ok := tableDefaultColumns[tableName]; ok {
|
|
return defaults
|
|
}
|
|
|
|
// Return nil to indicate "all except hidden"
|
|
return nil
|
|
}
|
|
|
|
// isHiddenColumn checks if a column should be hidden by default
|
|
func isHiddenColumn(col string) bool {
|
|
for _, hidden := range defaultHiddenColumns {
|
|
if col == hidden {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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 {
|
|
fmt.Println("No records found.")
|
|
return
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
fmt.Println("No records found.")
|
|
return
|
|
}
|
|
|
|
// Get headers (all keys or selected columns)
|
|
var headers []string
|
|
if len(columns) > 0 {
|
|
for _, col := range columns {
|
|
if !excludeColumns[col] {
|
|
headers = append(headers, col)
|
|
}
|
|
}
|
|
} else {
|
|
for key := range data[0] {
|
|
if hideDefaultColumns && isHiddenColumn(key) {
|
|
continue
|
|
}
|
|
if excludeColumns[key] {
|
|
continue
|
|
}
|
|
headers = append(headers, key)
|
|
}
|
|
sort.Strings(headers)
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
// Auto-detect terminal width if maxWidth is 0
|
|
effectiveWidth := maxWidth
|
|
if effectiveWidth == 0 {
|
|
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
|
|
effectiveWidth = w
|
|
}
|
|
}
|
|
if effectiveWidth > 0 && len(headers) > 0 {
|
|
// Compute per-column widths that fill the terminal width.
|
|
// WithMaxWidth only caps columns — short-content columns don't expand to fill space.
|
|
// Using WithColumnWidths forces each column to the allocated width.
|
|
numCols := len(headers)
|
|
// Overhead: " │ " (3 chars) between columns, plus leading/trailing space
|
|
overhead := (numCols-1)*3 + 2
|
|
available := effectiveWidth - overhead
|
|
|
|
widths := tw.NewMapper[int, int]()
|
|
displayNames := tableColumnDisplayNames[tableName]
|
|
if available >= numCols*4 {
|
|
// Base width per column = display header length + 2 (padding), minimum 8
|
|
mins := make([]int, numCols)
|
|
totalMin := 0
|
|
for i, h := range headers {
|
|
hLen := len(h)
|
|
if displayNames != nil {
|
|
if alias, ok := displayNames[h]; ok {
|
|
hLen = len(alias)
|
|
}
|
|
}
|
|
mins[i] = hLen + 2
|
|
if mins[i] < 8 {
|
|
mins[i] = 8
|
|
}
|
|
totalMin += mins[i]
|
|
}
|
|
if totalMin >= available {
|
|
// Terminal too narrow for headers — equal distribution
|
|
perCol := available / numCols
|
|
for i := range headers {
|
|
widths[i] = max(perCol, 4)
|
|
}
|
|
} else {
|
|
// Distribute surplus using per-column weights (default weight = 1)
|
|
surplus := available - totalMin
|
|
colWeights := tableColumnWeights[tableName]
|
|
totalWeight := 0
|
|
weights := make([]int, numCols)
|
|
for i, h := range headers {
|
|
w := 1
|
|
if colWeights != nil {
|
|
if cw, ok := colWeights[h]; ok {
|
|
w = cw
|
|
}
|
|
}
|
|
weights[i] = w
|
|
totalWeight += w
|
|
}
|
|
for i := range headers {
|
|
widths[i] = mins[i] + surplus*weights[i]/totalWeight
|
|
}
|
|
}
|
|
opts = append(opts, tablewriter.WithColumnWidths(widths))
|
|
} else {
|
|
opts = append(opts, tablewriter.WithMaxWidth(effectiveWidth))
|
|
}
|
|
opts = append(opts, tablewriter.WithRowAutoWrap(tw.WrapBreak))
|
|
} else if effectiveWidth == 0 {
|
|
// No width constraint (piped output) — apply per-column minimums if defined
|
|
if colWidths, ok := tableColumnMinWidths[tableName]; ok {
|
|
widths := tw.NewMapper[int, int]()
|
|
for i, h := range headers {
|
|
if minW, exists := colWidths[h]; exists {
|
|
widths[i] = minW
|
|
}
|
|
}
|
|
if len(widths) > 0 {
|
|
opts = append(opts, tablewriter.WithColumnWidths(widths))
|
|
}
|
|
}
|
|
}
|
|
|
|
table := tablewriter.NewTable(os.Stdout, opts...)
|
|
|
|
// Convert headers to []any for variadic call, applying display name aliases
|
|
displayNames := tableColumnDisplayNames[tableName]
|
|
headerArgs := make([]any, len(headers))
|
|
for i, h := range headers {
|
|
if displayNames != nil {
|
|
if alias, ok := displayNames[h]; ok {
|
|
headerArgs[i] = alias
|
|
continue
|
|
}
|
|
}
|
|
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 table display and applies column-specific coloring
|
|
func formatTableValue(v interface{}, columnName string) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
|
|
var s string
|
|
switch val := v.(type) {
|
|
case string:
|
|
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 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
|
|
}
|
|
|
|
// getDatabaseInfo returns a human-readable database info string
|
|
func getDatabaseInfo(cfg *config.Config, db interface{}) string {
|
|
if cfg.IsPostgres() {
|
|
return fmt.Sprintf("PostgreSQL @ %s:%d/%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.DBName)
|
|
}
|
|
return fmt.Sprintf("SQLite @ %s", cfg.GetDBPath())
|
|
}
|
|
|
|
// runIndexWorkflows indexes workflows from filesystem to database
|
|
func runIndexWorkflows(cmd *cobra.Command, args []string) error {
|
|
if disableDB {
|
|
return fmt.Errorf("database commands unavailable: --disable-db flag is set")
|
|
}
|
|
|
|
printer := terminal.NewPrinter()
|
|
cfg := config.Get()
|
|
|
|
if cfg == nil {
|
|
return fmt.Errorf("configuration not loaded")
|
|
}
|
|
|
|
printer.Info("Connecting to database...")
|
|
|
|
// 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()
|
|
|
|
// Ensure tables exist
|
|
if err := database.Migrate(ctx); err != nil {
|
|
return fmt.Errorf("failed to run migrations: %w", err)
|
|
}
|
|
|
|
printer.Info("Indexing workflows from: %s", cfg.WorkflowsPath)
|
|
if dbIndexForce {
|
|
printer.Info("Force mode: re-indexing all workflows")
|
|
}
|
|
|
|
// Index workflows
|
|
result, err := database.IndexWorkflowsFromFilesystem(ctx, cfg.WorkflowsPath, dbIndexForce)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to index workflows: %w", err)
|
|
}
|
|
|
|
// Print results
|
|
printer.Success("Workflow indexing completed")
|
|
fmt.Println()
|
|
fmt.Printf(" Added: %d\n", result.Added)
|
|
fmt.Printf(" Updated: %d\n", result.Updated)
|
|
fmt.Printf(" Removed: %d\n", result.Removed)
|
|
|
|
if len(result.Errors) > 0 {
|
|
fmt.Println()
|
|
printer.Warning("Errors encountered:")
|
|
for _, e := range result.Errors {
|
|
printer.Bullet(e)
|
|
}
|
|
}
|
|
|
|
// Show total count
|
|
count, err := database.GetWorkflowCount(ctx)
|
|
if err == nil {
|
|
fmt.Println()
|
|
printer.Info("Total workflows indexed: %d", count)
|
|
}
|
|
|
|
return nil
|
|
}
|