diff --git a/dev b/dev index 9a37344..77d1c08 100755 --- a/dev +++ b/dev @@ -34,6 +34,8 @@ if [[ ! -f "$BINARY" ]] || [[ "main.go" -nt "$BINARY" ]]; then log "Building dev tool..." mkdir -p bin + go mod tidy + if go build -ldflags="-s -w" -o "$BINARY" main.go; then log "✅ Built: $BINARY" else diff --git a/go.mod b/go.mod index a4d0d1a..61b6236 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module marianozunino/dev go 1.24.1 + +require github.com/spf13/cobra v1.9.1 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ffae55e --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index cc963d4..c4b7a5c 100644 --- a/main.go +++ b/main.go @@ -10,43 +10,33 @@ import ( "sort" "strings" "time" + + "github.com/spf13/cobra" ) -// Colors for terminal output const ( Red = "\033[0;31m" Green = "\033[0;32m" Blue = "\033[0;34m" Yellow = "\033[0;33m" - NC = "\033[0m" // No Color + NC = "\033[0m" ) -// Global configuration type Config struct { - ScriptDir string - EnvFile string - RunsDir string - LogsDir string - DryRun bool - Verbose bool - Filters []string - Command string + RunsDir string + LogsDir string + DryRun bool + Verbose bool } -// Script represents an executable script type Script struct { Path string Name string Description string } -// ExecutionResult tracks script execution outcomes -type ExecutionResult struct { - Executed []string - Failed []string -} +var config Config -// Logging functions func log(msg string, args ...interface{}) { fmt.Printf(Green+"[RUN]"+NC+" "+msg+"\n", args...) } @@ -59,13 +49,11 @@ func errorLog(msg string, args ...interface{}) { fmt.Fprintf(os.Stderr, Red+"[ERROR]"+NC+" "+msg+"\n", args...) } -// Check if command exists in PATH func commandExists(cmd string) bool { _, err := exec.LookPath(cmd) return err == nil } -// Check for required and optional dependencies func checkDependencies() error { requiredTools := []string{"git", "find", "grep"} optionalTools := []string{"gitleaks"} @@ -73,14 +61,12 @@ func checkDependencies() error { log("Checking dependencies...") - // Check required tools for _, tool := range requiredTools { if !commandExists(tool) { missing = append(missing, tool) } } - // Check optional tools for _, tool := range optionalTools { if !commandExists(tool) { warn("Optional tool missing: %s (recommended for security scanning)", tool) @@ -89,7 +75,6 @@ func checkDependencies() error { } } - // Report missing required tools if len(missing) > 0 { errorLog("Missing required tools: %s", strings.Join(missing, ", ")) errorLog("Please install missing dependencies") @@ -100,7 +85,6 @@ func checkDependencies() error { return nil } -// Run security scan using gitleaks func runSecurityScan() error { log("Running security scan...") @@ -133,19 +117,16 @@ func runSecurityScan() error { return nil } -// Check if we're in a git repository func isGitRepo() bool { cmd := exec.Command("git", "rev-parse", "--git-dir") return cmd.Run() == nil } -// Check for uncommitted changes func hasUncommittedChanges() bool { cmd := exec.Command("git", "diff-index", "--quiet", "HEAD", "--") return cmd.Run() != nil } -// Get current git branch func getCurrentBranch() (string, error) { cmd := exec.Command("git", "branch", "--show-current") output, err := cmd.Output() @@ -155,28 +136,23 @@ func getCurrentBranch() (string, error) { return strings.TrimSpace(string(output)), nil } -// Handle git push workflow func handlePush() error { log("Preparing to push repository...") - // Check if we're in a git repository if !isGitRepo() { errorLog("Not in a git repository") return fmt.Errorf("not in git repo") } - // Check for uncommitted changes if hasUncommittedChanges() { warn("You have uncommitted changes") fmt.Println() - // Show git status cmd := exec.Command("git", "status", "--short") cmd.Stdout = os.Stdout cmd.Run() fmt.Println() - // Generate default commit message defaultMsg := fmt.Sprintf("dev: automated commit - %s", time.Now().Format("2006-01-02 15:04:05")) fmt.Print("Commit all changes? (Y/n): ") @@ -196,7 +172,6 @@ func handlePush() error { commitMsg = defaultMsg } - // Add and commit changes if err := exec.Command("git", "add", ".").Run(); err != nil { return fmt.Errorf("failed to add changes: %v", err) } @@ -209,18 +184,15 @@ func handlePush() error { } } - // Run security scan if err := runSecurityScan(); err != nil { return err } - // Get current branch branch, err := getCurrentBranch() if err != nil { return fmt.Errorf("failed to get current branch: %v", err) } - // Push to origin log("Pushing branch '%s' to origin...", branch) cmd := exec.Command("git", "push", "origin", branch) @@ -233,41 +205,6 @@ func handlePush() error { return nil } -// Show help message -func showHelp(programName string) { - fmt.Printf(`%s - Development script runner - -Usage: %s [options] [arguments] - -COMMANDS: - run [filters...] Run scripts matching filters (or all if no filters) - push,u,ush Commit, scan for secrets, and push to git origin - ls, list List all available scripts - new Create a new script template - deps, check Check for required dependencies - help Show this help message - -RUN OPTIONS: - --dry Show what would run without executing - --verbose Show detailed output during execution - -EXAMPLES: - %s # Show this help - %s run # Run all scripts (with confirmation) - %s run docker # Run scripts containing 'docker' - %s run --dry api # Show what API scripts would run - %s ls # List all scripts - %s new backup # Create a new script called 'backup.sh' - %s push # Secure git push with secret scanning - -SECURITY: - The push command automatically scans for secrets using GitLeaks - before pushing to prevent accidental credential exposure. - -`, programName, programName, programName, programName, programName, programName, programName, programName, programName) -} - -// Get script description from comment func getDescription(scriptPath string) string { file, err := os.Open(scriptPath) if err != nil { @@ -288,7 +225,6 @@ func getDescription(scriptPath string) string { return "No description" } -// Check if script name matches any of the filters func matchesFilters(scriptName string, filters []string) bool { if len(filters) == 0 { return true @@ -304,16 +240,14 @@ func matchesFilters(scriptName string, filters []string) bool { return false } -// Find executable scripts in runs directory -func findScripts(runsDir string, filters []string) ([]Script, error) { +func findScripts(filters []string) ([]Script, error) { var scripts []Script - err := filepath.Walk(runsDir, func(path string, info os.FileInfo, err error) error { + err := filepath.Walk(config.RunsDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - // Check if file is executable if info.Mode().IsRegular() && (info.Mode()&0o111) != 0 { scriptName := filepath.Base(path) if matchesFilters(scriptName, filters) { @@ -330,7 +264,6 @@ func findScripts(runsDir string, filters []string) ([]Script, error) { return nil, err } - // Sort scripts by name sort.Slice(scripts, func(i, j int) bool { return scripts[i].Name < scripts[j].Name }) @@ -338,96 +271,21 @@ func findScripts(runsDir string, filters []string) ([]Script, error) { return scripts, nil } -// List all available scripts -func listScripts(runsDir string) error { - scripts, err := findScripts(runsDir, nil) - if err != nil { - return err - } - - if len(scripts) == 0 { - warn("No executable scripts found in %s", runsDir) - return nil - } - - fmt.Printf(Blue + "Available scripts:" + NC + "\n") - for _, script := range scripts { - fmt.Printf(" %s%s%s - %s\n", Green, script.Name, NC, script.Description) - } - fmt.Printf("\nTotal: %d scripts\n", len(scripts)) - return nil -} - -// Create a new script template -func createNewScript(runsDir, scriptName string) error { - if scriptName == "" { - return fmt.Errorf("script name required") - } - - // Ensure runs directory exists - if err := os.MkdirAll(runsDir, 0o755); err != nil { - return err - } - - // Add .sh extension if not provided - if !strings.HasSuffix(scriptName, ".sh") { - scriptName += ".sh" - } - - scriptPath := filepath.Join(runsDir, scriptName) - - // Check if script already exists - if _, err := os.Stat(scriptPath); err == nil { - return fmt.Errorf("script %s already exists", scriptName) - } - - // Create script template - template := fmt.Sprintf(`#!/usr/bin/env bash -# NAME: %s script - -set -euo pipefail - -# Add your script logic here -echo "Running %s script..." - -# Example: -# - Add your commands below -# - Use proper error handling -# - Add logging as needed - -echo "✅ %s completed successfully" -`, strings.TrimSuffix(scriptName, ".sh"), scriptName, strings.TrimSuffix(scriptName, ".sh")) - - if err := os.WriteFile(scriptPath, []byte(template), 0o755); err != nil { - return err - } - - log("✅ Created new script: %s", scriptPath) - log("Edit the script to add your commands") - return nil -} - -// Execute a single script -func executeScript(script Script, logsDir string, verbose bool) error { - logFile := filepath.Join(logsDir, strings.TrimSuffix(script.Name, filepath.Ext(script.Name))+".log") - +func executeScript(script Script, verbose bool) error { + logFile := filepath.Join(config.LogsDir, strings.TrimSuffix(script.Name, filepath.Ext(script.Name))+".log") cmd := exec.Command(script.Path) if verbose { - // Create log file logFileHandle, err := os.Create(logFile) if err != nil { return err } defer logFileHandle.Close() - // Use MultiWriter to write to both stdout and log file cmd.Stdout = io.MultiWriter(os.Stdout, logFileHandle) cmd.Stderr = io.MultiWriter(os.Stderr, logFileHandle) - return cmd.Run() } else { - // Only write to log file logFileHandle, err := os.Create(logFile) if err != nil { return err @@ -436,77 +294,10 @@ func executeScript(script Script, logsDir string, verbose bool) error { cmd.Stdout = logFileHandle cmd.Stderr = logFileHandle - return cmd.Run() } } -// Parse command line arguments -func parseArgs(args []string) (*Config, error) { - config := &Config{} - - // Get current working directory (equivalent to bash SCRIPT_DIR) - scriptDir, err := os.Getwd() - if err != nil { - return nil, err - } - config.ScriptDir = scriptDir - config.EnvFile = filepath.Join(config.ScriptDir, ".env") - config.RunsDir = filepath.Join(config.ScriptDir, "runs") - config.LogsDir = filepath.Join(config.ScriptDir, "logs") - - if len(args) == 0 { - return config, fmt.Errorf("help") - } - - i := 0 - // First argument should be the command - if i < len(args) { - arg := args[i] - switch arg { - case "--help", "help": - return config, fmt.Errorf("help") - case "run": - config.Command = "run" - i++ - case "push", "u", "ush": - config.Command = "push" - i++ - case "ls", "list": - config.Command = "list" - i++ - case "new": - config.Command = "new" - i++ - case "deps", "check": - config.Command = "deps" - i++ - default: - return nil, fmt.Errorf("unknown command: %s", arg) - } - } - - // Parse remaining arguments based on command - for i < len(args) { - arg := args[i] - switch arg { - case "--dry": - config.DryRun = true - case "--verbose": - config.Verbose = true - default: - if strings.HasPrefix(arg, "-") { - return nil, fmt.Errorf("unknown option: %s", arg) - } - config.Filters = append(config.Filters, arg) - } - i++ - } - - return config, nil -} - -// Confirm execution of all scripts func confirmExecution(scripts []Script) bool { fmt.Printf(Red + "⚠️ About to run ALL scripts:" + NC + "\n") for _, script := range scripts { @@ -522,73 +313,19 @@ func confirmExecution(scripts []Script) bool { return answer == "y" || answer == "yes" } -func main() { - const programName = "dev" +func initConfig() { + wd, _ := os.Getwd() + config.RunsDir = filepath.Join(wd, "runs") + config.LogsDir = filepath.Join(wd, "logs") +} - // Parse arguments - config, err := parseArgs(os.Args[1:]) - if err != nil { - if err.Error() == "help" { - showHelp(programName) - os.Exit(0) - } - errorLog("Error: %v", err) - fmt.Println() - showHelp(programName) - os.Exit(1) - } - - // Handle special commands - switch config.Command { - case "push": - if err := handlePush(); err != nil { - os.Exit(1) - } - os.Exit(0) - case "deps": - if err := checkDependencies(); err != nil { - os.Exit(1) - } - os.Exit(0) - case "list": - if err := listScripts(config.RunsDir); err != nil { - errorLog("Error listing scripts: %v", err) - os.Exit(1) - } - os.Exit(0) - case "new": - if len(config.Filters) == 0 { - errorLog("Script name required for 'new' command") - fmt.Printf("Usage: %s new \n", programName) - os.Exit(1) - } - scriptName := config.Filters[0] - if err := createNewScript(config.RunsDir, scriptName); err != nil { - errorLog("Error creating script: %v", err) - os.Exit(1) - } - os.Exit(0) - case "run": - // Continue with script execution logic below - case "": - // No command provided, show help - showHelp(programName) - os.Exit(0) - default: - errorLog("Unknown command: %s", config.Command) - showHelp(programName) - os.Exit(1) - } - - // Initialize runs directory if it doesn't exist (only for run command) +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 { - errorLog("Failed to create runs directory: %v", err) - os.Exit(1) + return err } - // Create example script exampleScript := filepath.Join(config.RunsDir, "example.sh") content := `#!/usr/bin/env bash # NAME: Example script @@ -596,82 +333,190 @@ 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 { - errorLog("Failed to create example script: %v", err) - os.Exit(1) + return err } log("✅ Created runs/ directory with example script") - log("Use '%s ls' to list scripts or '%s new ' to create a new one", programName, programName) - os.Exit(0) + log("Use 'dev ls' to list scripts or 'dev new ' to create a new one") + return nil } + return nil +} - // Create logs directory - if err := os.MkdirAll(config.LogsDir, 0o755); err != nil { - errorLog("Failed to create logs directory: %v", err) - os.Exit(1) - } +var rootCmd = &cobra.Command{ + Use: "dev", + Short: "Development script runner", + Long: "A simple tool to manage and run development scripts with security scanning", +} - // Find scripts to run - scripts, err := findScripts(config.RunsDir, config.Filters) - if err != nil { - errorLog("Error finding scripts: %v", err) - os.Exit(1) - } - - if len(scripts) == 0 { - if len(config.Filters) > 0 { - warn("No scripts match the given filters: %s", strings.Join(config.Filters, ", ")) - fmt.Printf("Use '%s ls' to see all available scripts\n", programName) - } else { - warn("No executable scripts found in %s", config.RunsDir) - fmt.Printf("Use '%s new ' to create a new script\n", programName) - } - os.Exit(0) - } - - // Confirm execution if running all scripts without dry run - if len(config.Filters) == 0 && !config.DryRun { - if !confirmExecution(scripts) { - fmt.Println("Cancelled") - os.Exit(0) - } - } - - // Execute scripts - result := ExecutionResult{} - - for _, script := range scripts { - if config.DryRun { - fmt.Printf(Blue+"[DRY]"+NC+" Would run: %s - %s\n", script.Name, script.Description) - continue +var runCmd = &cobra.Command{ + Use: "run [filters...]", + Short: "Run scripts matching filters (or all if no filters)", + RunE: func(cmd *cobra.Command, args []string) error { + if err := ensureRunsDir(); err != nil { + return err } - fmt.Printf("\n"+Blue+"▶"+NC+" Running: %s\n", script.Name) - if script.Description != "No description" { - fmt.Printf(" %s\n", script.Description) + if err := os.MkdirAll(config.LogsDir, 0o755); err != nil { + return err } - if err := executeScript(script, config.LogsDir, config.Verbose); err != nil { - errorLog("❌ %s failed (see %s)", script.Name, - filepath.Join(config.LogsDir, strings.TrimSuffix(script.Name, filepath.Ext(script.Name))+".log")) - result.Failed = append(result.Failed, script.Name) - } else { - log("✅ %s completed", script.Name) - result.Executed = append(result.Executed, script.Name) + scripts, err := findScripts(args) + if err != nil { + return err } - } - // Print summary - if !config.DryRun { - fmt.Printf("\n" + Blue + "═══ SUMMARY ═══" + NC + "\n") - fmt.Printf("✅ Completed: %d\n", len(result.Executed)) - if len(result.Failed) > 0 { - fmt.Printf("❌ Failed: %d\n", len(result.Failed)) - fmt.Printf("\n" + Red + "Failed scripts:" + NC + "\n") - for _, failed := range result.Failed { - fmt.Printf(" • %s\n", failed) + if len(scripts) == 0 { + if len(args) > 0 { + warn("No scripts match the given filters: %s", strings.Join(args, ", ")) + fmt.Println("Use 'dev ls' to see all available scripts") + } else { + warn("No executable scripts found in %s", config.RunsDir) + fmt.Println("Use 'dev new ' to create a new script") } - os.Exit(1) + return nil } + + if len(args) == 0 && !config.DryRun { + if !confirmExecution(scripts) { + fmt.Println("Cancelled") + return nil + } + } + + var executed, failed []string + + for _, script := range scripts { + if config.DryRun { + fmt.Printf(Blue+"[DRY]"+NC+" Would run: %s - %s\n", script.Name, script.Description) + continue + } + + fmt.Printf("\n"+Blue+"▶"+NC+" Running: %s\n", script.Name) + if script.Description != "No description" { + fmt.Printf(" %s\n", script.Description) + } + + if err := executeScript(script, config.Verbose); err != nil { + errorLog("❌ %s failed (see %s)", script.Name, + filepath.Join(config.LogsDir, strings.TrimSuffix(script.Name, filepath.Ext(script.Name))+".log")) + failed = append(failed, script.Name) + } else { + log("✅ %s completed", script.Name) + executed = append(executed, script.Name) + } + } + + if !config.DryRun { + fmt.Printf("\n" + Blue + "═══ SUMMARY ═══" + NC + "\n") + fmt.Printf("✅ Completed: %d\n", len(executed)) + if len(failed) > 0 { + fmt.Printf("❌ Failed: %d\n", len(failed)) + fmt.Printf("\n" + Red + "Failed scripts:" + NC + "\n") + for _, f := range failed { + fmt.Printf(" • %s\n", f) + } + return fmt.Errorf("some scripts failed") + } + } + return nil + }, +} + +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 listCmd = &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List all available scripts", + RunE: func(cmd *cobra.Command, args []string) error { + scripts, err := findScripts(nil) + if err != nil { + return err + } + + if len(scripts) == 0 { + warn("No executable scripts found in %s", config.RunsDir) + return nil + } + + fmt.Printf(Blue + "Available scripts:" + NC + "\n") + for _, script := range scripts { + fmt.Printf(" %s%s%s - %s\n", Green, script.Name, NC, script.Description) + } + fmt.Printf("\nTotal: %d scripts\n", len(scripts)) + 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 { + scriptName := args[0] + + if err := os.MkdirAll(config.RunsDir, 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 script + +set -euo pipefail + +echo "Running %s script..." + +# Add your commands here + +echo "✅ %s completed successfully" +`, strings.TrimSuffix(scriptName, ".sh"), scriptName, strings.TrimSuffix(scriptName, ".sh")) + + if err := os.WriteFile(scriptPath, []byte(template), 0o755); err != nil { + return err + } + + log("✅ Created new script: %s", scriptPath) + log("Edit the script to add your commands") + return nil + }, +} + +var depsCmd = &cobra.Command{ + Use: "deps", + Aliases: []string{"check"}, + Short: "Check for required dependencies", + RunE: func(cmd *cobra.Command, args []string) error { + return checkDependencies() + }, +} + +func main() { + initConfig() + + runCmd.Flags().BoolVar(&config.DryRun, "dry", false, "Show what would run without executing") + runCmd.Flags().BoolVarP(&config.Verbose, "verbose", "v", false, "Show detailed output during execution") + + rootCmd.AddCommand(runCmd, pushCmd, listCmd, newCmd, depsCmd) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) } }