Files
j3ssie 77bad65cd9 feat: v5.1.0 — orgs, npm distribution, bundled skills, platform vendoring
Org (tenant) layer
- New Org model with org_uuid denormalized onto workspaces, assets,
  vulnerabilities and runs so cross-workspace queries need no join
- Automatic attribution via BeforeAppendModel hooks; importers stay org-unaware
- Read semantics: empty org means no filter (backward compatible)
  Write semantics: empty org coerced to the default org
- Migration backfills every pre-existing row into the default org
- CLI: osmedeus org create/show/assign/use/rename/delete
- API: /osm/api/orgs CRUD plus ?org= on assets, vulns, runs and workspaces

npm distribution
- npm install -g @j3ssie/osmedeus ships the Go binary through npm
- One npm name with version-suffixed platform builds pulled in as aliased
  optionalDependencies, so an install downloads exactly one binary
- Binary ships gzipped and is decompressed on first run into a
  version-scoped cache, so an upgrade can never exec a stale binary
- make bump-version is the single source of truth for the version constant

Bundled agent skills
- public/skills/ embedded in the binary, installed via osmedeus skills install
- Filesystem-driven discovery: a new bundle needs no code change
- make sync-skills mirrors bundles out to the standalone skills repo

Platform sub-projects
- Vendor dashboard, registry and workflow under platform/ so they version
  with the engine they talk to; make sync-platform publishes them out
- Rebuild the embedded UI in public/ui/
2026-08-08 22:26:09 +08:00

217 lines
6.2 KiB
Go

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
assetsWhere []string
assetsSearch string
assetsValue string
)
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 (fuzzy match, e.g., httpx)")
assetsCmd.Flags().StringVar(&assetsType, "type", "", "filter by asset_type (fuzzy match, 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)")
assetsCmd.Flags().StringArrayVar(&assetsWhere, "where", nil, "filter by column (key=value, fuzzy match, repeatable)")
assetsCmd.Flags().StringVar(&assetsSearch, "search", "", "search all columns for substring (case-insensitive)")
assetsCmd.Flags().StringVarP(&assetsValue, "value", "V", "", "filter by asset_value (fuzzy match)")
}
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
}
// Exact-match filters
filters := make(map[string]string)
if assetsWorkspace != "" {
filters["workspace"] = assetsWorkspace
}
// Org filter. An empty resolution means no org was selected, in which case no
// clause is added at all and the query spans every org exactly as it did
// before orgs existed.
orgUUID, err := resolveOrgUUID(ctx)
if err != nil {
return err
}
if orgUUID != "" {
filters["org_uuid"] = orgUUID
}
// Fuzzy-match filters (LIKE)
fuzzyFilters := parseWhereFilters(assetsWhere)
if assetsSource != "" {
fuzzyFilters["source"] = assetsSource
}
if assetsType != "" {
fuzzyFilters["asset_type"] = assetsType
}
if assetsValue != "" {
fuzzyFilters["asset_value"] = assetsValue
}
// Search term: --search flag or positional arg
search := assetsSearch
if len(args) > 0 {
search = args[0]
}
records, err := database.GetTableRecords(ctx, "assets", assetsOffset, assetsLimit, filters, fuzzyFilters, 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()
}