diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3a53458 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race ./... + + - name: Build + run: go build ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c136f18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Compiled binaries +/termaid +termaid +/cmd/**/termaid + +# Runtime output +/workdir/ +run-*.log + +# Editor / OS cruft +*.swp +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c6a01f3 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +BINARY := termaid +PKG := ./cmd/termaid +PREFIX ?= $(HOME)/.local +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) + +.PHONY: all build install test race vet fmt fmt-check tidy run clean help + +all: build ## Build the binary (default) + +build: ## Compile the termaid binary + go build -o $(BINARY) $(PKG) + +install: ## Install termaid into $(PREFIX)/bin + install -d $(PREFIX)/bin + go build -o $(PREFIX)/bin/$(BINARY) $(PKG) + @echo "installed $(PREFIX)/bin/$(BINARY)" + +test: ## Run the test suite + go test ./... + +race: ## Run tests with the race detector + go test -race ./... + +vet: ## Run go vet + go vet ./... + +fmt: ## Format all Go sources + gofmt -w . + +fmt-check: ## Fail if any file is not gofmt-clean + @unformatted=$$(gofmt -l .); \ + if [ -n "$$unformatted" ]; then \ + echo "not gofmt-clean:"; echo "$$unformatted"; exit 1; \ + fi + +tidy: ## Sync go.mod/go.sum + go mod tidy + +run: build ## Build and launch the interactive TUI + ./$(BINARY) + +clean: ## Remove build artifacts and run output + rm -f $(BINARY) + rm -rf workdir + rm -f run-*.log + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' diff --git a/README.md b/README.md index 112b19d..e2d8fc2 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Since this platform is a work in progress and was greatly inspired by trickest.i ### Prerequisites -- Go 1.22+ +- Go 1.24+ - Python 3.x - Node.js (for some tools) - Common bug bounty tools (subfinder, httpx, nuclei, etc.) @@ -48,13 +48,19 @@ The installer will: ### Manual Build ```bash -go mod tidy go build -o termaid ./cmd/termaid +# or, with the Makefile: +make build # compile ./termaid +make install # install into ~/.local/bin +make test # run the test suite ``` +The tool catalog (`assets/tools.yaml`) is embedded into the binary at build +time, so `termaid` runs correctly from any working directory. + ## Usage -Launch the TUI: +Launch the interactive TUI: ```bash ./termaid @@ -68,6 +74,42 @@ Launch the TUI: 4. **Create Workflow** - Open the visual workflow builder 5. **Exit** - Quit the application +## Command-Line (Headless) Mode + +Termaid can be driven entirely from the command line, which makes it easy to +script and to run in CI. Running with no arguments launches the TUI; any +subcommand runs headlessly. + +```bash +# Execute a workflow against a target, writing results under ./workdir +termaid run -d example.com -w workflow.json -o workdir -c 6 + +# Print the Mermaid diagram for a workflow (JSON or .mmd) +termaid preview -w workflow.json + +# List the tool catalog (optionally filtered by category) +termaid tools +termaid tools -cat discovery + +# Validate a workflow file's structure +termaid validate -w workflow.json + +# Version / help +termaid version +termaid help +``` + +| Command | Flags | Description | +|------------|-----------------------------------------|----------------------------------------------| +| `run` | `-d` domain (required), `-w`, `-o`, `-c` | Execute a workflow headlessly | +| `preview` | `-w` | Print a workflow's Mermaid diagram | +| `tools` | `-cat` | List the embedded tool catalog | +| `validate` | `-w` | Check a workflow file for structural issues | + +`termaid run` streams per-tool status to stdout and exits non-zero on a fatal +error, so it composes well with shell pipelines and CI steps. Press `Ctrl-C` +to cancel a run cleanly. + ## Workflow Builder The interactive workflow builder allows you to: @@ -156,9 +198,18 @@ Workflows are JSON files with the following structure: ### Placeholders -- `{{domain}}` - Target domain -- `{{input}}` - Input file from previous layer -- `{{output}}` - Output file for current tool +Two equivalent placeholder styles are supported; use whichever you prefer. + +| Canonical | Catalog form | Meaning | +|--------------|-------------------|----------------------------------------| +| `{{domain}}` | `$(target)` | Target domain (first line of the input) | +| `{{input}}` | `$(target_file)` | Input file produced by the previous layer | +| `{{output}}` | `$(output)` | Output file for the current tool | + +If a tool's arguments contain no output placeholder, Termaid captures the +tool's **stdout** into its output file automatically — so tools that stream +results (e.g. `-o -`) work without any extra configuration. Each layer's tool +outputs are merged and de-duplicated before being handed to the next layer. ## Examples diff --git a/assets/embed.go b/assets/embed.go new file mode 100644 index 0000000..b7ab8bf --- /dev/null +++ b/assets/embed.go @@ -0,0 +1,11 @@ +// Package assets embeds static resources (the tool catalog) so the compiled +// termaid binary is self-contained and can run from any working directory. +package assets + +import _ "embed" + +// ToolsYAML is the built-in tool catalog, embedded at build time from +// assets/tools.yaml. Callers may still override it with a user-supplied file. +// +//go:embed tools.yaml +var ToolsYAML []byte diff --git a/cmd/demo/main.go b/cmd/demo/main.go index 0aad3a4..e8436a0 100644 --- a/cmd/demo/main.go +++ b/cmd/demo/main.go @@ -171,4 +171,4 @@ func createSampleWorkflow() *graph.DAG { } return dag -} \ No newline at end of file +} diff --git a/cmd/responsive-demo/main.go b/cmd/responsive-demo/main.go index f055cd3..de026fb 100644 --- a/cmd/responsive-demo/main.go +++ b/cmd/responsive-demo/main.go @@ -31,12 +31,12 @@ func main() { for _, size := range testSizes { fmt.Printf("šŸ“ %s Screen (%dx%d) - %s\n", size.name, size.width, size.height, size.desc) fmt.Println(strings.Repeat("─", 60)) - + layout := rm.CalculateLayout(size.width, size.height) - + // Show layout breakdown fmt.Printf("Screen Size Category: %v\n", getScreenSizeName(layout.ScreenSize)) - fmt.Printf("Tools Panel: %dx%d (%.1f%% width, %.1f%% height)\n", + fmt.Printf("Tools Panel: %dx%d (%.1f%% width, %.1f%% height)\n", layout.ToolsWidth, layout.ToolsHeight, float64(layout.ToolsWidth)/float64(size.width)*100, float64(layout.ToolsHeight)/float64(size.height)*100) @@ -46,7 +46,7 @@ func main() { layout.VisualWidth, layout.VisualHeight, float64(layout.VisualWidth)/float64(size.width)*100, float64(layout.VisualHeight)/float64(size.height)*100) - + // Show configuration details config := layout.Config fmt.Printf("\nConfiguration:\n") @@ -58,11 +58,11 @@ func main() { fmt.Printf(" Max Mermaid Lines: %d\n", config.MaxMermaidLines) fmt.Printf(" Vertical Scrolling: %t\n", config.UseVerticalScroll) fmt.Printf(" Horizontal Scrolling: %t\n", config.UseHorizontalScroll) - + // Visual layout representation fmt.Printf("\nLayout Visualization:\n") renderLayoutPreview(layout, size.width, size.height) - + fmt.Println() fmt.Println() } @@ -102,39 +102,45 @@ func main() { func getScreenSizeName(screenSize tui.ScreenSize) string { switch screenSize { - case 0: return "Tiny" - case 1: return "Small" - case 2: return "Medium" - case 3: return "Large" - case 4: return "XLarge" - default: return "Unknown" + case 0: + return "Tiny" + case 1: + return "Small" + case 2: + return "Medium" + case 3: + return "Large" + case 4: + return "XLarge" + default: + return "Unknown" } } func renderLayoutPreview(layout tui.LayoutDimensions, totalW, totalH int) { // Create a simple ASCII representation of the layout - fmt.Printf("ā”Œ%s┬%s┐\n", - strings.Repeat("─", layout.ToolsWidth/4), + fmt.Printf("ā”Œ%s┬%s┐\n", + strings.Repeat("─", layout.ToolsWidth/4), strings.Repeat("─", layout.InputWidth/4)) - + fmt.Printf("│%s│%s│ ← Input (%dx%d)\n", centerText("Tools", layout.ToolsWidth/4), centerText("Input/Args", layout.InputWidth/4), layout.InputWidth, layout.InputHeight) - + fmt.Printf("│%sā”œ%s┤\n", centerText(fmt.Sprintf("%dx%d", layout.ToolsWidth, layout.ToolsHeight), layout.ToolsWidth/4), strings.Repeat("─", layout.VisualWidth/4)) - + fmt.Printf("ā”œ%s┤%s│\n", strings.Repeat("─", layout.HelpWidth/4), centerText("Matrix Visual", layout.VisualWidth/4)) - + fmt.Printf("│%s│%s│ ← Visual (%dx%d)\n", centerText("Help", layout.HelpWidth/4), centerText(fmt.Sprintf("%dx%d", layout.VisualWidth, layout.VisualHeight), layout.VisualWidth/4), layout.VisualWidth, layout.VisualHeight) - + fmt.Printf("ā””%s┓%sā”˜\n", strings.Repeat("─", layout.HelpWidth/4), strings.Repeat("─", layout.VisualWidth/4)) @@ -151,4 +157,4 @@ func centerText(text string, width int) string { leftPad := padding / 2 rightPad := padding - leftPad return strings.Repeat(" ", leftPad) + text + strings.Repeat(" ", rightPad) -} \ No newline at end of file +} diff --git a/cmd/termaid/main.go b/cmd/termaid/main.go index 754132b..1df2151 100644 --- a/cmd/termaid/main.go +++ b/cmd/termaid/main.go @@ -1,21 +1,160 @@ +// Command termaid is a terminal-native automation framework for recon and bug +// bounty workflows. Run it with no arguments to launch the interactive TUI, or +// use a subcommand (run, preview, tools, validate) to drive it from scripts +// and CI. package main import ( - "log" + "context" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + "text/tabwriter" tea "github.com/charmbracelet/bubbletea" "github.com/MKlolbullen/termaid/internal/tui" ) +const version = "1.1.0" + func main() { + if len(os.Args) < 2 { + runTUI() + return + } + + switch os.Args[1] { + case "run": + cmdRun(os.Args[2:]) + case "preview": + cmdPreview(os.Args[2:]) + case "tools": + cmdTools(os.Args[2:]) + case "validate": + cmdValidate(os.Args[2:]) + case "tui": + runTUI() + case "version", "-v", "--version": + fmt.Println("termaid " + version) + case "help", "-h", "--help": + usage(os.Stdout) + default: + fmt.Fprintf(os.Stderr, "termaid: unknown command %q\n\n", os.Args[1]) + usage(os.Stderr) + os.Exit(2) + } +} + +func runTUI() { prog := tea.NewProgram( tui.NewMenu(), tea.WithAltScreen(), - tea.WithMouseAllMotion(), // ← mouse support + tea.WithMouseAllMotion(), ) + if _, err := prog.Run(); err != nil { + fmt.Fprintln(os.Stderr, "termaid:", err) + os.Exit(1) + } +} + +func cmdRun(argv []string) { + fs := flag.NewFlagSet("run", flag.ExitOnError) + wf := fs.String("w", "workflow.json", "workflow JSON file to execute") + domain := fs.String("d", "", "target domain (required)") + workdir := fs.String("o", "workdir", "output/working directory") + conc := fs.Int("c", 6, "maximum concurrent tools") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "Usage: termaid run -d [-w workflow.json] [-o workdir] [-c 6]") + fs.PrintDefaults() + } + _ = fs.Parse(argv) + + if *domain == "" { + fmt.Fprintln(os.Stderr, "termaid run: -d is required") + fs.Usage() + os.Exit(2) + } + + // Cancel the run cleanly on Ctrl-C / SIGTERM. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := tui.RunHeadless(ctx, *wf, *domain, *workdir, *conc, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "termaid run:", err) + os.Exit(1) + } +} + +func cmdPreview(argv []string) { + fs := flag.NewFlagSet("preview", flag.ExitOnError) + wf := fs.String("w", "workflow.json", "workflow JSON or .mmd file") + _ = fs.Parse(argv) - if err := prog.Start(); err != nil { - log.Fatal(err) + mmd, err := tui.MermaidForWorkflow(*wf) + if err != nil { + fmt.Fprintln(os.Stderr, "termaid preview:", err) + os.Exit(1) } + fmt.Print(mmd) + if len(mmd) > 0 && mmd[len(mmd)-1] != '\n' { + fmt.Println() + } +} + +func cmdTools(argv []string) { + fs := flag.NewFlagSet("tools", flag.ExitOnError) + cat := fs.String("cat", "", "filter by category") + _ = fs.Parse(argv) + + tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tCATEGORY\tIN\tOUT\tDESCRIPTION") + count := 0 + for _, t := range tui.CatalogInfo() { + if *cat != "" && t.Cat != *cat { + continue + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", t.Name, t.Cat, dash(t.In), dash(t.Out), t.Desc) + count++ + } + tw.Flush() + fmt.Printf("\n%d tool(s)\n", count) +} + +func cmdValidate(argv []string) { + fs := flag.NewFlagSet("validate", flag.ExitOnError) + wf := fs.String("w", "workflow.json", "workflow JSON file to validate") + _ = fs.Parse(argv) + + dag, err := tui.ValidateWorkflow(*wf) + if err != nil { + fmt.Fprintln(os.Stderr, "termaid validate:", err) + os.Exit(1) + } + fmt.Printf("āœ” %s is valid: %d node(s), %d layer(s), %d subgraph(s)\n", + *wf, len(dag.Nodes)-1, dag.MaxX, len(dag.Subgraphs)) +} + +func dash(s string) string { + if s == "" { + return "-" + } + return s +} + +func usage(w *os.File) { + fmt.Fprintln(w, `termaid `+version+` — terminal-native recon automation + +Usage: + termaid launch the interactive TUI + termaid run -d [-w f] execute a workflow headlessly + termaid preview [-w f] print a workflow's Mermaid diagram + termaid tools [-cat category] list the tool catalog + termaid validate [-w f] validate a workflow file + termaid version print the version + termaid help show this help + +Run "termaid -h" for command-specific flags.`) } diff --git a/cmd/tui-test/main.go b/cmd/tui-test/main.go index fd49305..e108ca7 100644 --- a/cmd/tui-test/main.go +++ b/cmd/tui-test/main.go @@ -81,7 +81,7 @@ func (m *model) addTool() { tool := m.tools[m.toolIdx] id := fmt.Sprintf("%s-1", tool) args := fmt.Sprintf("-d {{domain}} -o {{output}}") - + m.dag.AddNodeAtPosition(m.selNode, id, tool, args, m.layer+1, 0, "", false) m.selNode = id m.layer++ @@ -100,10 +100,10 @@ func (m model) View() string { } // Calculate 2x2 layout dimensions - toolsW := m.width / 5 // 20% - inputW := (m.width * 4) / 5 // 80% - helpH := m.height / 5 // 20% - visualH := (m.height * 4) / 5 // 80% + toolsW := m.width / 5 // 20% + inputW := (m.width * 4) / 5 // 80% + helpH := m.height / 5 // 20% + visualH := (m.height * 4) / 5 // 80% // Render panels toolsPanel := m.renderTools(toolsW, m.height-helpH) @@ -114,17 +114,17 @@ func (m model) View() string { // Combine layout leftCol := lipgloss.JoinVertical(lipgloss.Top, toolsPanel, helpPanel) rightCol := lipgloss.JoinVertical(lipgloss.Top, inputPanel, visualPanel) - + return lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) } func (m model) renderTools(w, h int) string { var content strings.Builder content.WriteString("Tools:\n") - + start := max(0, m.toolIdx-h+3) end := min(len(m.tools), start+h-2) - + for i := start; i < end; i++ { prefix := " " if i == m.toolIdx { @@ -132,56 +132,56 @@ func (m model) renderTools(w, h int) string { } content.WriteString(fmt.Sprintf("%s%s\n", prefix, m.tools[i])) } - + style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Width(w).Height(h) if m.focus == 0 { style = style.BorderForeground(lipgloss.Color("10")) } else { style = style.BorderForeground(lipgloss.Color("8")) } - + return style.Render(content.String()) } func (m model) renderHelp(w, h int) string { content := "Matrix Controls:\ntab - focus\n↑/↓ - navigate\nn - add tool\nr - remove\nq - quit" - + style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Width(w).Height(h) if m.focus == 1 { style = style.BorderForeground(lipgloss.Color("10")) } else { style = style.BorderForeground(lipgloss.Color("8")) } - + return style.Render(content) } func (m model) renderInput(w, h int) string { content := fmt.Sprintf("Target: example.com\nSelected: %s\nMatrix: [%d,%d]", m.selNode, m.layer, 0) - + style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Width(w).Height(h) if m.focus == 2 { style = style.BorderForeground(lipgloss.Color("10")) } else { style = style.BorderForeground(lipgloss.Color("8")) } - + return style.Render(content) } func (m model) renderVisual(w, h int) string { var content strings.Builder content.WriteString("Matrix Workflow:\n\n") - + for layer := 0; layer <= m.dag.MaxX; layer++ { layerMatrix := m.dag.GetLayerMatrix(layer) prefix := " " if layer == m.layer && m.focus == 3 { prefix = "ā–¶ " } - + content.WriteString(fmt.Sprintf("%sL%d: ", prefix, layer)) - + hasNodes := false for pos := 0; pos <= m.dag.MaxY; pos++ { if nodes, exists := layerMatrix[pos]; exists { @@ -214,7 +214,7 @@ func (m model) renderVisual(w, h int) string { } content.WriteString("\n") } - + content.WriteString("\nMermaid (LR):\n") mermaidLines := strings.Split(m.dag.ToCompactMermaid(), "\n") maxLines := min(h-8, len(mermaidLines)) @@ -224,14 +224,14 @@ func (m model) renderVisual(w, h int) string { content.WriteString("\n") } } - + style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Width(w).Height(h) if m.focus == 3 { style = style.BorderForeground(lipgloss.Color("10")) } else { style = style.BorderForeground(lipgloss.Color("8")) } - + return style.Render(content.String()) } @@ -254,4 +254,4 @@ func main() { if err := p.Start(); err != nil { log.Fatal(err) } -} \ No newline at end of file +} diff --git a/internal/graph/dag.go b/internal/graph/dag.go index 8ea5de4..3ad6413 100644 --- a/internal/graph/dag.go +++ b/internal/graph/dag.go @@ -24,22 +24,22 @@ type Coordinate struct { // SubgraphInfo contains metadata about a subgraph type SubgraphInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Nodes []string `json:"nodes"` - Parallel bool `json:"parallel"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Nodes []string `json:"nodes"` + Parallel bool `json:"parallel"` Matrix map[string]Coordinate `json:"matrix"` // node_id -> local coordinate } // DAG is a directed acyclic graph of nodes with matrix positioning. type DAG struct { - Nodes map[string]*Node `json:"nodes"` - Root string `json:"root"` - Matrix map[Coordinate][]*Node `json:"matrix"` // coordinate -> nodes at position - Subgraphs map[string]*SubgraphInfo `json:"subgraphs"` // subgraph_id -> info - MaxX int `json:"max_x"` // maximum layer - MaxY int `json:"max_y"` // maximum position in any layer + Nodes map[string]*Node `json:"nodes"` + Root string `json:"root"` + Matrix map[Coordinate][]*Node `json:"matrix"` // coordinate -> nodes at position + Subgraphs map[string]*SubgraphInfo `json:"subgraphs"` // subgraph_id -> info + MaxX int `json:"max_x"` // maximum layer + MaxY int `json:"max_y"` // maximum position in any layer } // NewDAG with an implicit "input" root. @@ -77,12 +77,12 @@ func (g *DAG) AddNodeAtPosition(parentID, nodeID, tool, args string, layer, posi if _, dup := g.Nodes[nodeID]; dup { return fmt.Errorf("node %q already exists", nodeID) } - + // Auto-assign position if not specified if position == -1 { position = g.getNextPosition(layer, subgraph) } - + node := &Node{ ID: nodeID, Tool: tool, @@ -93,7 +93,7 @@ func (g *DAG) AddNodeAtPosition(parentID, nodeID, tool, args string, layer, posi Subgraph: subgraph, Parallel: parallel, } - + // Set subgraph coordinates if in subgraph if subgraph != "" { if sg, exists := g.Subgraphs[subgraph]; exists { @@ -114,12 +114,12 @@ func (g *DAG) AddNodeAtPosition(parentID, nodeID, tool, args string, layer, posi } g.Subgraphs[subgraph].Matrix[nodeID] = Coordinate{X: node.SubX, Y: node.SubY} } - + g.Nodes[nodeID] = node g.Nodes[parentID].Children = append(g.Nodes[parentID].Children, nodeID) g.addToMatrix(node) g.updateBounds(layer, position) - + return nil } @@ -191,18 +191,18 @@ func (g *DAG) MoveNode(nodeID string, newLayer, newPosition int) error { if !exists { return fmt.Errorf("node %q not found", nodeID) } - + // Remove from current position g.removeFromMatrix(node) - + // Update coordinates node.Layer = newLayer node.Position = newPosition - + // Add to new position g.addToMatrix(node) g.updateBounds(newLayer, newPosition) - + return nil } @@ -214,7 +214,7 @@ func (g *DAG) CompactLayer(layer int) { nodes = append(nodes, node) } } - + // Sort by current position for i := 0; i < len(nodes)-1; i++ { for j := i + 1; j < len(nodes); j++ { @@ -223,21 +223,21 @@ func (g *DAG) CompactLayer(layer int) { } } } - + // Reassign positions sequentially for i, node := range nodes { g.removeFromMatrix(node) node.Position = i g.addToMatrix(node) } - + g.recalculateBounds() } // GetExecutionOrder returns the optimal execution order considering matrix positioning. func (g *DAG) GetExecutionOrder() [][]string { var order [][]string - + for layer := 0; layer <= g.MaxX; layer++ { layerGroups := g.GetParallelNodes(layer) for _, group := range layerGroups { @@ -250,7 +250,7 @@ func (g *DAG) GetExecutionOrder() [][]string { } } } - + return order } @@ -262,13 +262,13 @@ func (g *DAG) ValidateMatrix() error { // Multiple nodes at same coordinate - check if they're all parallel for _, node := range nodes { if !node.Parallel { - return fmt.Errorf("non-parallel node %s conflicts with other nodes at coordinate (%d,%d)", + return fmt.Errorf("non-parallel node %s conflicts with other nodes at coordinate (%d,%d)", node.ID, coord.X, coord.Y) } } } } - + // Check if all nodes are in matrix for _, node := range g.Nodes { coord := Coordinate{X: node.Layer, Y: node.Position} @@ -282,11 +282,11 @@ func (g *DAG) ValidateMatrix() error { } } if !found { - return fmt.Errorf("node %s not found in matrix at coordinate (%d,%d)", + return fmt.Errorf("node %s not found in matrix at coordinate (%d,%d)", node.ID, coord.X, coord.Y) } } - + return nil } @@ -295,16 +295,16 @@ func (g *DAG) RemoveNode(id string) error { if id == g.Root { return fmt.Errorf("cannot remove root") } - + // Get node before deletion node, exists := g.Nodes[id] if !exists { return fmt.Errorf("node %q not found", id) } - + // Remove from matrix g.removeFromMatrix(node) - + // Remove from subgraph if applicable if node.Subgraph != "" { if sg, exists := g.Subgraphs[node.Subgraph]; exists { @@ -316,17 +316,17 @@ func (g *DAG) RemoveNode(id string) error { } } delete(sg.Matrix, id) - + // Remove subgraph if empty if len(sg.Nodes) == 0 { delete(g.Subgraphs, node.Subgraph) } } } - + // Remove node delete(g.Nodes, id) - + // Remove from all children lists for _, n := range g.Nodes { dst := n.Children[:0] @@ -337,10 +337,10 @@ func (g *DAG) RemoveNode(id string) error { } n.Children = dst } - + // Recalculate bounds g.recalculateBounds() - + return nil } @@ -352,7 +352,7 @@ func (g *DAG) GetLayer(l int) []string { nodes = append(nodes, n) } } - + // Sort by position (Y coordinate) for i := 0; i < len(nodes)-1; i++ { for j := i + 1; j < len(nodes); j++ { @@ -361,7 +361,7 @@ func (g *DAG) GetLayer(l int) []string { } } } - + ids := make([]string, len(nodes)) for i, n := range nodes { ids[i] = n.ID @@ -419,28 +419,57 @@ func (g *DAG) UpdateBounds(layer, position int) { } } -// GetParallelNodes returns nodes that can run in parallel at the same layer. +// MaxLayer returns the highest layer index currently in use (X axis). +func (g *DAG) MaxLayer() int { return g.MaxX } + +// RemoveFromLayer detaches a node from the coordinate matrix without deleting +// it from the graph, so it can be re-inserted at a new position. +func (g *DAG) RemoveFromLayer(id string) { + if node, ok := g.Nodes[id]; ok { + g.removeFromMatrix(node) + } +} + +// InsertAtLayer places an existing node at the given layer/position and +// re-registers it in the matrix. +func (g *DAG) InsertAtLayer(id string, layer, position int) { + node, ok := g.Nodes[id] + if !ok { + return + } + node.Layer = layer + node.Position = position + g.addToMatrix(node) + g.updateBounds(layer, position) +} + +// GetParallelNodes groups the nodes of a layer by how they execute. All +// parallel nodes in the layer share a single concurrent group (regardless of +// their vertical position), while each non-parallel node forms its own group +// that runs sequentially. func (g *DAG) GetParallelNodes(layer int) [][]*Node { layerMatrix := g.GetLayerMatrix(layer) var groups [][]*Node - + parallelGroup := []*Node{} + for pos := 0; pos <= g.MaxY; pos++ { - if nodes, exists := layerMatrix[pos]; exists { - parallelGroup := []*Node{} - for _, node := range nodes { - if node.Parallel { - parallelGroup = append(parallelGroup, node) - } else { - // Non-parallel nodes get their own group - groups = append(groups, []*Node{node}) - } - } - if len(parallelGroup) > 0 { - groups = append(groups, parallelGroup) + nodes, exists := layerMatrix[pos] + if !exists { + continue + } + for _, node := range nodes { + if node.Parallel { + parallelGroup = append(parallelGroup, node) + } else { + groups = append(groups, []*Node{node}) } } } - + + if len(parallelGroup) > 0 { + groups = append(groups, parallelGroup) + } + return groups } @@ -453,17 +482,17 @@ func (g *DAG) GetSubgraphNodes(subgraphID string) []*Node { nodes = append(nodes, node) } } - + // Sort by subgraph coordinates for i := 0; i < len(nodes)-1; i++ { for j := i + 1; j < len(nodes); j++ { - if nodes[i].SubX > nodes[j].SubX || - (nodes[i].SubX == nodes[j].SubX && nodes[i].SubY > nodes[j].SubY) { + if nodes[i].SubX > nodes[j].SubX || + (nodes[i].SubX == nodes[j].SubX && nodes[i].SubY > nodes[j].SubY) { nodes[i], nodes[j] = nodes[j], nodes[i] } } } - + return nodes } return []*Node{} diff --git a/internal/graph/dag_test.go b/internal/graph/dag_test.go new file mode 100644 index 0000000..29ba41c --- /dev/null +++ b/internal/graph/dag_test.go @@ -0,0 +1,130 @@ +package graph + +import ( + "strings" + "testing" +) + +func TestAddNodeCreatesEdgeAndLayer(t *testing.T) { + g := NewDAG() + if err := g.AddNode("input", "subfinder-1", "subfinder", "-d {{domain}}", 1); err != nil { + t.Fatalf("AddNode: %v", err) + } + if err := g.AddNode("subfinder-1", "httpx-1", "httpx", "-l {{input}}", 2); err != nil { + t.Fatalf("AddNode: %v", err) + } + + if got := len(g.Nodes); got != 3 { // input + 2 + t.Fatalf("node count = %d, want 3", got) + } + if g.MaxLayer() != 2 { + t.Fatalf("MaxLayer = %d, want 2", g.MaxLayer()) + } + if kids := g.Nodes["input"].Children; len(kids) != 1 || kids[0] != "subfinder-1" { + t.Fatalf("input children = %v, want [subfinder-1]", kids) + } +} + +func TestAddNodeRejectsDuplicatesAndMissingParent(t *testing.T) { + g := NewDAG() + _ = g.AddNode("input", "a-1", "a", "", 1) + + if err := g.AddNode("input", "a-1", "a", "", 1); err == nil { + t.Fatal("expected error for duplicate node id") + } + if err := g.AddNode("ghost", "b-1", "b", "", 1); err == nil { + t.Fatal("expected error for missing parent") + } +} + +func TestRemoveNodePrunesEdges(t *testing.T) { + g := NewDAG() + _ = g.AddNode("input", "a-1", "a", "", 1) + _ = g.AddNode("a-1", "b-1", "b", "", 2) + + if err := g.RemoveNode("b-1"); err != nil { + t.Fatalf("RemoveNode: %v", err) + } + if _, ok := g.Nodes["b-1"]; ok { + t.Fatal("b-1 still present after removal") + } + for _, c := range g.Nodes["a-1"].Children { + if c == "b-1" { + t.Fatal("dangling child edge to b-1") + } + } + if err := g.RemoveNode("input"); err == nil { + t.Fatal("expected error removing root") + } +} + +func TestGetParallelNodesGroupsWholeLayer(t *testing.T) { + g := NewDAG() + _ = g.AddNodeAtPosition("input", "a-1", "a", "", 1, 0, "", true) + _ = g.AddNodeAtPosition("input", "b-1", "b", "", 1, 1, "", true) + _ = g.AddNodeAtPosition("input", "c-1", "c", "", 1, 2, "", true) + + groups := g.GetParallelNodes(1) + if len(groups) != 1 { + t.Fatalf("want 1 parallel group, got %d: %v", len(groups), groups) + } + if len(groups[0]) != 3 { + t.Fatalf("want 3 nodes in the parallel group, got %d", len(groups[0])) + } + + // Add a sequential node in the same layer: it must form its own group. + _ = g.AddNodeAtPosition("input", "d-1", "d", "", 1, 3, "", false) + groups = g.GetParallelNodes(1) + if len(groups) != 2 { + t.Fatalf("want 2 groups (1 sequential + 1 parallel), got %d: %v", len(groups), groups) + } +} + +func TestExecutionOrderFollowsLayers(t *testing.T) { + g := NewDAG() + _ = g.AddNode("input", "subfinder-1", "subfinder", "", 1) + _ = g.AddNode("subfinder-1", "httpx-1", "httpx", "", 2) + _ = g.AddNode("httpx-1", "nuclei-1", "nuclei", "", 3) + + order := g.GetExecutionOrder() + // Flatten and check subfinder precedes httpx precedes nuclei. + pos := map[string]int{} + for i, group := range order { + for _, id := range group { + pos[id] = i + } + } + if !(pos["subfinder-1"] < pos["httpx-1"] && pos["httpx-1"] < pos["nuclei-1"]) { + t.Fatalf("bad execution ordering: %v", pos) + } +} + +func TestInsertAtLayerRepositions(t *testing.T) { + g := NewDAG() + _ = g.AddNodeAtPosition("input", "a-1", "a", "", 1, 0, "", false) + + g.RemoveFromLayer("a-1") + g.InsertAtLayer("a-1", 3, 2) + + if n := g.Nodes["a-1"]; n.Layer != 3 || n.Position != 2 { + t.Fatalf("node position = (%d,%d), want (3,2)", n.Layer, n.Position) + } + if g.MaxLayer() != 3 { + t.Fatalf("MaxLayer = %d, want 3", g.MaxLayer()) + } +} + +func TestToMermaidAndJSON(t *testing.T) { + g := NewDAG() + _ = g.AddNode("input", "subfinder-1", "subfinder", "-d {{domain}}", 1) + + mmd := g.ToMermaid() + if !strings.HasPrefix(mmd, "graph LR") { + t.Fatalf("mermaid missing header: %q", mmd) + } + + js := g.ToJSON() + if !strings.Contains(js, `"subfinder-1"`) || !strings.Contains(js, `"version": "2.0"`) { + t.Fatalf("json missing expected content: %s", js) + } +} diff --git a/internal/graph/render.go b/internal/graph/render.go index f7d76d5..17b6cbb 100644 --- a/internal/graph/render.go +++ b/internal/graph/render.go @@ -28,13 +28,13 @@ func (g *DAG) generateSubgraphs(b *strings.Builder) { for sgID, sg := range g.Subgraphs { if len(sg.Nodes) > 0 { fmt.Fprintf(b, " subgraph %s[\"%s\"]\n", sgID, sg.Name) - + // Sort nodes by subgraph coordinates nodes := g.GetSubgraphNodes(sgID) for _, node := range nodes { fmt.Fprintf(b, " %s[\"%s\\n%s\"]\n", node.ID, node.Tool, truncateArgs(node.Args)) } - + b.WriteString(" end\n") } } @@ -44,14 +44,14 @@ func (g *DAG) generateSubgraphs(b *strings.Builder) { func (g *DAG) generateLayers(b *strings.Builder) { for layer := 0; layer <= g.MaxX; layer++ { layerMatrix := g.GetLayerMatrix(layer) - + if len(layerMatrix) == 0 { continue } - + // Create layer subgraph fmt.Fprintf(b, " subgraph L%d[\"Layer %d\"]\n", layer, layer) - + // Process positions in order for pos := 0; pos <= g.MaxY; pos++ { if nodes, exists := layerMatrix[pos]; exists { @@ -59,7 +59,7 @@ func (g *DAG) generateLayers(b *strings.Builder) { // Single node at position node := nodes[0] if node.Subgraph == "" { // Only render if not in a subgraph - fmt.Fprintf(b, " %s[\"%s\\n%s\"]\n", + fmt.Fprintf(b, " %s[\"%s\\n%s\"]\n", node.ID, node.Tool, truncateArgs(node.Args)) } } else if len(nodes) > 1 { @@ -67,7 +67,7 @@ func (g *DAG) generateLayers(b *strings.Builder) { fmt.Fprintf(b, " subgraph P%d_%d[\"Parallel Group\"]\n", layer, pos) for _, node := range nodes { if node.Subgraph == "" { - fmt.Fprintf(b, " %s[\"%s\\n%s\"]\n", + fmt.Fprintf(b, " %s[\"%s\\n%s\"]\n", node.ID, node.Tool, truncateArgs(node.Args)) } } @@ -75,7 +75,7 @@ func (g *DAG) generateLayers(b *strings.Builder) { } } } - + b.WriteString(" end\n") } } @@ -87,7 +87,7 @@ func (g *DAG) generateEdges(b *strings.Builder) { for _, node := range g.Nodes { sortedNodes = append(sortedNodes, node) } - + // Sort by layer then position sort.Slice(sortedNodes, func(i, j int) bool { if sortedNodes[i].Layer != sortedNodes[j].Layer { @@ -95,7 +95,7 @@ func (g *DAG) generateEdges(b *strings.Builder) { } return sortedNodes[i].Position < sortedNodes[j].Position }) - + for _, node := range sortedNodes { for _, childID := range node.Children { if child, exists := g.Nodes[childID]; exists { @@ -103,10 +103,10 @@ func (g *DAG) generateEdges(b *strings.Builder) { edgeStyle := "-->" if child.Parallel && len(node.Children) > 1 { edgeStyle = "-.->|parallel|" - } else if child.Layer == node.Layer + 1 { + } else if child.Layer == node.Layer+1 { edgeStyle = "-->|sequential|" } - + fmt.Fprintf(b, " %s %s %s\n", node.ID, edgeStyle, childID) } } @@ -130,7 +130,7 @@ func (g *DAG) ToJSON() string { b.WriteString(fmt.Sprintf(" \"max_x\": %d,\n", g.MaxX)) b.WriteString(fmt.Sprintf(" \"max_y\": %d\n", g.MaxY)) b.WriteString(" },\n") - + // Export subgraphs if len(g.Subgraphs) > 0 { b.WriteString(" \"subgraphs\": [\n") @@ -145,11 +145,11 @@ func (g *DAG) ToJSON() string { } b.WriteString("\n ],\n") } - + // Export workflow nodes b.WriteString(" \"workflow\": [\n") first := true - + // Sort nodes by layer then position for consistent output var sortedNodes []*Node for _, n := range g.Nodes { @@ -157,35 +157,35 @@ func (g *DAG) ToJSON() string { sortedNodes = append(sortedNodes, n) } } - + sort.Slice(sortedNodes, func(i, j int) bool { if sortedNodes[i].Layer != sortedNodes[j].Layer { return sortedNodes[i].Layer < sortedNodes[j].Layer } return sortedNodes[i].Position < sortedNodes[j].Position }) - + for _, n := range sortedNodes { if !first { b.WriteString(",\n") } first = false - + subgraphStr := "" if n.Subgraph != "" { - subgraphStr = fmt.Sprintf(",\"subgraph\":\"%s\",\"sub_x\":%d,\"sub_y\":%d", + subgraphStr = fmt.Sprintf(",\"subgraph\":\"%s\",\"sub_x\":%d,\"sub_y\":%d", n.Subgraph, n.SubX, n.SubY) } - + fmt.Fprintf(&b, " {\"id\":\"%s\",\"tool\":\"%s\",\"args\":\"%s\",\"children\":%s,\"layer\":%d,\"position\":%d,\"parallel\":%t%s}", - n.ID, n.Tool, escapeJSON(n.Args), childrenJSON(n.Children), + n.ID, n.Tool, escapeJSON(n.Args), childrenJSON(n.Children), n.Layer, n.Position, n.Parallel, subgraphStr) } b.WriteString("\n ]\n}") return b.String() } -func escapeJSON(s string) string { +func escapeJSON(s string) string { s = strings.ReplaceAll(s, `"`, `\"`) s = strings.ReplaceAll(s, "\n", "\\n") s = strings.ReplaceAll(s, "\r", "\\r") @@ -211,7 +211,7 @@ func stringArrayJSON(arr []string) string { func (g *DAG) ToCompactMermaid() string { var b strings.Builder b.WriteString("graph LR\n") - + // Simple node definitions for _, node := range g.Nodes { if node.ID == g.Root { @@ -220,14 +220,14 @@ func (g *DAG) ToCompactMermaid() string { fmt.Fprintf(&b, " %s[%s]\n", node.ID, node.Tool) } } - + // Simple edges for _, node := range g.Nodes { for _, childID := range node.Children { fmt.Fprintf(&b, " %s --> %s\n", node.ID, childID) } } - + return b.String() } @@ -236,12 +236,12 @@ func (g *DAG) ToExecutionPlan() string { var b strings.Builder b.WriteString("Execution Plan:\n") b.WriteString("==============\n\n") - + executionOrder := g.GetExecutionOrder() - + for stepNum, group := range executionOrder { fmt.Fprintf(&b, "Step %d:\n", stepNum+1) - + if len(group) == 1 { if node, exists := g.Nodes[group[0]]; exists { fmt.Fprintf(&b, " → %s (%s)\n", node.Tool, node.ID) @@ -262,6 +262,6 @@ func (g *DAG) ToExecutionPlan() string { } b.WriteString("\n") } - + return b.String() } diff --git a/internal/pipeline/dataflow.go b/internal/pipeline/dataflow.go index 50831fa..afc5b30 100644 --- a/internal/pipeline/dataflow.go +++ b/internal/pipeline/dataflow.go @@ -36,13 +36,13 @@ type NodeOutput struct { // GlobalState tracks the overall workflow execution state type GlobalState struct { - RunID string `json:"run_id"` - StartTime time.Time `json:"start_time"` - Domain string `json:"domain"` - WorkflowPath string `json:"workflow_path"` - NodeStates map[string]NodeStatus `json:"node_states"` - DataLinks map[string][]string `json:"data_links"` // node_id -> input_files - Statistics *ExecutionStatistics `json:"statistics"` + RunID string `json:"run_id"` + StartTime time.Time `json:"start_time"` + Domain string `json:"domain"` + WorkflowPath string `json:"workflow_path"` + NodeStates map[string]NodeStatus `json:"node_states"` + DataLinks map[string][]string `json:"data_links"` // node_id -> input_files + Statistics *ExecutionStatistics `json:"statistics"` } // NodeStatus tracks individual node execution status @@ -58,13 +58,13 @@ const ( // ExecutionStatistics provides workflow execution metrics type ExecutionStatistics struct { - TotalNodes int `json:"total_nodes"` - CompletedNodes int `json:"completed_nodes"` - FailedNodes int `json:"failed_nodes"` - TotalResults int `json:"total_results"` - UniqueResults int `json:"unique_results"` - ExecutionTime time.Duration `json:"execution_time"` - ParallelEfficiency float64 `json:"parallel_efficiency"` + TotalNodes int `json:"total_nodes"` + CompletedNodes int `json:"completed_nodes"` + FailedNodes int `json:"failed_nodes"` + TotalResults int `json:"total_results"` + UniqueResults int `json:"unique_results"` + ExecutionTime time.Duration `json:"execution_time"` + ParallelEfficiency float64 `json:"parallel_efficiency"` } // DataProcessor handles different data formats and transformations @@ -95,9 +95,9 @@ type Formatter interface { // DataRecord represents a single piece of data (URL, domain, IP, etc.) type DataRecord struct { Value string `json:"value"` - Type string `json:"type"` // domain, url, ip, port, etc. - Source string `json:"source"` // which tool generated this - Layer int `json:"layer"` // workflow layer + Type string `json:"type"` // domain, url, ip, port, etc. + Source string `json:"source"` // which tool generated this + Layer int `json:"layer"` // workflow layer Timestamp time.Time `json:"timestamp"` Confidence float64 `json:"confidence"` // 0.0 to 1.0 Metadata map[string]string `json:"metadata"` @@ -106,27 +106,27 @@ type DataRecord struct { // NewDataFlow creates a new data flow manager func NewDataFlow(workDir, domain string) (*DataFlow, error) { runID := fmt.Sprintf("run-%d", time.Now().Unix()) - + df := &DataFlow{ WorkDir: workDir, RunID: runID, NodeOutputs: make(map[string]*NodeOutput), GlobalState: &GlobalState{ - RunID: runID, - StartTime: time.Now(), - Domain: domain, - NodeStates: make(map[string]NodeStatus), - DataLinks: make(map[string][]string), - Statistics: &ExecutionStatistics{}, + RunID: runID, + StartTime: time.Now(), + Domain: domain, + NodeStates: make(map[string]NodeStatus), + DataLinks: make(map[string][]string), + Statistics: &ExecutionStatistics{}, }, } - + // Create run-specific directory runDir := filepath.Join(workDir, runID) if err := os.MkdirAll(runDir, 0755); err != nil { return nil, fmt.Errorf("failed to create run directory: %w", err) } - + // Create subdirectories for organization dirs := []string{"raw", "processed", "merged", "analysis", "logs"} for _, dir := range dirs { @@ -134,7 +134,7 @@ func NewDataFlow(workDir, domain string) (*DataFlow, error) { return nil, fmt.Errorf("failed to create %s directory: %w", dir, err) } } - + return df, nil } @@ -142,11 +142,11 @@ func NewDataFlow(workDir, domain string) (*DataFlow, error) { func (df *DataFlow) CreateSeedFile() (string, error) { seedPath := filepath.Join(df.WorkDir, df.RunID, "raw", "00-seed.txt") content := fmt.Sprintf("%s\n", df.GlobalState.Domain) - + if err := os.WriteFile(seedPath, []byte(content), 0644); err != nil { return "", fmt.Errorf("failed to create seed file: %w", err) } - + // Create seed node output record df.NodeOutputs["seed"] = &NodeOutput{ NodeID: "seed", @@ -160,7 +160,7 @@ func (df *DataFlow) CreateSeedFile() (string, error) { Format: "txt", Metadata: map[string]string{"type": "domain", "source": "user_input"}, } - + return seedPath, nil } @@ -169,18 +169,18 @@ func (df *DataFlow) PrepareNodeInput(nodeID string, parentIDs []string, layer in if len(parentIDs) == 0 { return "", fmt.Errorf("no parent nodes specified for %s", nodeID) } - + // For single parent, use its output directly if len(parentIDs) == 1 { parentOutput, exists := df.NodeOutputs[parentIDs[0]] if !exists { return "", fmt.Errorf("parent node %s has no output", parentIDs[0]) } - + if len(parentOutput.OutputFiles) == 0 { return "", fmt.Errorf("parent node %s has no output files", parentIDs[0]) } - + // Use the merged output if available, otherwise the first output file for _, file := range parentOutput.OutputFiles { if strings.Contains(file, "merged") { @@ -188,30 +188,30 @@ func (df *DataFlow) PrepareNodeInput(nodeID string, parentIDs []string, layer in return file, nil } } - + inputFile := parentOutput.OutputFiles[0] df.GlobalState.DataLinks[nodeID] = []string{inputFile} return inputFile, nil } - + // For multiple parents, merge their outputs return df.mergeParentOutputs(nodeID, parentIDs, layer) } // mergeParentOutputs combines outputs from multiple parent nodes func (df *DataFlow) mergeParentOutputs(nodeID string, parentIDs []string, layer int) (string, error) { - mergedPath := filepath.Join(df.WorkDir, df.RunID, "merged", + mergedPath := filepath.Join(df.WorkDir, df.RunID, "merged", fmt.Sprintf("L%02d-%s-input.txt", layer, nodeID)) - + var allRecords []DataRecord var inputFiles []string - + for _, parentID := range parentIDs { parentOutput, exists := df.NodeOutputs[parentID] if !exists { continue } - + for _, outputFile := range parentOutput.OutputFiles { inputFiles = append(inputFiles, outputFile) records, err := df.parseFile(outputFile, parentID) @@ -221,54 +221,54 @@ func (df *DataFlow) mergeParentOutputs(nodeID string, parentIDs []string, layer allRecords = append(allRecords, records...) } } - + // Deduplicate and sort records uniqueRecords := df.deduplicateRecords(allRecords) sort.Slice(uniqueRecords, func(i, j int) bool { return uniqueRecords[i].Value < uniqueRecords[j].Value }) - + // Write merged file file, err := os.Create(mergedPath) if err != nil { return "", fmt.Errorf("failed to create merged file: %w", err) } defer file.Close() - + writer := bufio.NewWriter(file) for _, record := range uniqueRecords { fmt.Fprintln(writer, record.Value) } writer.Flush() - + df.GlobalState.DataLinks[nodeID] = inputFiles return mergedPath, nil } // RecordNodeOutput records the output from a completed node -func (df *DataFlow) RecordNodeOutput(nodeID, tool string, startTime, endTime time.Time, +func (df *DataFlow) RecordNodeOutput(nodeID, tool string, startTime, endTime time.Time, exitCode int, outputFiles []string, errorLog string) error { - + // Calculate file statistics var totalSize int64 var totalLines int var format string - + for _, file := range outputFiles { if stat, err := os.Stat(file); err == nil { totalSize += stat.Size() } - + if lines, err := df.countLines(file); err == nil { totalLines += lines } - + // Detect format from first file if format == "" { format = df.detectFormat(file) } } - + nodeOutput := &NodeOutput{ NodeID: nodeID, Tool: tool, @@ -282,10 +282,10 @@ func (df *DataFlow) RecordNodeOutput(nodeID, tool string, startTime, endTime tim Format: format, Metadata: make(map[string]string), } - + // Store node output df.NodeOutputs[nodeID] = nodeOutput - + // Update global state if exitCode == 0 { df.GlobalState.NodeStates[nodeID] = NodeCompleted @@ -294,12 +294,12 @@ func (df *DataFlow) RecordNodeOutput(nodeID, tool string, startTime, endTime tim df.GlobalState.NodeStates[nodeID] = NodeFailed df.GlobalState.Statistics.FailedNodes++ } - + // Create analysis summary if err := df.createNodeAnalysis(nodeOutput); err != nil { return fmt.Errorf("failed to create node analysis: %w", err) } - + return nil } @@ -309,33 +309,33 @@ func (df *DataFlow) ProcessNodeOutputs(nodeID string) error { if !exists { return fmt.Errorf("no output recorded for node %s", nodeID) } - + var processedFiles []string - + for _, outputFile := range nodeOutput.OutputFiles { // Parse and validate the file records, err := df.parseFile(outputFile, nodeID) if err != nil { continue // Skip invalid files } - + // Filter and clean records validRecords := df.filterValidRecords(records) - + // Create processed version processedPath := filepath.Join(df.WorkDir, df.RunID, "processed", fmt.Sprintf("%s-%s.json", nodeID, filepath.Base(outputFile))) - + if err := df.writeJSONRecords(validRecords, processedPath); err != nil { continue } - + processedFiles = append(processedFiles, processedPath) } - + // Update node output with processed files nodeOutput.Metadata["processed_files"] = strings.Join(processedFiles, ",") - + return nil } @@ -345,11 +345,11 @@ func (df *DataFlow) GetLatestOutput(nodeID string) (string, error) { if !exists { return "", fmt.Errorf("no output for node %s", nodeID) } - + if len(nodeOutput.OutputFiles) == 0 { return "", fmt.Errorf("no output files for node %s", nodeID) } - + // Return the last (most recent) output file return nodeOutput.OutputFiles[len(nodeOutput.OutputFiles)-1], nil } @@ -357,11 +357,11 @@ func (df *DataFlow) GetLatestOutput(nodeID string) (string, error) { // CreateExecutionReport generates a comprehensive execution report func (df *DataFlow) CreateExecutionReport() error { reportPath := filepath.Join(df.WorkDir, df.RunID, "execution-report.json") - + // Update final statistics df.GlobalState.Statistics.ExecutionTime = time.Since(df.GlobalState.StartTime) df.GlobalState.Statistics.TotalNodes = len(df.NodeOutputs) - + // Calculate unique results across all nodes allRecords := make(map[string]DataRecord) for _, nodeOutput := range df.NodeOutputs { @@ -375,16 +375,16 @@ func (df *DataFlow) CreateExecutionReport() error { } } } - + df.GlobalState.Statistics.TotalResults = len(allRecords) df.GlobalState.Statistics.UniqueResults = len(allRecords) - + // Write report reportData, err := json.MarshalIndent(df.GlobalState, "", " ") if err != nil { return fmt.Errorf("failed to marshal report: %w", err) } - + return os.WriteFile(reportPath, reportData, 0644) } @@ -396,16 +396,16 @@ func (df *DataFlow) parseFile(filePath, sourceNode string) ([]DataRecord, error) return nil, err } defer file.Close() - + var records []DataRecord scanner := bufio.NewScanner(file) - + for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } - + record := DataRecord{ Value: line, Type: df.inferDataType(line), @@ -414,40 +414,40 @@ func (df *DataFlow) parseFile(filePath, sourceNode string) ([]DataRecord, error) Confidence: 1.0, Metadata: make(map[string]string), } - + records = append(records, record) } - + return records, scanner.Err() } func (df *DataFlow) deduplicateRecords(records []DataRecord) []DataRecord { seen := make(map[string]DataRecord) - + for _, record := range records { existing, exists := seen[record.Value] if !exists || record.Confidence > existing.Confidence { seen[record.Value] = record } } - + var unique []DataRecord for _, record := range seen { unique = append(unique, record) } - + return unique } func (df *DataFlow) filterValidRecords(records []DataRecord) []DataRecord { var valid []DataRecord - + for _, record := range records { if df.isValidRecord(record) { valid = append(valid, record) } } - + return valid } @@ -456,7 +456,7 @@ func (df *DataFlow) isValidRecord(record DataRecord) bool { if value == "" { return false } - + // Basic validation based on type switch record.Type { case "domain": @@ -502,13 +502,13 @@ func (df *DataFlow) countLines(filePath string) (int, error) { return 0, err } defer file.Close() - + count := 0 scanner := bufio.NewScanner(file) for scanner.Scan() { count++ } - + return count, scanner.Err() } @@ -531,14 +531,14 @@ func (df *DataFlow) writeJSONRecords(records []DataRecord, outputPath string) er if err != nil { return err } - + return os.WriteFile(outputPath, data, 0644) } func (df *DataFlow) createNodeAnalysis(nodeOutput *NodeOutput) error { - analysisPath := filepath.Join(df.WorkDir, df.RunID, "analysis", + analysisPath := filepath.Join(df.WorkDir, df.RunID, "analysis", fmt.Sprintf("%s-analysis.json", nodeOutput.NodeID)) - + analysis := map[string]interface{}{ "node_id": nodeOutput.NodeID, "tool": nodeOutput.Tool, @@ -551,11 +551,11 @@ func (df *DataFlow) createNodeAnalysis(nodeOutput *NodeOutput) error { "success": nodeOutput.ExitCode == 0, "generated_at": time.Now().Format(time.RFC3339), } - + data, err := json.MarshalIndent(analysis, "", " ") if err != nil { return err } - + return os.WriteFile(analysisPath, data, 0644) -} \ No newline at end of file +} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index a71103a..985ff8b 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -115,12 +115,16 @@ func Run( } } - // Get merged output for next layer + // Merge every tool's output in this layer into a single, deduplicated + // file that seeds the next layer. Using the whole category directory + // (rather than just the first tool) ensures parallel branches all feed + // forward. if len(cat.Tools) > 0 { - prevPath, err = dataFlow.GetLatestOutput(cat.Tools[0].Name) - if err != nil { - return fmt.Errorf("failed to get latest output: %w", err) + merged, mErr := mergeOutputs(catDir) + if mErr != nil { + return fmt.Errorf("failed to merge layer outputs: %w", mErr) } + prevPath = merged } } @@ -151,22 +155,12 @@ func runTool( outputFile := filepath.Join(catDir, fmt.Sprintf("%s-%d.txt", tool.Name, startTime.Unix())) outputFiles = append(outputFiles, outputFile) - // prepare args with placeholder substitution - args := make([]string, len(tool.Args)) - copy(args, tool.Args) - - for i, a := range args { - if strings.Contains(a, "{{input}}") { - args[i] = strings.ReplaceAll(a, "{{input}}", inputPath) - } - if strings.Contains(a, "{{domain}}") { - domain := strings.TrimSpace(readFirstLine(inputPath)) - args[i] = strings.ReplaceAll(a, "{{domain}}", domain) - } - if strings.Contains(a, "{{output}}") { - args[i] = strings.ReplaceAll(a, "{{output}}", outputFile) - } - } + // Prepare args with placeholder substitution. Both the + // {{input}}/{{domain}}/{{output}} and $(target_file)/$(target)/$(output) + // placeholder styles are supported so hand-written presets and + // catalog-generated workflows behave identically. + domain := strings.TrimSpace(readFirstLine(inputPath)) + args := substituteArgs(tool.Args, domain, inputPath, outputFile) // Validate tool before execution if err := validateTool(tool); err != nil { @@ -199,6 +193,21 @@ func runTool( cmd.Stdin = inputFile } + // Capture stdout into the tool's output file unless the tool writes the + // file itself via an {{output}}/$(output) placeholder. Many recon tools + // stream results to stdout (e.g. "-o -"); without this their results + // would be discarded and never reach the next layer. + if !writesOwnFile(tool.Args) { + outF, err := os.Create(outputFile) + if err != nil { + out <- Status{Type: StatusError, Category: catName, Tool: tool.Name, Err: err} + dataFlow.RecordNodeOutput(tool.Name, tool.Command, startTime, time.Now(), 1, outputFiles, err.Error()) + return err + } + defer outF.Close() + cmd.Stdout = outF + } + stderr, err := cmd.StderrPipe() if err != nil { out <- Status{Type: StatusError, Category: catName, Tool: tool.Name, Err: err} @@ -303,6 +312,40 @@ func seedInput(domain string) (string, error) { func dirSafe(s string) string { return strings.ReplaceAll(strings.ToLower(s), " ", "_") } +// substituteArgs resolves workflow placeholders in a tool's argument list. +// It understands both placeholder dialects used across the project: +// +// {{input}} / $(target_file) -> path to the input file from the prior layer +// {{domain}} / $(target) -> the target domain (first line of the input) +// {{output}} / $(output) -> path to this tool's output file +func substituteArgs(rawArgs []string, domain, inputPath, outputFile string) []string { + r := strings.NewReplacer( + "{{input}}", inputPath, + "$(target_file)", inputPath, + "{{domain}}", domain, + "$(target)", domain, + "{{output}}", outputFile, + "$(output)", outputFile, + ) + out := make([]string, len(rawArgs)) + for i, a := range rawArgs { + out[i] = r.Replace(a) + } + return out +} + +// writesOwnFile reports whether a tool manages its own output file via an +// output placeholder, in which case the engine must not also redirect stdout +// to that file. +func writesOwnFile(rawArgs []string) bool { + for _, a := range rawArgs { + if strings.Contains(a, "{{output}}") || strings.Contains(a, "$(output)") { + return true + } + } + return false +} + // validateTool checks if a tool exists and is executable func validateTool(tool *Tool) error { // Check if command exists in PATH diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go new file mode 100644 index 0000000..1e2e8e6 --- /dev/null +++ b/internal/pipeline/pipeline_test.go @@ -0,0 +1,114 @@ +package pipeline + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSubstituteArgsBothDialects(t *testing.T) { + cases := []struct { + name string + in []string + want []string + }{ + { + name: "curly placeholders", + in: []string{"-l", "{{input}}", "-d", "{{domain}}", "-o", "{{output}}"}, + want: []string{"-l", "/in.txt", "-d", "example.com", "-o", "/out.txt"}, + }, + { + name: "dollar placeholders", + in: []string{"-silent", "$(target)", "-l", "$(target_file)"}, + want: []string{"-silent", "example.com", "-l", "/in.txt"}, + }, + { + name: "multiple placeholders in one arg", + in: []string{"-u", "{{input}}/FUZZ"}, + want: []string{"-u", "/in.txt/FUZZ"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := substituteArgs(tc.in, "example.com", "/in.txt", "/out.txt") + if len(got) != len(tc.want) { + t.Fatalf("len = %d, want %d (%v)", len(got), len(tc.want), got) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("arg %d = %q, want %q", i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestWritesOwnFile(t *testing.T) { + if !writesOwnFile([]string{"-o", "{{output}}"}) { + t.Fatal("expected true for {{output}}") + } + if !writesOwnFile([]string{"-o", "$(output)"}) { + t.Fatal("expected true for $(output)") + } + if writesOwnFile([]string{"-o", "-", "-silent"}) { + t.Fatal("expected false for stdout-only args") + } +} + +func TestMergeOutputsDeduplicates(t *testing.T) { + dir := t.TempDir() + write := func(name, content string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("a.txt", "a.example.com\nb.example.com\n") + write("b.txt", "b.example.com\nc.example.com\n") // b duplicated + + merged, err := mergeOutputs(dir) + if err != nil { + t.Fatalf("mergeOutputs: %v", err) + } + data, err := os.ReadFile(merged) + if err != nil { + t.Fatal(err) + } + seen := map[string]int{} + for _, line := range splitNonEmpty(string(data)) { + seen[line]++ + } + if len(seen) != 3 { + t.Fatalf("unique lines = %d, want 3 (%v)", len(seen), seen) + } + for k, v := range seen { + if v != 1 { + t.Fatalf("line %q appears %d times, want 1", k, v) + } + } +} + +func TestValidateToolMissingBinary(t *testing.T) { + err := validateTool(&Tool{Command: "definitely-not-a-real-binary-xyz", Args: []string{"-x"}}) + if err == nil { + t.Fatal("expected error for missing binary") + } +} + +func splitNonEmpty(s string) []string { + var out []string + cur := "" + for _, r := range s { + if r == '\n' { + if cur != "" { + out = append(out, cur) + } + cur = "" + continue + } + cur += string(r) + } + if cur != "" { + out = append(out, cur) + } + return out +} diff --git a/internal/tui/builder.go b/internal/tui/builder.go index c9c6b4a..92b4880 100644 --- a/internal/tui/builder.go +++ b/internal/tui/builder.go @@ -3,20 +3,26 @@ package tui import ( "fmt" "io" - "os" - "path/filepath" + "regexp" "strings" - "time" - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/MKlolbullen/termaid/internal/graph" ) +// sepItem is a non-selectable category header rendered inside the tool list. +type sepItem string + +func (s sepItem) Title() string { return string(s) } +func (s sepItem) Description() string { return "" } +func (s sepItem) FilterValue() string { return "" } +func (s sepItem) String() string { return string(s) } + /*─────────────────────── visual styles ─────────────────────────*/ var ( @@ -84,11 +90,12 @@ type BuilderModel struct { g *graph.DAG occ map[string]int - // cursor / focus - focus focusArea - curY int - curX int - msg string + // selection / cursor / focus + selNode string // currently selected node ID (defaults to root "input") + focus focusArea + curY int + curX int + msg string } /*─────────────────────── constructor ─────────────────────────*/ @@ -126,15 +133,16 @@ func NewBuilder(tools []string) BuilderModel { cv.YPosition = 1 return BuilderModel{ - btns: btns, - domainInp: dom, - toolSel: lst, - filterBox: filt, - argsInp: arg, - canvas: cv, - g: graph.NewDAG(), - occ: make(map[string]int), - focus: fHeader, + btns: btns, + domainInp: dom, + toolSel: lst, + filterBox: filt, + argsInp: arg, + canvas: cv, + g: graph.NewDAG(), + occ: make(map[string]int), + selNode: "input", + focus: fHeader, } } @@ -148,7 +156,7 @@ func (m BuilderModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { /*──────── mouse handling ───────*/ case tea.MouseMsg: - if v.Button == tea.MouseButtonLeft && v.Type == tea.MouseButtonPress { + if v.Button == tea.MouseButtonLeft && v.Action == tea.MouseActionPress { switch { case hitHeader(v): m.focus = fHeader @@ -167,10 +175,10 @@ func (m BuilderModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } if m.focus == fCanvas { - if v.Type == tea.MouseWheelUp { + if v.Button == tea.MouseButtonWheelUp { m.canvas.LineUp(3) } - if v.Type == tea.MouseWheelDown { + if v.Button == tea.MouseButtonWheelDown { m.canvas.LineDown(3) } } @@ -279,7 +287,7 @@ func (m *BuilderModel) handleKeys(k tea.KeyMsg) { m.focus = fArgs case "shift+tab": m.focus = fList - case "pgup", "pgdn", "ctrl+left", "ctrl+right", "ctrl+up", "ctrl+down": + case "pgup", "pgdn", "ctrl+up", "ctrl+down": m.zoomPan(ks) case "n", "r", "c": m.nodeOps(ks) @@ -305,8 +313,13 @@ func (m *BuilderModel) nodeOps(k string) { switch k { case "n": // add child - tool := m.toolSel.SelectedItem().(entryItem).name - if !canPipe(m.selNode, tool) { + sel, ok := m.toolSel.SelectedItem().(entryItem) + if !ok { + m.msg = "Select a tool first" + return + } + tool := sel.name + if !m.canPipe(m.selNode, tool) { m.msg = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("Type mismatch!") return } @@ -353,10 +366,6 @@ func (m *BuilderModel) zoomPan(key string) { case "pgdn": // zoom out m.canvas.Width = clamp(m.canvas.Width+10, 30, 120) m.canvas.Height = clamp(m.canvas.Height-3, 10, 50) - case "ctrl+left": - m.canvas.SetXOffset(m.canvas.XOffset - 6) - case "ctrl+right": - m.canvas.SetXOffset(m.canvas.XOffset + 6) case "ctrl+up": m.canvas.LineUp(2) case "ctrl+down": @@ -478,7 +487,7 @@ func (m BuilderModel) View() string { ) help := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render( - "↑↓←→ move n new r rm m pick/drop c args PgUp/Down zoom Ctrl+Arrows pan / filter ? legend q quit", + "↑↓←→ move n new r rm m pick/drop c args PgUp/Down zoom Ctrl+↑↓ scroll / filter q quit", ) return hdr + "\n" + @@ -494,7 +503,7 @@ func buildToolItems(names []string) []list.Item { for _, c := range catalog { if c.Cat != curCat { curCat = c.Cat - items = append(items, list.Separator("── "+curCat+" ──")) + items = append(items, sepItem("── "+curCat+" ──")) } items = append(items, entryItem{c.Name, c.Desc}) } @@ -503,15 +512,18 @@ func buildToolItems(names []string) []list.Item { type toolDelegate struct{} -func (toolDelegate) Height() int { return 1 } -func (toolDelegate) Spacing() int { return 0 } +func (toolDelegate) Height() int { return 1 } +func (toolDelegate) Spacing() int { return 0 } func (toolDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil } func (toolDelegate) Render(w io.Writer, m list.Model, idx int, itm list.Item) { - if sep, ok := itm.(list.Separator); ok { + if sep, ok := itm.(sepItem); ok { fmt.Fprintln(w, lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(sep.String())) return } - e := itm.(entryItem) + e, ok := itm.(entryItem) + if !ok { + return + } title := lipgloss.NewStyle().Width(14).Render(e.name) desc := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(e.desc) if idx == m.Index() { @@ -531,7 +543,7 @@ func (m *BuilderModel) applyFilter(cat string) { var items []list.Item for _, it := range buildToolItems(catalogueNames()) { switch v := it.(type) { - case list.Separator: + case sepItem: if strings.Contains(strings.ToLower(v.String()), cat) { items = append(items, v) } @@ -546,26 +558,33 @@ func (m *BuilderModel) applyFilter(cat string) { /*──────── type check ─────────────────────*/ -func canPipe(parentID, childTool string) bool { - parentKey := parentIDTool(parentID) - parentEntry, ok1 := catalogMap[parentKey] - childEntry, ok2 := catalogMap[childTool] - if !ok1 || !ok2 { +// canPipe reports whether childTool can consume the output of the node +// identified by parentID. The parent's output type is resolved through the +// DAG (node ID -> tool -> catalog entry); the implicit root emits the seed +// domain. Unknown or wildcard ("any"/"raw") types never block the user. +func (m *BuilderModel) canPipe(parentID, childTool string) bool { + childEntry, ok := catalogMap[childTool] + if !ok { return false } - pOut := parentEntry.Out cIn := childEntry.In - if pOut == "raw" || cIn == "raw" { - return true + + var pOut string + if parentID == m.g.Root { + pOut = "domain" // seed file contains the target domain + } else if pn, ok := m.g.Nodes[parentID]; ok { + if pe, ok := catalogMap[pn.Tool]; ok { + pOut = pe.Out + } } - return pOut == cIn -} -func parentIDTool(id string) string { - if entry, ok := catalogMap[id]; ok { - return entry.Name + if pOut == "" || cIn == "" { + return true } - return "" + if pOut == "any" || cIn == "any" || pOut == "raw" || cIn == "raw" { + return true + } + return pOut == cIn } /*──────── hit-test helpers ───────────────*/ @@ -579,8 +598,14 @@ func headerIndex(v tea.MouseMsg) int { return v.X / 10 } func listRow(v tea.MouseMsg) int { return v.Y - 3 } func canvasCoord(v tea.MouseMsg, vp viewport.Model) (int, int) { - x := (v.X - 46 + vp.XOffset) / 8 // 8 chars per cell - y := (v.Y - 3 + vp.YOffset) + x := (v.X - 46) / 8 // 8 chars per cell + y := v.Y - 3 + vp.YOffset + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } return x, y } @@ -611,7 +636,8 @@ func idAtCursor(m BuilderModel) string { return "" } +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + func stripAnsi(s string) string { - return lipgloss.NewStyle().Unset(). - UnsetBorder().UnsetMargin().UnsetPadding().Render(s) + return strings.TrimSpace(ansiRe.ReplaceAllString(s, "")) } diff --git a/internal/tui/catalog.go b/internal/tui/catalog.go index cd6dd57..72ea9e5 100644 --- a/internal/tui/catalog.go +++ b/internal/tui/catalog.go @@ -1,10 +1,14 @@ package tui import ( + "fmt" "os" "sort" + "strings" "gopkg.in/yaml.v3" + + "github.com/MKlolbullen/termaid/assets" ) /* ------ Shared UI list.Item: entryItem ------ */ @@ -15,49 +19,174 @@ func (e entryItem) Title() string { return e.name } func (e entryItem) Description() string { return e.desc } func (e entryItem) FilterValue() string { return e.name } -/* ─── catalogEntry (YAML) ─────────────────────────────────────────── */ +/* ─── rawTool mirrors the YAML catalog schema ───────────────────────────── + * + * assets/tools.yaml is a mapping keyed by tool name, e.g. + * + * subfinder: + * cat: discovery + * in: domain + * out: hosts + * def: ["-silent","-json","-o","-","$(target)"] + * params: + * threads: {type: int, default: 25, doc: "..."} + */ -type catalogEntry struct { - Name string `yaml:"name"` - Cat string `yaml:"cat"` - Desc string `yaml:"desc"` - Def string `yaml:"def"` +type rawTool struct { + Cat string `yaml:"cat"` + In string `yaml:"in"` + Out string `yaml:"out"` + Desc string `yaml:"desc"` + Def []string `yaml:"def"` + Params map[string]rawParam `yaml:"params"` } -/* ─── entryItem (UI list item) ────────────────────────────────────── */ +type rawParam struct { + Type string `yaml:"type"` + Default any `yaml:"default"` + Doc string `yaml:"doc"` + Values []string `yaml:"values"` +} +/* ─── catalogEntry (resolved, UI-friendly) ────────────────────────────────── */ -/* ─── global catalog slice ───────────────────────────────────────── */ +type catalogEntry struct { + Name string + Cat string + In string // input data type (domain, hosts, urls, ...) + Out string // output data type + Desc string + Def string // default args, space-joined for display/editing + DefArr []string // default args, pre-split + Params map[string]rawParam +} -var catalog []catalogEntry +/* ─── global catalog ────────────────────────────────────────────────────── + * + * catalog - sorted slice, drives the tool picker list. + * catalogMap - name -> entry, used for type-aware piping in the builder. + */ + +var ( + catalog []catalogEntry + catalogMap map[string]catalogEntry +) func init() { - c, err := LoadCatalog("assets/tools.yaml") + entries, err := parseCatalog(assets.ToolsYAML) if err != nil { - panic(err) + panic(fmt.Errorf("termaid: failed to parse embedded tool catalog: %w", err)) } - catalog = c + setCatalog(entries) } +// LoadCatalog reads and parses a catalog file, replacing the active catalog. +// It lets users point termaid at a customized assets/tools.yaml. func LoadCatalog(path string) ([]catalogEntry, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } - var list []catalogEntry - if err := yaml.Unmarshal(raw, &list); err != nil { + entries, err := parseCatalog(raw) + if err != nil { return nil, err } - sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name }) - return list, nil + setCatalog(entries) + return entries, nil +} + +// parseCatalog decodes the YAML catalog into resolved entries. +func parseCatalog(data []byte) ([]catalogEntry, error) { + var raw map[string]rawTool + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, err + } + + entries := make([]catalogEntry, 0, len(raw)) + for name, t := range raw { + def := t.Def + // Some catalog entries prefix the binary name in def (e.g. bbot, + // masscan). The binary is invoked separately, so drop a redundant + // leading token to avoid running "bbot bbot ...". + if len(def) > 0 && def[0] == name { + def = def[1:] + } + + desc := t.Desc + if desc == "" { + desc = describeTool(t) + } + + entries = append(entries, catalogEntry{ + Name: name, + Cat: t.Cat, + In: t.In, + Out: t.Out, + Desc: desc, + Def: strings.Join(def, " "), + DefArr: def, + Params: t.Params, + }) + } + return entries, nil +} + +// describeTool builds a short human-readable description from the schema when +// the catalog entry does not supply one. +func describeTool(t rawTool) string { + flow := "" + if t.In != "" || t.Out != "" { + flow = fmt.Sprintf("%s → %s", orDash(t.In), orDash(t.Out)) + } + if t.Cat == "" { + return flow + } + if flow == "" { + return t.Cat + } + return fmt.Sprintf("%s (%s)", t.Cat, flow) +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} + +// setCatalog installs entries as the active catalog, sorted by category then +// name so the picker groups tools sensibly. +func setCatalog(entries []catalogEntry) { + sort.Slice(entries, func(i, j int) bool { + if entries[i].Cat != entries[j].Cat { + return entries[i].Cat < entries[j].Cat + } + return entries[i].Name < entries[j].Name + }) + + catalog = entries + catalogMap = make(map[string]catalogEntry, len(entries)) + for _, e := range entries { + catalogMap[e.Name] = e + } } /* helper used by builder */ func defaultArgs(tool string) string { - for _, c := range catalog { - if c.Name == tool { - return c.Def - } + if e, ok := catalogMap[tool]; ok { + return normalizePlaceholders(e.Def) } return "" } + +// normalizePlaceholders converts catalog-style placeholders ($(target), +// $(target_file)) into the pipeline's canonical {{...}} form so a freshly +// built workflow runs the same way as a hand-written preset. +func normalizePlaceholders(args string) string { + r := strings.NewReplacer( + "$(target_file)", "{{input}}", + "$(target)", "{{domain}}", + "$(output)", "{{output}}", + ) + return r.Replace(args) +} diff --git a/internal/tui/catalog_test.go b/internal/tui/catalog_test.go new file mode 100644 index 0000000..3af6584 --- /dev/null +++ b/internal/tui/catalog_test.go @@ -0,0 +1,66 @@ +package tui + +import "testing" + +func TestEmbeddedCatalogLoaded(t *testing.T) { + if len(catalog) == 0 { + t.Fatal("embedded catalog is empty") + } + sf, ok := catalogMap["subfinder"] + if !ok { + t.Fatal("subfinder missing from catalog") + } + if sf.In != "domain" || sf.Out != "hosts" { + t.Fatalf("subfinder types = %s -> %s, want domain -> hosts", sf.In, sf.Out) + } +} + +func TestParseCatalogStripsLeadingBinaryName(t *testing.T) { + data := []byte("bbot:\n cat: discovery\n in: domain\n out: urls\n def: [\"bbot\", \"-t\", \"$(target)\"]\n") + entries, err := parseCatalog(data) + if err != nil { + t.Fatalf("parseCatalog: %v", err) + } + if len(entries) != 1 { + t.Fatalf("entries = %d, want 1", len(entries)) + } + e := entries[0] + if len(e.DefArr) != 2 || e.DefArr[0] != "-t" { + t.Fatalf("leading binary name not stripped: %v", e.DefArr) + } + if e.Desc == "" { + t.Fatal("expected a derived description") + } +} + +func TestNormalizePlaceholders(t *testing.T) { + got := normalizePlaceholders("-l $(target_file) -d $(target) -o $(output)") + want := "-l {{input}} -d {{domain}} -o {{output}}" + if got != want { + t.Fatalf("normalizePlaceholders = %q, want %q", got, want) + } +} + +func TestCanPipeTypeChecks(t *testing.T) { + m := NewBuilder(catalogueNames()) + if err := m.g.AddNode("input", "subfinder-1", "subfinder", "", 1); err != nil { + t.Fatalf("AddNode: %v", err) + } + + // Root emits the seed domain: subfinder (in=domain) accepts it. + if !m.canPipe("input", "subfinder") { + t.Fatal("input -> subfinder should be allowed") + } + // httpx wants hosts, not a raw domain. + if m.canPipe("input", "httpx") { + t.Fatal("input -> httpx should be rejected (domain != hosts)") + } + // subfinder (out=hosts) -> httpx (in=hosts) is valid. + if !m.canPipe("subfinder-1", "httpx") { + t.Fatal("subfinder -> httpx should be allowed (hosts -> hosts)") + } + // subfinder (out=hosts) -> nuclei (in=urls) is a mismatch. + if m.canPipe("subfinder-1", "nuclei") { + t.Fatal("subfinder -> nuclei should be rejected (hosts != urls)") + } +} diff --git a/internal/tui/headless.go b/internal/tui/headless.go new file mode 100644 index 0000000..1f0bb3c --- /dev/null +++ b/internal/tui/headless.go @@ -0,0 +1,132 @@ +package tui + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/MKlolbullen/termaid/internal/graph" + "github.com/MKlolbullen/termaid/internal/pipeline" +) + +// ToolSummary is a catalog entry exposed for non-interactive consumers (the +// CLI, tests). It mirrors the internal catalogEntry without leaking the +// unexported type. +type ToolSummary struct { + Name string + Cat string + In string + Out string + Desc string + Def string +} + +// CatalogInfo returns the active tool catalog as a slice of summaries, sorted +// the same way the interactive picker groups them (category, then name). +func CatalogInfo() []ToolSummary { + out := make([]ToolSummary, 0, len(catalog)) + for _, e := range catalog { + out = append(out, ToolSummary{ + Name: e.Name, + Cat: e.Cat, + In: e.In, + Out: e.Out, + Desc: e.Desc, + Def: e.Def, + }) + } + return out +} + +// MermaidForWorkflow returns the Mermaid representation of a workflow. A .mmd +// file is returned verbatim; a workflow JSON file is loaded and rendered. +func MermaidForWorkflow(path string) (string, error) { + if strings.HasSuffix(path, ".mmd") { + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(b), nil + } + dag, err := LoadWorkflow(path) + if err != nil { + return "", err + } + return dag.ToMermaid(), nil +} + +// ValidateWorkflow loads a workflow and checks its matrix for consistency. +// The DAG is returned even when validation fails, so callers can still report +// structural details. +func ValidateWorkflow(path string) (*graph.DAG, error) { + dag, err := LoadWorkflow(path) + if err != nil { + return nil, err + } + return dag, dag.ValidateMatrix() +} + +// RunHeadless executes a workflow without the TUI, streaming human-readable +// status lines to w. It blocks until the run completes and returns the first +// fatal error, if any. +func RunHeadless(ctx context.Context, path, domain, workdir string, concurrency int, w io.Writer) error { + if strings.TrimSpace(domain) == "" { + return fmt.Errorf("domain must not be empty") + } + if concurrency < 1 { + concurrency = 1 + } + + dag, err := LoadWorkflow(path) + if err != nil { + return fmt.Errorf("load workflow %q: %w", path, err) + } + + cats := dagToCategories(dag) + if len(cats) == 0 { + return fmt.Errorf("workflow %q contains no runnable tools", path) + } + + fmt.Fprintf(w, "ā–¶ running %q against %s (%d steps, concurrency %d)\n", + path, domain, len(cats), concurrency) + + ch := make(chan pipeline.Status, 128) + errCh := make(chan error, 1) + go func() { + errCh <- pipeline.Run(ctx, domain, workdir, cats, concurrency, ch) + close(ch) + }() + + var failures int + for st := range ch { + if st.Type == pipeline.StatusError { + failures++ + } + fmt.Fprintf(w, " [%s] %-20s %s\n", st.Category, st.Tool, headlessStatus(st)) + } + + if err := <-errCh; err != nil { + return err + } + fmt.Fprintf(w, "āœ” finished: %d step(s), %d tool error(s). Results in %s/\n", + len(cats), failures, workdir) + return nil +} + +func headlessStatus(s pipeline.Status) string { + switch s.Type { + case pipeline.StatusStart: + return "started" + case pipeline.StatusFinish: + return "done" + case pipeline.StatusError: + if s.Err != nil { + return "error: " + s.Err.Error() + } + return "error" + default: + return "?" + } +} diff --git a/internal/tui/menu.go b/internal/tui/menu.go index 5887238..eb292db 100644 --- a/internal/tui/menu.go +++ b/internal/tui/menu.go @@ -32,7 +32,7 @@ func NewMenu() MenuModel { if files, err := filepath.Glob("workflows/*.json"); err == nil { templateCount = len(files) } - + // Check if default workflow exists defaultExists := "āœ—" if _, err := os.Stat("workflow.json"); err == nil { @@ -114,12 +114,12 @@ func (m MenuModel) View() string { Foreground(lipgloss.Color("14")). Render("Termaid v1.0") + " " + lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")). - Render("- Bug Bounty Automation Platform") + Foreground(lipgloss.Color("8")). + Render("- Bug Bounty Automation Platform") // Status information statusInfo := m.getStatusInfo() - + // Footer with keyboard shortcuts footer := lipgloss.NewStyle(). Foreground(lipgloss.Color("8")). @@ -172,11 +172,11 @@ func LoadWorkflow(path string) (*graph.DAG, error) { if err != nil { return nil, err } - + // Try new format first var newFormat struct { - Version string `json:"version"` - Matrix struct { + Version string `json:"version"` + Matrix struct { MaxX int `json:"max_x"` MaxY int `json:"max_y"` } `json:"matrix"` @@ -188,13 +188,13 @@ func LoadWorkflow(path string) (*graph.DAG, error) { } `json:"subgraphs"` Workflow []graph.Node `json:"workflow"` } - + if err := json.Unmarshal(data, &newFormat); err == nil && newFormat.Version == "2.0" { // New format with matrix positioning g := graph.NewDAG() g.MaxX = newFormat.Matrix.MaxX g.MaxY = newFormat.Matrix.MaxY - + // Load subgraphs for _, sg := range newFormat.Subgraphs { g.Subgraphs[sg.ID] = &graph.SubgraphInfo{ @@ -205,7 +205,7 @@ func LoadWorkflow(path string) (*graph.DAG, error) { Matrix: make(map[string]graph.Coordinate), } } - + // Load nodes for _, n := range newFormat.Workflow { cp := n @@ -213,10 +213,10 @@ func LoadWorkflow(path string) (*graph.DAG, error) { g.Matrix[graph.Coordinate{X: n.Layer, Y: n.Position}] = append( g.Matrix[graph.Coordinate{X: n.Layer, Y: n.Position}], &cp) } - + return g, nil } - + // Fallback to old format var oldFormat struct { Workflow []graph.Node `json:"workflow"` @@ -224,7 +224,7 @@ func LoadWorkflow(path string) (*graph.DAG, error) { if err := json.Unmarshal(data, &oldFormat); err != nil { return nil, err } - + g := graph.NewDAG() for _, n := range oldFormat.Workflow { cp := n @@ -237,7 +237,7 @@ func LoadWorkflow(path string) (*graph.DAG, error) { g.Matrix[graph.Coordinate{X: cp.Layer, Y: cp.Position}], &cp) g.UpdateBounds(cp.Layer, cp.Position) } - + return g, nil } @@ -249,7 +249,7 @@ func runWorkflowWithDomain(path, domain string) (tea.Model, tea.Cmd) { if domain == "" { return errView(fmt.Errorf("domain cannot be empty")), nil } - + dag, err := LoadWorkflow(path) if err != nil { if os.IsNotExist(err) { @@ -257,7 +257,7 @@ func runWorkflowWithDomain(path, domain string) (tea.Model, tea.Cmd) { } return errView(fmt.Errorf("failed to load workflow '%s': %w", path, err)), nil } - + cats := dagToCategories(dag) if len(cats) == 0 { return errView(fmt.Errorf("workflow '%s' contains no valid tools to execute", path)), nil @@ -282,12 +282,12 @@ func previewMermaid() (tea.Model, tea.Cmd) { if err != nil { return errView(fmt.Errorf("failed to read workflow.mmd: %w", err)), nil } - + // Check if glow is available if _, err := exec.LookPath("glow"); err != nil { return errView(fmt.Errorf("glow command not found - please install glow to preview mermaid diagrams")), nil } - + md := "```mermaid\n" + string(raw) + "\n```" cmd := exec.Command("glow", "-") cmd.Stdin = strings.NewReader(md) @@ -302,20 +302,20 @@ func dagToCategories(g *graph.DAG) []pipeline.Category { if g.MaxX == 0 { return []pipeline.Category{} } - + var cats []pipeline.Category - + // Use execution order from matrix positioning executionOrder := g.GetExecutionOrder() - + for stepNum, nodeGroup := range executionOrder { if len(nodeGroup) == 0 { continue } - + var tools []pipeline.Tool categoryName := fmt.Sprintf("step-%d", stepNum+1) - + // Check if this is a parallel group isParallel := len(nodeGroup) > 1 if !isParallel && len(nodeGroup) == 1 { @@ -323,7 +323,7 @@ func dagToCategories(g *graph.DAG) []pipeline.Category { isParallel = node.Parallel } } - + for _, nodeID := range nodeGroup { if node, exists := g.Nodes[nodeID]; exists && node.ID != g.Root { tools = append(tools, pipeline.Tool{ @@ -335,7 +335,7 @@ func dagToCategories(g *graph.DAG) []pipeline.Category { }) } } - + if len(tools) > 0 { // Add layer info to category name for clarity if len(nodeGroup) > 0 { @@ -343,14 +343,14 @@ func dagToCategories(g *graph.DAG) []pipeline.Category { categoryName = fmt.Sprintf("layer-%d-step-%d", node.Layer, stepNum+1) } } - + cats = append(cats, pipeline.Category{ Name: categoryName, Tools: tools, }) } } - + return cats } @@ -358,26 +358,26 @@ func dagToCategories(g *graph.DAG) []pipeline.Category { func (m MenuModel) getStatusInfo() string { var status []string - + // Check workflow status if _, err := os.Stat("workflow.json"); err == nil { status = append(status, "āœ“ Default workflow ready") } else { status = append(status, "⚠ No default workflow") } - + // Count templates if files, err := filepath.Glob("workflows/*.json"); err == nil && len(files) > 0 { status = append(status, fmt.Sprintf("āœ“ %d templates available", len(files))) } else { status = append(status, "⚠ No templates found") } - + // Check for recent results if _, err := os.Stat("workdir"); err == nil { status = append(status, "āœ“ Previous results available") } - + return lipgloss.NewStyle(). Foreground(lipgloss.Color("8")). Render(strings.Join(status, " | ")) @@ -388,7 +388,7 @@ func (m MenuModel) viewResults() (tea.Model, tea.Cmd) { if _, err := os.Stat("workdir"); os.IsNotExist(err) { return errView(fmt.Errorf("no results found - run a workflow first")), nil } - + // Open file browser or list recent runs return errView(fmt.Errorf("results viewer not yet implemented - check ./workdir manually")), nil } @@ -397,14 +397,14 @@ func (m MenuModel) cleanWorkdir() (tea.Model, tea.Cmd) { if err := os.RemoveAll("workdir"); err != nil { return errView(fmt.Errorf("failed to clean workdir: %w", err)), nil } - + // Also clean log files if logs, err := filepath.Glob("run-*.log"); err == nil { for _, log := range logs { os.Remove(log) } } - + return errView(fmt.Errorf("workdir cleaned successfully")), nil } diff --git a/internal/tui/model.go b/internal/tui/model.go index c7a9c6c..3ce40ce 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -161,4 +161,4 @@ func (m Model) renderChart() string { out += "\n" } return out -} \ No newline at end of file +} diff --git a/internal/tui/responsive.go b/internal/tui/responsive.go index cc463a5..6974845 100644 --- a/internal/tui/responsive.go +++ b/internal/tui/responsive.go @@ -9,11 +9,11 @@ import ( type ScreenSize int const ( - ScreenTiny ScreenSize = iota // < 80x24 (minimum) - ScreenSmall // 80x24 to 120x30 - ScreenMedium // 120x30 to 160x40 - ScreenLarge // 160x40 to 200x50 - ScreenXLarge // > 200x50 + ScreenTiny ScreenSize = iota // < 80x24 (minimum) + ScreenSmall // 80x24 to 120x30 + ScreenMedium // 120x30 to 160x40 + ScreenLarge // 160x40 to 200x50 + ScreenXLarge // > 200x50 ) // Breakpoints define responsive design thresholds @@ -46,23 +46,23 @@ var DefaultBreakpoints = Breakpoints{ // LayoutConfig defines responsive layout parameters type LayoutConfig struct { - ToolsPanelWidth float64 // Percentage of screen width - ToolsPanelHeight float64 // Percentage of screen height - HelpPanelHeight float64 // Percentage of screen height - InputPanelHeight float64 // Percentage of screen height - VisualPanelHeight float64 // Percentage of screen height - MinToolsWidth int // Minimum absolute width - MinHelpHeight int // Minimum absolute height - MinInputHeight int // Minimum absolute height - MinVisualHeight int // Minimum absolute height - MaxToolsEntries int // Maximum visible tool entries - MaxMermaidLines int // Maximum Mermaid preview lines - UseVerticalScroll bool // Enable vertical scrolling - UseHorizontalScroll bool // Enable horizontal scrolling - CompactMode bool // Use compact rendering - ShowDetailedHelp bool // Show detailed help text - ShowMatrixGrid bool // Show full matrix grid - ShowSubgraphDetails bool // Show subgraph information + ToolsPanelWidth float64 // Percentage of screen width + ToolsPanelHeight float64 // Percentage of screen height + HelpPanelHeight float64 // Percentage of screen height + InputPanelHeight float64 // Percentage of screen height + VisualPanelHeight float64 // Percentage of screen height + MinToolsWidth int // Minimum absolute width + MinHelpHeight int // Minimum absolute height + MinInputHeight int // Minimum absolute height + MinVisualHeight int // Minimum absolute height + MaxToolsEntries int // Maximum visible tool entries + MaxMermaidLines int // Maximum Mermaid preview lines + UseVerticalScroll bool // Enable vertical scrolling + UseHorizontalScroll bool // Enable horizontal scrolling + CompactMode bool // Use compact rendering + ShowDetailedHelp bool // Show detailed help text + ShowMatrixGrid bool // Show full matrix grid + ShowSubgraphDetails bool // Show subgraph information } // ResponsiveManager handles adaptive layout calculations @@ -215,27 +215,27 @@ func (rm *ResponsiveManager) GetLayoutConfig(width, height int) LayoutConfig { // CalculateLayout computes actual pixel dimensions for layout func (rm *ResponsiveManager) CalculateLayout(width, height int) LayoutDimensions { config := rm.GetLayoutConfig(width, height) - + // Calculate panel dimensions toolsWidth := max(int(float64(width)*config.ToolsPanelWidth), config.MinToolsWidth) inputWidth := width - toolsWidth - + helpHeight := max(int(float64(height)*config.HelpPanelHeight), config.MinHelpHeight) inputHeight := max(int(float64(height)*config.InputPanelHeight), config.MinInputHeight) visualHeight := height - inputHeight toolsHeight := height - helpHeight - + return LayoutDimensions{ - ToolsWidth: toolsWidth, - ToolsHeight: toolsHeight, - HelpWidth: toolsWidth, - HelpHeight: helpHeight, - InputWidth: inputWidth, - InputHeight: inputHeight, - VisualWidth: inputWidth, - VisualHeight: visualHeight, - Config: config, - ScreenSize: rm.DetectScreenSize(width, height), + ToolsWidth: toolsWidth, + ToolsHeight: toolsHeight, + HelpWidth: toolsWidth, + HelpHeight: helpHeight, + InputWidth: inputWidth, + InputHeight: inputHeight, + VisualWidth: inputWidth, + VisualHeight: visualHeight, + Config: config, + ScreenSize: rm.DetectScreenSize(width, height), } } @@ -281,7 +281,7 @@ func NewScrollManager() *ScrollManager { // UpdateBounds updates scrolling boundaries based on content size func (sm *ScrollManager) UpdateBounds(toolsCount, matrixWidth, matrixHeight, mermaidLines int, layout LayoutDimensions) { sm.state.MaxToolsOffset = max(0, toolsCount-layout.Config.MaxToolsEntries) - sm.state.MaxVisualOffsetX = max(0, matrixWidth-layout.VisualWidth/8) // Rough character width + sm.state.MaxVisualOffsetX = max(0, matrixWidth-layout.VisualWidth/8) // Rough character width sm.state.MaxVisualOffsetY = max(0, matrixHeight-layout.VisualHeight/2) // Rough line height } @@ -337,40 +337,40 @@ func (sm *ScrollManager) GetState() ScrollState { // StyleAdaptive creates adaptive styles based on screen size func StyleAdaptive(screenSize ScreenSize) AdaptiveStyles { base := lipgloss.NewStyle() - + switch screenSize { case ScreenTiny: return AdaptiveStyles{ - Border: base.Border(lipgloss.NormalBorder()), - Title: base.Bold(false), - Highlight: base.Foreground(lipgloss.Color("12")), - Muted: base.Foreground(lipgloss.Color("8")), - Error: base.Foreground(lipgloss.Color("9")), - Success: base.Foreground(lipgloss.Color("10")), - Padding: 0, - Margin: 0, + Border: base.Border(lipgloss.NormalBorder()), + Title: base.Bold(false), + Highlight: base.Foreground(lipgloss.Color("12")), + Muted: base.Foreground(lipgloss.Color("8")), + Error: base.Foreground(lipgloss.Color("9")), + Success: base.Foreground(lipgloss.Color("10")), + Padding: 0, + Margin: 0, } case ScreenSmall: return AdaptiveStyles{ - Border: base.Border(lipgloss.RoundedBorder()), - Title: base.Bold(true), - Highlight: base.Foreground(lipgloss.Color("14")), - Muted: base.Foreground(lipgloss.Color("8")), - Error: base.Foreground(lipgloss.Color("9")), - Success: base.Foreground(lipgloss.Color("10")), - Padding: 1, - Margin: 0, + Border: base.Border(lipgloss.RoundedBorder()), + Title: base.Bold(true), + Highlight: base.Foreground(lipgloss.Color("14")), + Muted: base.Foreground(lipgloss.Color("8")), + Error: base.Foreground(lipgloss.Color("9")), + Success: base.Foreground(lipgloss.Color("10")), + Padding: 1, + Margin: 0, } default: return AdaptiveStyles{ - Border: base.Border(lipgloss.RoundedBorder()), - Title: base.Bold(true).Underline(true), - Highlight: base.Foreground(lipgloss.Color("14")).Bold(true), - Muted: base.Foreground(lipgloss.Color("8")), - Error: base.Foreground(lipgloss.Color("9")).Bold(true), - Success: base.Foreground(lipgloss.Color("10")).Bold(true), - Padding: 1, - Margin: 1, + Border: base.Border(lipgloss.RoundedBorder()), + Title: base.Bold(true).Underline(true), + Highlight: base.Foreground(lipgloss.Color("14")).Bold(true), + Muted: base.Foreground(lipgloss.Color("8")), + Error: base.Foreground(lipgloss.Color("9")).Bold(true), + Success: base.Foreground(lipgloss.Color("10")).Bold(true), + Padding: 1, + Margin: 1, } } } @@ -443,4 +443,4 @@ func (vm *ViewportManager) GetScrollIndicators() (horizontal, vertical string) { vertical = fmt.Sprintf("ā–²\n%.0f%%\nā–¼", progress*100) } return -} \ No newline at end of file +} diff --git a/project.zip b/project.zip deleted file mode 100644 index 8b13789..0000000 --- a/project.zip +++ /dev/null @@ -1 +0,0 @@ -