Git Repository

Stackyard

Stackyard scannt lokale Entwicklungsordner und bündelt Projekte, Git-Status, TODOs, Toolchains, Docker-Compose-Setups, Datei- und Launcher-Funktionen sowie Operations-Informationen in einer lokalen WebUI.

Projektseite ↗
HTTPShttps://zanvex.de/git/stackyard.git

internal/operations/operations.go

Zum Verzeichnis
package operations

import (
	"bufio"
	"context"
	"fmt"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"gopkg.in/yaml.v3"

	"stackyard/internal/project"
	"stackyard/internal/todo"
)

type Manifest struct {
	Description  string           `yaml:"description"`
	Provider     string           `yaml:"provider"`
	Dependencies []string         `yaml:"dependencies"`
	Actions      []Action         `yaml:"actions"`
	Services     []ServiceProfile `yaml:"services"`
}

type Action struct {
	Name string `yaml:"name"`
	Kind string `yaml:"kind"`
}

type ServiceProfile struct {
	Name       string `yaml:"name"`
	Port       int    `yaml:"port"`
	URL        string `yaml:"url"`
	HealthPath string `yaml:"health_path"`
}

type PortStatus struct {
	ProjectID   string
	ProjectName string
	Name        string
	Port        int
	URL         string
	Reachable   bool
	Healthy     bool
	Detail      string
}

type TodoItem struct {
	ProjectID   string
	ProjectName string
	Path        string
	Line        int
	Text        string
	Done        bool
}

var allowedActions = map[string][]string{
	"go-test":  {"go", "test", "./..."},
	"go-build": {"go", "build", "./..."},
	"go-run":   {"go", "run", "."},
}

func IsLongRunningAction(kind string) bool {
	return kind == "go-run"
}

func LoadManifest(projectDir string) Manifest {
	data, err := os.ReadFile(filepath.Join(projectDir, ".stackyard", "project.yaml"))
	if err != nil {
		return Manifest{}
	}
	var manifest Manifest
	if yaml.Unmarshal(data, &manifest) != nil {
		return Manifest{}
	}
	return manifest
}

func ValidateAction(kind string) error {
	if _, ok := allowedActions[kind]; !ok {
		return fmt.Errorf("unsupported action kind %q", kind)
	}
	return nil
}

func RunAction(ctx context.Context, projectDir, kind string) (string, error) {
	args, ok := allowedActions[kind]
	if !ok {
		return "", fmt.Errorf("unsupported action kind %q", kind)
	}
	ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
	defer cancel()
	cmd := exec.CommandContext(ctx, args[0], args[1:]...)
	cmd.Dir = projectDir
	out, err := cmd.CombinedOutput()
	if len(out) > 64<<10 {
		out = out[len(out)-(64<<10):]
	}
	if err != nil {
		return string(out), fmt.Errorf("%s: %w", kind, err)
	}
	return string(out), nil
}

func CollectPorts(ctx context.Context, projects []project.Project) []PortStatus {
	var values []PortStatus
	client := &http.Client{Timeout: 800 * time.Millisecond}
	for _, current := range projects {
		manifest := LoadManifest(current.Path)
		profiles := manifest.Services
		if len(profiles) == 0 {
			for _, port := range current.Ports {
				profiles = append(profiles, ServiceProfile{Name: "Detected", Port: port, URL: "http://127.0.0.1:" + strconv.Itoa(port)})
			}
		}
		for _, service := range profiles {
			if service.Port <= 0 || service.Port > 65535 {
				continue
			}
			value := PortStatus{ProjectID: current.ID, ProjectName: current.Name, Name: service.Name, Port: service.Port, URL: service.URL}
			conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(service.Port)), 250*time.Millisecond)
			if err == nil {
				value.Reachable = true
				_ = conn.Close()
			}
			if value.Reachable && service.URL != "" && isLoopbackURL(service.URL) {
				healthURL := strings.TrimRight(service.URL, "/") + service.HealthPath
				req, _ := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
				if response, requestErr := client.Do(req); requestErr == nil {
					value.Healthy = response.StatusCode >= 200 && response.StatusCode < 400
					value.Detail = response.Status
					_ = response.Body.Close()
				}
			} else if service.URL != "" && !isLoopbackURL(service.URL) {
				value.Detail = "Externe URL blockiert"
				value.URL = ""
			}
			values = append(values, value)
		}
	}
	return values
}

func isLoopbackURL(raw string) bool {
	parsed, err := url.Parse(raw)
	if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
		return false
	}
	host := strings.ToLower(parsed.Hostname())
	return host == "localhost" || host == "127.0.0.1" || host == "::1"
}

func CollectTodos(projects []project.Project) []TodoItem {
	var values []TodoItem
	for _, current := range projects {
		path, body, err := todo.Read(current.Path)
		if err != nil || body == "" {
			continue
		}
		scanner := bufio.NewScanner(strings.NewReader(body))
		line := 0
		for scanner.Scan() {
			line++
			text := strings.TrimSpace(scanner.Text())
			done := strings.HasPrefix(strings.ToLower(text), "- [x]")
			if !done && !strings.HasPrefix(text, "- [ ]") {
				continue
			}
			values = append(values, TodoItem{ProjectID: current.ID, ProjectName: current.Name, Path: path, Line: line, Text: strings.TrimSpace(text[5:]), Done: done})
		}
	}
	return values
}