diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..af2499a --- /dev/null +++ b/commands.go @@ -0,0 +1,259 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "dev", + Short: "Development script runner", + Long: "A CLI tool for managing and running development scripts", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, +} + +var runCmd = &cobra.Command{ + Use: "run [script-filters...] [-- script-args...]", + Short: "Run scripts matching filters (or all if no filters)", + Long: `Run scripts matching filters. Use -- to pass arguments to scripts. + +Scripts marked with "# REQUIRES: sudo" will automatically run with elevated privileges. + +Examples: + dev run tools/dev.sh # Run specific script + dev run tools/dev.sh -- arg1 arg2 # Run with arguments + dev run --verbose install -- --force # Run with flags +`, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if err := ensureRunsDir(); err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + scripts, err := findScripts(nil) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var completions []string + for _, script := range scripts { + desc := script.Desc + if script.RequiresSudo { + desc = desc + " [SUDO]" + } + completions = append(completions, fmt.Sprintf("%s\t%s", script.RelPath, desc)) + } + return completions, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + // Parse arguments manually to handle -- separator + var filters []string + var scriptArgs []string + + // Find "run" command position and -- separator in raw args + cmdPos := -1 + separatorPos := -1 + rawArgs := os.Args + + for i, arg := range rawArgs { + if arg == "run" { + cmdPos = i + break + } + } + + if cmdPos >= 0 { + for i := cmdPos + 1; i < len(rawArgs); i++ { + if rawArgs[i] == "--" { + separatorPos = i + break + } + } + } + + if separatorPos >= 0 { + // Get everything between 'run' and '--' as filters + filters = rawArgs[cmdPos+1 : separatorPos] + // Remove any flags from filters + for i := 0; i < len(filters); i++ { + if strings.HasPrefix(filters[i], "-") { + filters = append(filters[:i], filters[i+1:]...) + i-- + } + } + // Get everything after '--' as script args + scriptArgs = rawArgs[separatorPos+1:] + } else { + // No '--' separator found, consider all non-flag args as filters + for _, arg := range args { + if !strings.HasPrefix(arg, "-") { + filters = append(filters, arg) + } + } + } + + if err := ensureRunsDir(); err != nil { + return err + } + + scripts, err := findScripts(filters) + if err != nil { + return err + } + + if len(scripts) == 0 { + if len(filters) > 0 { + warn("No scripts match filters: %s", strings.Join(filters, ", ")) + } else { + warn("No scripts found. Use 'dev new ' to create one") + } + return nil + } + + for _, script := range scripts { + if config.DryRun { + argsStr := "" + if len(scriptArgs) > 0 { + argsStr = fmt.Sprintf(" with args: %s", strings.Join(scriptArgs, " ")) + } + sudoStr := "" + if script.RequiresSudo { + sudoStr = " (with sudo)" + } + log("Would run: %s%s%s", script.RelPath, argsStr, sudoStr) + } else { + if err := executeScript(script, scriptArgs, config.Verbose); err != nil { + errorLog("❌ %s failed", script.RelPath) + if !config.Verbose { + fmt.Printf(" Check log: %s\n", filepath.Join(config.LogsDir, strings.TrimSuffix(script.Name, filepath.Ext(script.Name))+".log")) + } + } else { + if script.RequiresSudo { + sudoLog("✅ %s completed (elevated)", script.RelPath) + } else { + log("✅ %s completed", script.RelPath) + } + } + } + } + return nil + }, +} + +var listCmd = &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List all available scripts", + RunE: func(cmd *cobra.Command, args []string) error { + if err := ensureRunsDir(); err != nil { + return err + } + + scripts, err := findScripts(nil) + if err != nil { + return err + } + + if len(scripts) == 0 { + warn("No scripts found in %s", config.RunsDir) + fmt.Println("Use 'dev new ' to create a new script") + return nil + } + + fmt.Printf("Available scripts in %s:\n\n", config.RunsDir) + for _, script := range scripts { + if script.RequiresSudo { + fmt.Printf(" %s%s%s - %s %s[SUDO]%s\n", + Blue, script.RelPath, NC, script.Desc, Purple, NC) + } else { + fmt.Printf(" %s%s%s - %s\n", + Blue, script.RelPath, NC, script.Desc) + } + } + + sudoCount := 0 + for _, script := range scripts { + if script.RequiresSudo { + sudoCount++ + } + } + + fmt.Printf("\nTotal: %d scripts", len(scripts)) + if sudoCount > 0 { + fmt.Printf(" (%d require elevated privileges)", sudoCount) + } + fmt.Println() + return nil + }, +} + +var newCmd = &cobra.Command{ + Use: "new ", + Short: "Create a new script template", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return createNewScript(args[0]) + }, +} + +var pushCmd = &cobra.Command{ + Use: "push", + Aliases: []string{"u", "ush"}, + Short: "Commit, scan for secrets, and push to git origin", + RunE: func(cmd *cobra.Command, args []string) error { + return handlePush() + }, +} + +var depsCmd = &cobra.Command{ + Use: "deps", + Short: "Install dependencies", + RunE: func(cmd *cobra.Command, args []string) error { + return checkDependencies() + }, +} + +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish]", + Short: "Generate completion script", + Long: `To load completions: + +Bash: + $ source <(dev completion bash) + + # To load completions for each session, execute once: + # Linux: + $ dev completion bash > /etc/bash_completion.d/dev + # macOS: + $ dev completion bash > $(brew --prefix)/etc/bash_completion.d/dev + +Zsh: + # If shell completion is not already enabled in your environment, + # you will need to enable it. You can execute the following once: + $ echo "autoload -U compinit; compinit" >> ~/.zshrc + + # To load completions for each session, execute once: + $ dev completion zsh > "${fpath[1]}/_dev" + + # You will need to start a new shell for this setup to take effect. +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return rootCmd.GenBashCompletion(os.Stdout) + case "zsh": + return rootCmd.GenZshCompletion(os.Stdout) + case "fish": + return rootCmd.GenFishCompletion(os.Stdout, true) + } + return nil + }, +} diff --git a/dev b/dev index db11bba..23d45cb 100755 --- a/dev +++ b/dev @@ -28,20 +28,54 @@ BINARY="bin/dev-linux-$ARCH" # Build if binary doesn't exist or source is newer if [[ ! -f "$BINARY" ]] || [[ "main.go" -nt "$BINARY" ]]; then + # Check Go version if ! command -v go &>/dev/null; then error "Go not installed. Install with: sudo pacman -S go" exit 1 fi + + GO_VERSION=$(go version | cut -d' ' -f3) + if [[ "$GO_VERSION" < "go1.18" ]]; then + error "Go version 1.18 or higher required. Current version: $GO_VERSION" + exit 1 + fi + + # Check for required dependencies + if ! command -v git &>/dev/null; then + error "Git not installed. Install with: sudo pacman -S git" + exit 1 + fi + + # Create build cache directory + mkdir -p .build-cache + log "Building dev tool..." mkdir -p bin + + # Initialize Go modules if needed + if [[ ! -f "go.mod" ]]; then + log "Initializing Go modules..." + go mod init dev + fi + go mod tidy - if go build -ldflags="-s -w" -o "$BINARY" main.go; then + + # Build with cache + if go build -ldflags="-s -w" -o "$BINARY" -gcflags="all=-trimpath=$PWD" -asmflags="all=-trimpath=$PWD"; then log "✅ Built: $BINARY" + find bin -type f -name "dev-linux-*" -not -name "$(basename "$BINARY")" -delete else error "Build failed" exit 1 fi fi -# Run the binary +if [[ ${1:-} == "clean" ]]; then + log "Cleaning up build artifacts..." + rm -rf .build-cache + rm -rf bin/*.old + exit 0 +fi + +# Run the binary with all arguments exec "$BINARY" "$@" diff --git a/git.go b/git.go new file mode 100644 index 0000000..86933f0 --- /dev/null +++ b/git.go @@ -0,0 +1,98 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +func isGitRepo() bool { + cmd := exec.Command("git", "rev-parse", "--git-dir") + return cmd.Run() == nil +} + +func hasUncommittedChanges() bool { + cmd := exec.Command("git", "diff-index", "--quiet", "HEAD", "--") + return cmd.Run() != nil +} + +func getCurrentBranch() (string, error) { + cmd := exec.Command("git", "branch", "--show-current") + output, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} + +func handlePush() error { + log("Preparing to push repository...") + + if !isGitRepo() { + errorLog("Not in a git repository") + return fmt.Errorf("not in git repo") + } + + if hasUncommittedChanges() { + warn("You have uncommitted changes") + fmt.Println() + + cmd := exec.Command("git", "status", "--short") + cmd.Stdout = os.Stdout + cmd.Run() + fmt.Println() + + defaultMsg := fmt.Sprintf("dev: automated commit - %s", time.Now().Format("2006-01-02 15:04:05")) + + fmt.Print("Commit all changes? (Y/n): ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + + if answer != "n" && answer != "no" { + fmt.Println() + fmt.Printf("Default: %s\n", defaultMsg) + fmt.Print("Custom commit message (or press Enter for default): ") + + commitMsg, _ := reader.ReadString('\n') + commitMsg = strings.TrimSpace(commitMsg) + + if commitMsg == "" { + commitMsg = defaultMsg + } + + if err := exec.Command("git", "add", ".").Run(); err != nil { + return fmt.Errorf("failed to add changes: %v", err) + } + + if err := exec.Command("git", "commit", "-m", commitMsg).Run(); err != nil { + return fmt.Errorf("failed to commit changes: %v", err) + } + + log("✓ Changes committed: %s", commitMsg) + } + } + + if err := runSecurityScan(); err != nil { + return err + } + + branch, err := getCurrentBranch() + if err != nil { + return fmt.Errorf("failed to get current branch: %v", err) + } + + log("Pushing branch '%s' to origin...", branch) + cmd := exec.Command("git", "push", "origin", branch) + + if err := cmd.Run(); err != nil { + errorLog("❌ Push failed") + return fmt.Errorf("push failed") + } + + log("✅ Successfully pushed to origin/%s", branch) + return nil +} diff --git a/logging.go b/logging.go new file mode 100644 index 0000000..5098c37 --- /dev/null +++ b/logging.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + "os" +) + +func log(msg string, args ...any) { + fmt.Printf(Green+"[RUN]"+NC+" "+msg+"\n", args...) +} + +func warn(msg string, args ...any) { + fmt.Printf(Yellow+"[WARN]"+NC+" "+msg+"\n", args...) +} + +func errorLog(msg string, args ...any) { + fmt.Fprintf(os.Stderr, Red+"[ERROR]"+NC+" "+msg+"\n", args...) +} + +func sudoLog(msg string, args ...any) { + fmt.Printf(Purple+"[SUDO]"+NC+" "+msg+"\n", args...) +} diff --git a/main.go b/main.go index d85e270..1c78ab6 100644 --- a/main.go +++ b/main.go @@ -1,748 +1,19 @@ package main import ( - "bufio" - "fmt" - "io" "os" - "os/exec" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/spf13/cobra" ) -const ( - Red = "\033[0;31m" - Green = "\033[0;32m" - Blue = "\033[0;34m" - Yellow = "\033[0;33m" - Purple = "\033[0;35m" // For elevated scripts - NC = "\033[0m" -) - -type Config struct { - RunsDir string - LogsDir string - DryRun bool - Verbose bool - Interactive bool -} - -type Script struct { - Path string // Full filesystem path - Name string // Just filename (e.g. "install.sh") - RelPath string // Relative path from runs/ (e.g. "tools/install.sh") - Desc string // Description from script comment - RequiresSudo bool // Whether script needs elevated privileges - RequiresInteractive bool // Whether script needs interactive input -} - -var config Config - -func log(msg string, args ...any) { - fmt.Printf(Green+"[RUN]"+NC+" "+msg+"\n", args...) -} - -func warn(msg string, args ...any) { - fmt.Printf(Yellow+"[WARN]"+NC+" "+msg+"\n", args...) -} - -func errorLog(msg string, args ...any) { - fmt.Fprintf(os.Stderr, Red+"[ERROR]"+NC+" "+msg+"\n", args...) -} - -func sudoLog(msg string, args ...any) { - fmt.Printf(Purple+"[SUDO]"+NC+" "+msg+"\n", args...) -} - -func commandExists(cmd string) bool { - _, err := exec.LookPath(cmd) - return err == nil -} - -func checkDependencies() error { - requiredTools := []string{"git", "find", "grep"} - optionalTools := []string{"gitleaks"} - var missing []string - - log("Checking dependencies...") - - for _, tool := range requiredTools { - if !commandExists(tool) { - missing = append(missing, tool) - } - } - - for _, tool := range optionalTools { - if !commandExists(tool) { - warn("Optional tool missing: %s (recommended for security scanning)", tool) - } else { - log("✓ Found: %s", tool) - } - } - - if len(missing) > 0 { - errorLog("Missing required tools: %s", strings.Join(missing, ", ")) - errorLog("Please install missing dependencies") - return fmt.Errorf("missing dependencies") - } - - log("✓ All required dependencies found") - return nil -} - -func runSecurityScan() error { - log("Running security scan...") - - if !commandExists("gitleaks") { - warn("GitLeaks not installed - skipping security scan") - warn("Install with: paru -S gitleaks") - fmt.Println() - - fmt.Print("Continue without security scan? (y/N): ") - reader := bufio.NewReader(os.Stdin) - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(strings.ToLower(answer)) - - if answer != "y" && answer != "yes" { - errorLog("Push cancelled for security") - return fmt.Errorf("security scan cancelled") - } - return nil - } - - log("Using GitLeaks for secret detection...") - cmd := exec.Command("gitleaks", "detect", "--verbose", "--exit-code", "1") - - if err := cmd.Run(); err != nil { - errorLog("❌ Secrets detected! Review before pushing.") - return fmt.Errorf("secrets detected") - } - - log("✅ No secrets detected") - return nil -} - -func isGitRepo() bool { - cmd := exec.Command("git", "rev-parse", "--git-dir") - return cmd.Run() == nil -} - -func hasUncommittedChanges() bool { - cmd := exec.Command("git", "diff-index", "--quiet", "HEAD", "--") - return cmd.Run() != nil -} - -func getCurrentBranch() (string, error) { - cmd := exec.Command("git", "branch", "--show-current") - output, err := cmd.Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(output)), nil -} - -func handlePush() error { - log("Preparing to push repository...") - - if !isGitRepo() { - errorLog("Not in a git repository") - return fmt.Errorf("not in git repo") - } - - if hasUncommittedChanges() { - warn("You have uncommitted changes") - fmt.Println() - - cmd := exec.Command("git", "status", "--short") - cmd.Stdout = os.Stdout - cmd.Run() - fmt.Println() - - defaultMsg := fmt.Sprintf("dev: automated commit - %s", time.Now().Format("2006-01-02 15:04:05")) - - fmt.Print("Commit all changes? (Y/n): ") - reader := bufio.NewReader(os.Stdin) - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(strings.ToLower(answer)) - - if answer != "n" && answer != "no" { - fmt.Println() - fmt.Printf("Default: %s\n", defaultMsg) - fmt.Print("Custom commit message (or press Enter for default): ") - - commitMsg, _ := reader.ReadString('\n') - commitMsg = strings.TrimSpace(commitMsg) - - if commitMsg == "" { - commitMsg = defaultMsg - } - - if err := exec.Command("git", "add", ".").Run(); err != nil { - return fmt.Errorf("failed to add changes: %v", err) - } - - if err := exec.Command("git", "commit", "-m", commitMsg).Run(); err != nil { - return fmt.Errorf("failed to commit changes: %v", err) - } - - log("✓ Changes committed: %s", commitMsg) - } - } - - if err := runSecurityScan(); err != nil { - return err - } - - branch, err := getCurrentBranch() - if err != nil { - return fmt.Errorf("failed to get current branch: %v", err) - } - - log("Pushing branch '%s' to origin...", branch) - cmd := exec.Command("git", "push", "origin", branch) - - if err := cmd.Run(); err != nil { - errorLog("❌ Push failed") - return fmt.Errorf("push failed") - } - - log("✅ Successfully pushed to origin/%s", branch) - return nil -} - -type ScriptMetadata struct { - RequiresSudo bool - RequiresInteractive bool -} - -func getScriptMetadata(scriptPath string) (string, ScriptMetadata) { - file, err := os.Open(scriptPath) - if err != nil { - return "No description", ScriptMetadata{} - } - defer file.Close() - - var desc string - scriptMetadata := ScriptMetadata{} - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - if strings.HasPrefix(line, "# NAME:") { - desc = strings.TrimSpace(strings.TrimPrefix(line, "# NAME:")) - } - - if strings.HasPrefix(line, "# REQUIRES:") { - scriptMetadata.RequiresSudo = strings.Contains(line, "sudo") - scriptMetadata.RequiresInteractive = strings.Contains(line, "interactive") - } - } - - if desc == "" { - desc = "No description" - } - - return desc, scriptMetadata -} - -func findScripts(filters []string) ([]Script, error) { - var scripts []Script - - err := filepath.Walk(config.RunsDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - if info.Mode().IsRegular() && (info.Mode()&0o111) != 0 { - // Get relative path from runs directory - relPath, err := filepath.Rel(config.RunsDir, path) - if err != nil { - return err - } - - // Skip hidden files and directories - if strings.HasPrefix(filepath.Base(path), ".") { - return nil - } - - scriptName := filepath.Base(path) - - if len(filters) == 0 || matchesFilters(relPath, scriptName, filters) { - desc, metaData := getScriptMetadata(path) - - scripts = append(scripts, Script{ - Path: path, - Name: scriptName, - RelPath: relPath, - Desc: desc, - RequiresSudo: metaData.RequiresSudo, - RequiresInteractive: metaData.RequiresInteractive, - }) - } - } - return nil - }) - - sort.Slice(scripts, func(i, j int) bool { - return scripts[i].RelPath < scripts[j].RelPath - }) - - return scripts, err -} - -func matchesFilters(relPath, scriptName string, filters []string) bool { - for _, filter := range filters { - // Normalize paths for comparison (remove .sh extension from filter if present) - normalizedFilter := strings.TrimSuffix(filter, ".sh") - normalizedRelPath := strings.TrimSuffix(relPath, ".sh") - normalizedScriptName := strings.TrimSuffix(scriptName, ".sh") - - // Check exact matches - if normalizedRelPath == normalizedFilter || normalizedScriptName == normalizedFilter { - return true - } - - // Check if filter matches the relative path or script name (case insensitive) - filterLower := strings.ToLower(normalizedFilter) - relPathLower := strings.ToLower(normalizedRelPath) - scriptNameLower := strings.ToLower(normalizedScriptName) - - if strings.Contains(relPathLower, filterLower) || strings.Contains(scriptNameLower, filterLower) { - return true - } - - // Check directory match (e.g., "tools" matches "tools/install.sh") - if strings.HasPrefix(relPathLower, filterLower+"/") { - return true - } - } - return false -} - -func executeScript(script Script, args []string, verbose bool) error { - logFile := filepath.Join(config.LogsDir, strings.TrimSuffix(script.Name, filepath.Ext(script.Name))+".log") - - var cmd *exec.Cmd - if script.RequiresSudo { - sudoLog("Script requires elevated privileges: %s", script.RelPath) - if !commandExists("sudo") { - errorLog("sudo command not found - cannot run elevated script") - return fmt.Errorf("sudo not available") - } - // Use -S to read password from stdin, and preserve environment - fullArgs := append([]string{"-S", "-E", script.Path}, args...) - cmd = exec.Command("sudo", fullArgs...) - sudoLog("Running with sudo: %s", strings.Join(append([]string{script.Path}, args...), " ")) - } else { - cmd = exec.Command(script.Path, args...) - } - - if config.Interactive || script.RequiresInteractive { - cmd.Stdin = os.Stdin - } - - logFileHandle, err := os.Create(logFile) - if err != nil { - return err - } - defer logFileHandle.Close() - - showOutput := verbose || config.Interactive || script.RequiresInteractive - if showOutput { - cmd.Stdout = io.MultiWriter(os.Stdout, logFileHandle) - cmd.Stderr = io.MultiWriter(os.Stderr, logFileHandle) - } else { - cmd.Stdout = logFileHandle - cmd.Stderr = logFileHandle - } - - return cmd.Run() -} - -func createNewScript(scriptName string) error { - scriptPath := filepath.Join(config.RunsDir, scriptName) - - dirPath := filepath.Dir(scriptPath) - - if err := os.MkdirAll(dirPath, 0o755); err != nil { - return err - } - - if !strings.HasSuffix(scriptName, ".sh") { - scriptName += ".sh" - scriptPath = filepath.Join(config.RunsDir, scriptName) - } - - if _, err := os.Stat(scriptPath); err == nil { - return fmt.Errorf("script %s already exists", scriptName) - } - - template := fmt.Sprintf(`#!/usr/bin/env bash -# NAME: %s -# REQUIRES: sudo interactive - -set -euo pipefail - -# Source common functions -source "$(dirname "$0")/../common.sh" || { - echo "[ERROR] Could not source common.sh" >&2 - exit 1 -} - -check_requirements() { - # Check required commands - local required_commands=() - # Add your required commands here - # required_commands=(git curl wget) - - for cmd in "${required_commands[@]}"; do - if ! command_exists "$cmd"; then - log_error "Required command not found: $cmd" - exit 1 - fi - done -} - -main() { - init_script - - check_requirements - - # Your main script logic goes here - - - # Example operations: - # if ! confirm "Proceed with operation?"; then - # log_info "Operation cancelled" - # finish_script 0 - # fi - - # Add your implementation here - - finish_script 0 -} - -main "$@" -`, strings.TrimSuffix(filepath.Base(scriptName), ".sh")) - - err := os.WriteFile(scriptPath, []byte(template), 0o755) - if err != nil { - return err - } - - log("✅ Created script: %s", scriptPath) - return nil -} - func initConfig() { - wd, _ := os.Getwd() - config.RunsDir = filepath.Join(wd, "runs") - config.LogsDir = filepath.Join(wd, "logs") -} - -func ensureRunsDir() error { - if _, err := os.Stat(config.RunsDir); os.IsNotExist(err) { - log("Creating runs directory at: %s", config.RunsDir) - if err := os.MkdirAll(config.RunsDir, 0o755); err != nil { - return err - } - - exampleScript := filepath.Join(config.RunsDir, "example.sh") - content := `#!/usr/bin/env bash -# NAME: Example script -echo "Hello from runs/example.sh!" -echo "Edit this script or add your own to runs/" -` - if err := os.WriteFile(exampleScript, []byte(content), 0o755); err != nil { - return err - } - - sudoExampleScript := filepath.Join(config.RunsDir, "system-info.sh") - sudoContent := `#!/usr/bin/env bash -# NAME: System information collector -# REQUIRES: sudo - -set -euo pipefail - -echo "Collecting system information (requires elevated privileges)..." - -echo "=== Disk Usage ===" -df -h - -echo "=== Memory Info ===" -free -h - -echo "=== System Logs (last 10 lines) ===" -tail -n 10 /var/log/syslog 2>/dev/null || tail -n 10 /var/log/messages 2>/dev/null || echo "No accessible system logs" - -echo "✅ System info collection completed" -` - if err := os.WriteFile(sudoExampleScript, []byte(sudoContent), 0o755); err != nil { - return err - } - - log("✅ Created runs/ directory with example scripts") - return nil + config = Config{ + DryRun: false, + Verbose: false, + Interactive: false, + LogsDir: "./logs", + RunsDir: "./runs", } - return nil -} -var rootCmd = &cobra.Command{ - Use: "dev", - Short: "Development script runner", - Long: "A CLI tool for managing and running development scripts", - RunE: func(cmd *cobra.Command, args []string) error { - // Default behavior: list scripts if no subcommand - return listCmd.RunE(cmd, args) - }, -} - -var runCmd = &cobra.Command{ - Use: "run [filters...] [-- script-args...]", - Short: "Run scripts matching filters (or all if no filters)", - Long: `Run scripts matching filters. Use -- to pass arguments to scripts. - -Scripts marked with "# REQUIRES: sudo" will automatically run with elevated privileges. - -Examples: - dev run tools/dev.sh # Run specific script - dev run tools/dev.sh -- arg1 arg2 # Run with arguments - dev run --verbose install -- --force # Run with flags - dev run system-info # Runs with sudo if marked as required`, - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - scripts, err := findScripts(nil) - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - var completions []string - for _, script := range scripts { - if strings.HasPrefix(script.Name, toComplete) || strings.HasPrefix(script.RelPath, toComplete) { - desc := script.Desc - if script.RequiresSudo { - desc = desc + " [SUDO]" - } - completions = append(completions, fmt.Sprintf("%s\t%s", script.RelPath, desc)) - } - } - return completions, cobra.ShellCompDirectiveNoFileComp - }, - RunE: func(cmd *cobra.Command, args []string) error { - // Parse arguments manually to handle -- separator - var filters []string - var scriptArgs []string - - // Find "run" command position and -- separator in raw args - rawArgs := os.Args[1:] // Skip program name - runIndex := -1 - dashDashIndex := -1 - - for i, arg := range rawArgs { - if arg == "run" { - runIndex = i - } - if arg == "--" && runIndex >= 0 { - dashDashIndex = i - break - } - } - - if dashDashIndex >= 0 { - for i := runIndex + 1; i < dashDashIndex; i++ { - arg := rawArgs[i] - if !strings.HasPrefix(arg, "-") { - filters = append(filters, arg) - } - } - scriptArgs = rawArgs[dashDashIndex+1:] - } else { - filters = args - scriptArgs = []string{} - } - - if err := ensureRunsDir(); err != nil { - return err - } - - if err := os.MkdirAll(config.LogsDir, 0o755); err != nil { - return err - } - - scripts, err := findScripts(filters) - if err != nil { - return err - } - - if len(scripts) == 0 { - if len(filters) > 0 { - warn("No scripts match filters: %s", strings.Join(filters, ", ")) - } else { - warn("No scripts found. Use 'dev new ' to create one") - } - return nil - } - - for _, script := range scripts { - if config.DryRun { - argsStr := "" - if len(scriptArgs) > 0 { - argsStr = fmt.Sprintf(" with args: %s", strings.Join(scriptArgs, " ")) - } - sudoStr := "" - if script.RequiresSudo { - sudoStr = " [SUDO]" - } - fmt.Printf("[DRY] Would run: %s - %s%s%s\n", script.RelPath, script.Desc, sudoStr, argsStr) - continue - } - - argsDisplay := "" - if len(scriptArgs) > 0 { - argsDisplay = fmt.Sprintf(" with args: %s", strings.Join(scriptArgs, " ")) - } - - sudoDisplay := "" - if script.RequiresSudo { - sudoDisplay = " [ELEVATED]" - } - - fmt.Printf("Running: %s%s%s\n", script.RelPath, sudoDisplay, argsDisplay) - - if err := executeScript(script, scriptArgs, config.Verbose); err != nil { - errorLog("❌ %s failed", script.RelPath) - if !config.Verbose { - fmt.Printf(" Check log: %s\n", filepath.Join(config.LogsDir, strings.TrimSuffix(script.Name, filepath.Ext(script.Name))+".log")) - } - } else { - if script.RequiresSudo { - sudoLog("✅ %s completed (elevated)", script.RelPath) - } else { - log("✅ %s completed", script.RelPath) - } - } - } - return nil - }, -} - -var listCmd = &cobra.Command{ - Use: "ls", - Aliases: []string{"list"}, - Short: "List all available scripts", - RunE: func(cmd *cobra.Command, args []string) error { - if err := ensureRunsDir(); err != nil { - return err - } - - scripts, err := findScripts(nil) - if err != nil { - return err - } - - if len(scripts) == 0 { - warn("No scripts found in %s", config.RunsDir) - fmt.Println("Use 'dev new ' to create a new script") - return nil - } - - fmt.Printf("Available scripts in %s:\n\n", config.RunsDir) - for _, script := range scripts { - if script.RequiresSudo { - fmt.Printf(" %s%s%s - %s %s[SUDO]%s\n", - Blue, script.RelPath, NC, script.Desc, Purple, NC) - } else { - fmt.Printf(" %s%s%s - %s\n", - Blue, script.RelPath, NC, script.Desc) - } - } - - sudoCount := 0 - for _, script := range scripts { - if script.RequiresSudo { - sudoCount++ - } - } - - fmt.Printf("\nTotal: %d scripts", len(scripts)) - if sudoCount > 0 { - fmt.Printf(" (%d require elevated privileges)", sudoCount) - } - fmt.Println() - return nil - }, -} - -var newCmd = &cobra.Command{ - Use: "new ", - Short: "Create a new script template", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return createNewScript(args[0]) - }, -} - -var pushCmd = &cobra.Command{ - Use: "push", - Aliases: []string{"u", "ush"}, - Short: "Commit, scan for secrets, and push to git origin", - RunE: func(cmd *cobra.Command, args []string) error { - return handlePush() - }, -} - -var depsCmd = &cobra.Command{ - Use: "deps", - Short: "Install dependencies", - RunE: func(cmd *cobra.Command, args []string) error { - return checkDependencies() - }, -} - -var completionCmd = &cobra.Command{ - Use: "completion [bash|zsh|fish|powershell]", - Short: "Generate completion script", - Long: `To load completions: - -Bash: - $ source <(dev completion bash) - - # To load completions for each session, execute once: - # Linux: - $ dev completion bash > /etc/bash_completion.d/dev - # macOS: - $ dev completion bash > $(brew --prefix)/etc/bash_completion.d/dev - -Zsh: - # If shell completion is not already enabled in your environment, - # you will need to enable it. You can execute the following once: - $ echo "autoload -U compinit; compinit" >> ~/.zshrc - - # To load completions for each session, execute once: - $ dev completion zsh > "${fpath[1]}/_dev" - - # You will need to start a new shell for this setup to take effect. -`, - DisableFlagsInUseLine: true, - ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - RunE: func(cmd *cobra.Command, args []string) error { - switch args[0] { - case "bash": - return rootCmd.GenBashCompletion(os.Stdout) - case "zsh": - return rootCmd.GenZshCompletion(os.Stdout) - case "fish": - return rootCmd.GenFishCompletion(os.Stdout, true) - case "powershell": - return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout) - } - return nil - }, + os.MkdirAll("logs", 0o755) } func main() { diff --git a/scripts.go b/scripts.go new file mode 100644 index 0000000..77e8cb2 --- /dev/null +++ b/scripts.go @@ -0,0 +1,243 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +func getScriptMetadata(scriptPath string) (string, bool) { + file, err := os.Open(scriptPath) + if err != nil { + return "No description", false + } + defer file.Close() + + var desc string + var requiresSudo bool + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if strings.HasPrefix(line, "# NAME:") { + desc = strings.TrimSpace(strings.TrimPrefix(line, "# NAME:")) + } + + if strings.HasPrefix(line, "# REQUIRES: sudo") || + strings.HasPrefix(line, "# REQUIRES:sudo") || + strings.HasPrefix(line, "# ELEVATED: true") || + strings.HasPrefix(line, "# ELEVATED:true") || + strings.HasPrefix(line, "# SUDO: true") || + strings.HasPrefix(line, "# SUDO:true") { + requiresSudo = true + } + } + + if desc == "" { + desc = "No description" + } + + return desc, requiresSudo +} + +func findScripts(filters []string) ([]Script, error) { + var scripts []Script + + err := filepath.Walk(config.RunsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.Mode().IsRegular() && (info.Mode()&0o111) != 0 { + // Get relative path from runs directory + relPath, err := filepath.Rel(config.RunsDir, path) + if err != nil { + return err + } + + // Skip hidden files and directories + if strings.HasPrefix(filepath.Base(path), ".") { + return nil + } + + scriptName := filepath.Base(path) + + if len(filters) == 0 || matchesFilters(relPath, scriptName, filters) { + desc, requiresSudo := getScriptMetadata(path) + + scripts = append(scripts, Script{ + Path: path, + Name: scriptName, + RelPath: relPath, + Desc: desc, + RequiresSudo: requiresSudo, + }) + } + } + return nil + }) + + sort.Slice(scripts, func(i, j int) bool { + return scripts[i].RelPath < scripts[j].RelPath + }) + + return scripts, err +} + +func matchesFilters(relPath, scriptName string, filters []string) bool { + for _, filter := range filters { + // Normalize paths for comparison (remove .sh extension from filter if present) + normalizedFilter := strings.TrimSuffix(filter, ".sh") + normalizedRelPath := strings.TrimSuffix(relPath, ".sh") + normalizedScriptName := strings.TrimSuffix(scriptName, ".sh") + + // Check exact matches + if normalizedRelPath == normalizedFilter || normalizedScriptName == normalizedFilter { + return true + } + + // Check if filter matches the relative path or script name (case insensitive) + filterLower := strings.ToLower(normalizedFilter) + relPathLower := strings.ToLower(normalizedRelPath) + scriptNameLower := strings.ToLower(normalizedScriptName) + + if strings.Contains(relPathLower, filterLower) || strings.Contains(scriptNameLower, filterLower) { + return true + } + + // Check directory match (e.g., "tools" matches "tools/install.sh") + if strings.HasPrefix(relPathLower, filterLower+"/") { + return true + } + } + return false +} + +func executeScript(script Script, args []string, verbose bool) error { + logFile := filepath.Join(config.LogsDir, strings.TrimSuffix(script.Name, filepath.Ext(script.Name))+".log") + + var cmd *exec.Cmd + if script.RequiresSudo { + sudoLog("Script requires elevated privileges: %s", script.RelPath) + if !commandExists("sudo") { + errorLog("sudo command not found - cannot run elevated script") + return fmt.Errorf("sudo not available") + } + fullArgs := append([]string{script.Path}, args...) + cmd = exec.Command("sudo", fullArgs...) + sudoLog("Running with sudo: %s", strings.Join(append([]string{script.Path}, args...), " ")) + } else { + cmd = exec.Command(script.Path, args...) + } + + if config.Interactive { + cmd.Stdin = os.Stdin + } + + logFileHandle, err := os.Create(logFile) + if err != nil { + return err + } + defer logFileHandle.Close() + + showOutput := verbose || config.Interactive + if showOutput { + cmd.Stdout = io.MultiWriter(os.Stdout, logFileHandle) + cmd.Stderr = io.MultiWriter(os.Stderr, logFileHandle) + } else { + cmd.Stdout = logFileHandle + cmd.Stderr = logFileHandle + } + + return cmd.Run() +} + +func createNewScript(scriptName string) error { + scriptPath := filepath.Join(config.RunsDir, scriptName) + + dirPath := filepath.Dir(scriptPath) + + if err := os.MkdirAll(dirPath, 0o755); err != nil { + return err + } + + if !strings.HasSuffix(scriptName, ".sh") { + scriptName += ".sh" + } + + if _, err := os.Stat(scriptPath); err == nil { + return fmt.Errorf("script %s already exists", scriptName) + } + + template := fmt.Sprintf(`#!/usr/bin/env bash +# NAME: %s script +# REQUIRES: sudo + +set -euo pipefail + +echo "Running %s script..." + +echo "✅ %s completed successfully" +`, strings.TrimSuffix(scriptName, ".sh"), scriptName, strings.TrimSuffix(scriptName, ".sh")) + + err := os.WriteFile(scriptPath, []byte(template), 0o755) + if err != nil { + return err + } + + log("✅ Created script: %s", scriptPath) + return nil +} + +func ensureRunsDir() error { + if _, err := os.Stat(config.RunsDir); os.IsNotExist(err) { + log("Creating runs directory at: %s", config.RunsDir) + if err := os.MkdirAll(config.RunsDir, 0o755); err != nil { + return err + } + + exampleScript := filepath.Join(config.RunsDir, "example.sh") + content := `#!/usr/bin/env bash +# NAME: Example script +echo "Hello from runs/example.sh!" +echo "Edit this script or add your own to runs/" +` + if err := os.WriteFile(exampleScript, []byte(content), 0o755); err != nil { + return err + } + + sudoExampleScript := filepath.Join(config.RunsDir, "system-info.sh") + sudoContent := `#!/usr/bin/env bash +# NAME: System information collector +# REQUIRES: sudo + +set -euo pipefail + +echo "Collecting system information (requires elevated privileges)..." + +echo "=== Disk Usage ===" +df -h + +echo "=== Memory Info ===" +free -h + +echo "=== System Logs (last 10 lines) ===" +tail -n 10 /var/log/syslog 2>/dev/null || tail -n 10 /var/log/messages 2>/dev/null || echo "No accessible system logs" + +echo "✅ System info collection completed" +` + if err := os.WriteFile(sudoExampleScript, []byte(sudoContent), 0o755); err != nil { + return err + } + + log("✅ Created runs/ directory with example scripts") + return nil + } + return nil +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..6355faa --- /dev/null +++ b/types.go @@ -0,0 +1,28 @@ +package main + +const ( + Red = "\033[0;31m" + Green = "\033[0;32m" + Blue = "\033[0;34m" + Yellow = "\033[0;33m" + Purple = "\033[0;35m" // For elevated scripts + NC = "\033[0m" +) + +type Config struct { + LogsDir string + RunsDir string + DryRun bool + Verbose bool + Interactive bool +} + +type Script struct { + Path string // Full filesystem path + Name string // Just filename (e.g. "install.sh") + RelPath string // Relative path from runs/ (e.g. "tools/install.sh") + Desc string // Description from script comment + RequiresSudo bool // Whether script needs elevated privileges +} + +var config Config diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..5eb5bf6 --- /dev/null +++ b/utils.go @@ -0,0 +1,77 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strings" +) + +func commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +func checkDependencies() error { + requiredTools := []string{"git", "find", "grep"} + optionalTools := []string{"gitleaks"} + var missing []string + + log("Checking dependencies...") + + for _, tool := range requiredTools { + if !commandExists(tool) { + missing = append(missing, tool) + } + } + + for _, tool := range optionalTools { + if !commandExists(tool) { + warn("Optional tool missing: %s (recommended for security scanning)", tool) + } else { + log("✓ Found: %s", tool) + } + } + + if len(missing) > 0 { + errorLog("Missing required tools: %s", strings.Join(missing, ", ")) + errorLog("Please install missing dependencies") + return fmt.Errorf("missing dependencies") + } + + log("✓ All required dependencies found") + return nil +} + +func runSecurityScan() error { + log("Running security scan...") + + if !commandExists("gitleaks") { + warn("GitLeaks not installed - skipping security scan") + warn("Install with: paru -S gitleaks") + fmt.Println() + + fmt.Print("Continue without security scan? (y/N): ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + + if answer != "y" && answer != "yes" { + errorLog("Push cancelled for security") + return fmt.Errorf("security scan cancelled") + } + return nil + } + + log("Using GitLeaks for secret detection...") + cmd := exec.Command("gitleaks", "detect", "--verbose", "--exit-code", "1") + + if err := cmd.Run(); err != nil { + errorLog("❌ Secrets detected! Review before pushing.") + return fmt.Errorf("secrets detected") + } + + log("✅ No secrets detected") + return nil +}