main.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. package main
  2. import (
  3. "bufio"
  4. "fmt"
  5. "io"
  6. "os"
  7. "os/exec"
  8. "path/filepath"
  9. "sort"
  10. "strings"
  11. "time"
  12. "github.com/spf13/cobra"
  13. )
  14. const (
  15. Red = "\033[0;31m"
  16. Green = "\033[0;32m"
  17. Blue = "\033[0;34m"
  18. Yellow = "\033[0;33m"
  19. NC = "\033[0m"
  20. )
  21. type Config struct {
  22. RunsDir string
  23. LogsDir string
  24. DryRun bool
  25. Verbose bool
  26. }
  27. type Script struct {
  28. Path string // Full filesystem path
  29. Name string // Just filename (e.g. "install.sh")
  30. RelPath string // Relative path from runs/ (e.g. "tools/install.sh")
  31. Desc string // Description from script comment
  32. }
  33. var config Config
  34. func log(msg string, args ...any) {
  35. fmt.Printf(Green+"[RUN]"+NC+" "+msg+"\n", args...)
  36. }
  37. func warn(msg string, args ...any) {
  38. fmt.Printf(Yellow+"[WARN]"+NC+" "+msg+"\n", args...)
  39. }
  40. func errorLog(msg string, args ...any) {
  41. fmt.Fprintf(os.Stderr, Red+"[ERROR]"+NC+" "+msg+"\n", args...)
  42. }
  43. func commandExists(cmd string) bool {
  44. _, err := exec.LookPath(cmd)
  45. return err == nil
  46. }
  47. func checkDependencies() error {
  48. requiredTools := []string{"git", "find", "grep"}
  49. optionalTools := []string{"gitleaks"}
  50. var missing []string
  51. log("Checking dependencies...")
  52. for _, tool := range requiredTools {
  53. if !commandExists(tool) {
  54. missing = append(missing, tool)
  55. }
  56. }
  57. for _, tool := range optionalTools {
  58. if !commandExists(tool) {
  59. warn("Optional tool missing: %s (recommended for security scanning)", tool)
  60. } else {
  61. log("✓ Found: %s", tool)
  62. }
  63. }
  64. if len(missing) > 0 {
  65. errorLog("Missing required tools: %s", strings.Join(missing, ", "))
  66. errorLog("Please install missing dependencies")
  67. return fmt.Errorf("missing dependencies")
  68. }
  69. log("✓ All required dependencies found")
  70. return nil
  71. }
  72. func runSecurityScan() error {
  73. log("Running security scan...")
  74. if !commandExists("gitleaks") {
  75. warn("GitLeaks not installed - skipping security scan")
  76. warn("Install with: paru -S gitleaks")
  77. fmt.Println()
  78. fmt.Print("Continue without security scan? (y/N): ")
  79. reader := bufio.NewReader(os.Stdin)
  80. answer, _ := reader.ReadString('\n')
  81. answer = strings.TrimSpace(strings.ToLower(answer))
  82. if answer != "y" && answer != "yes" {
  83. errorLog("Push cancelled for security")
  84. return fmt.Errorf("security scan cancelled")
  85. }
  86. return nil
  87. }
  88. log("Using GitLeaks for secret detection...")
  89. cmd := exec.Command("gitleaks", "detect", "--verbose", "--exit-code", "1")
  90. if err := cmd.Run(); err != nil {
  91. errorLog("❌ Secrets detected! Review before pushing.")
  92. return fmt.Errorf("secrets detected")
  93. }
  94. log("✅ No secrets detected")
  95. return nil
  96. }
  97. func isGitRepo() bool {
  98. cmd := exec.Command("git", "rev-parse", "--git-dir")
  99. return cmd.Run() == nil
  100. }
  101. func hasUncommittedChanges() bool {
  102. cmd := exec.Command("git", "diff-index", "--quiet", "HEAD", "--")
  103. return cmd.Run() != nil
  104. }
  105. func getCurrentBranch() (string, error) {
  106. cmd := exec.Command("git", "branch", "--show-current")
  107. output, err := cmd.Output()
  108. if err != nil {
  109. return "", err
  110. }
  111. return strings.TrimSpace(string(output)), nil
  112. }
  113. func handlePush() error {
  114. log("Preparing to push repository...")
  115. if !isGitRepo() {
  116. errorLog("Not in a git repository")
  117. return fmt.Errorf("not in git repo")
  118. }
  119. if hasUncommittedChanges() {
  120. warn("You have uncommitted changes")
  121. fmt.Println()
  122. cmd := exec.Command("git", "status", "--short")
  123. cmd.Stdout = os.Stdout
  124. cmd.Run()
  125. fmt.Println()
  126. defaultMsg := fmt.Sprintf("dev: automated commit - %s", time.Now().Format("2006-01-02 15:04:05"))
  127. fmt.Print("Commit all changes? (Y/n): ")
  128. reader := bufio.NewReader(os.Stdin)
  129. answer, _ := reader.ReadString('\n')
  130. answer = strings.TrimSpace(strings.ToLower(answer))
  131. if answer != "n" && answer != "no" {
  132. fmt.Println()
  133. fmt.Printf("Default: %s\n", defaultMsg)
  134. fmt.Print("Custom commit message (or press Enter for default): ")
  135. commitMsg, _ := reader.ReadString('\n')
  136. commitMsg = strings.TrimSpace(commitMsg)
  137. if commitMsg == "" {
  138. commitMsg = defaultMsg
  139. }
  140. if err := exec.Command("git", "add", ".").Run(); err != nil {
  141. return fmt.Errorf("failed to add changes: %v", err)
  142. }
  143. if err := exec.Command("git", "commit", "-m", commitMsg).Run(); err != nil {
  144. return fmt.Errorf("failed to commit changes: %v", err)
  145. }
  146. log("✓ Changes committed: %s", commitMsg)
  147. }
  148. }
  149. if err := runSecurityScan(); err != nil {
  150. return err
  151. }
  152. branch, err := getCurrentBranch()
  153. if err != nil {
  154. return fmt.Errorf("failed to get current branch: %v", err)
  155. }
  156. log("Pushing branch '%s' to origin...", branch)
  157. cmd := exec.Command("git", "push", "origin", branch)
  158. if err := cmd.Run(); err != nil {
  159. errorLog("❌ Push failed")
  160. return fmt.Errorf("push failed")
  161. }
  162. log("✅ Successfully pushed to origin/%s", branch)
  163. return nil
  164. }
  165. func getDescription(scriptPath string) string {
  166. file, err := os.Open(scriptPath)
  167. if err != nil {
  168. return "No description"
  169. }
  170. defer file.Close()
  171. scanner := bufio.NewScanner(file)
  172. for scanner.Scan() {
  173. line := scanner.Text()
  174. if strings.HasPrefix(line, "# NAME:") {
  175. desc := strings.TrimSpace(strings.TrimPrefix(line, "# NAME:"))
  176. if desc != "" {
  177. return desc
  178. }
  179. }
  180. }
  181. return "No description"
  182. }
  183. func findScripts(filters []string) ([]Script, error) {
  184. var scripts []Script
  185. err := filepath.Walk(config.RunsDir, func(path string, info os.FileInfo, err error) error {
  186. if err != nil {
  187. return err
  188. }
  189. if info.Mode().IsRegular() && (info.Mode()&0o111) != 0 {
  190. // Get relative path from runs directory
  191. relPath, err := filepath.Rel(config.RunsDir, path)
  192. if err != nil {
  193. return err
  194. }
  195. // Skip hidden files and directories
  196. if strings.HasPrefix(filepath.Base(path), ".") {
  197. return nil
  198. }
  199. scriptName := filepath.Base(path)
  200. if len(filters) == 0 || matchesFilters(relPath, scriptName, filters) {
  201. scripts = append(scripts, Script{
  202. Path: path,
  203. Name: scriptName,
  204. RelPath: relPath,
  205. Desc: getDescription(path),
  206. })
  207. }
  208. }
  209. return nil
  210. })
  211. sort.Slice(scripts, func(i, j int) bool {
  212. return scripts[i].RelPath < scripts[j].RelPath
  213. })
  214. return scripts, err
  215. }
  216. func matchesFilters(relPath, scriptName string, filters []string) bool {
  217. for _, filter := range filters {
  218. // Normalize paths for comparison (remove .sh extension from filter if present)
  219. normalizedFilter := strings.TrimSuffix(filter, ".sh")
  220. normalizedRelPath := strings.TrimSuffix(relPath, ".sh")
  221. normalizedScriptName := strings.TrimSuffix(scriptName, ".sh")
  222. // Check exact matches
  223. if normalizedRelPath == normalizedFilter || normalizedScriptName == normalizedFilter {
  224. return true
  225. }
  226. // Check if filter matches the relative path or script name (case insensitive)
  227. filterLower := strings.ToLower(normalizedFilter)
  228. relPathLower := strings.ToLower(normalizedRelPath)
  229. scriptNameLower := strings.ToLower(normalizedScriptName)
  230. if strings.Contains(relPathLower, filterLower) || strings.Contains(scriptNameLower, filterLower) {
  231. return true
  232. }
  233. // Check directory match (e.g., "tools" matches "tools/install.sh")
  234. if strings.HasPrefix(relPathLower, filterLower+"/") {
  235. return true
  236. }
  237. }
  238. return false
  239. }
  240. func executeScript(script Script, args []string, verbose bool) error {
  241. logFile := filepath.Join(config.LogsDir, strings.TrimSuffix(script.Name, filepath.Ext(script.Name))+".log")
  242. // Create command with script path and arguments
  243. cmd := exec.Command(script.Path, args...)
  244. if verbose {
  245. logFileHandle, err := os.Create(logFile)
  246. if err != nil {
  247. return err
  248. }
  249. defer logFileHandle.Close()
  250. cmd.Stdout = io.MultiWriter(os.Stdout, logFileHandle)
  251. cmd.Stderr = io.MultiWriter(os.Stderr, logFileHandle)
  252. return cmd.Run()
  253. } else {
  254. logFileHandle, err := os.Create(logFile)
  255. if err != nil {
  256. return err
  257. }
  258. defer logFileHandle.Close()
  259. cmd.Stdout = logFileHandle
  260. cmd.Stderr = logFileHandle
  261. return cmd.Run()
  262. }
  263. }
  264. func createNewScript(scriptName string) error {
  265. if err := os.MkdirAll(config.RunsDir, 0o755); err != nil {
  266. return err
  267. }
  268. if !strings.HasSuffix(scriptName, ".sh") {
  269. scriptName += ".sh"
  270. }
  271. scriptPath := filepath.Join(config.RunsDir, scriptName)
  272. if _, err := os.Stat(scriptPath); err == nil {
  273. return fmt.Errorf("script %s already exists", scriptName)
  274. }
  275. template := fmt.Sprintf(`#!/usr/bin/env bash
  276. # NAME: %s script
  277. set -euo pipefail
  278. echo "Running %s script..."
  279. # Add your commands here
  280. echo "✅ %s completed successfully"
  281. `, strings.TrimSuffix(scriptName, ".sh"), scriptName, strings.TrimSuffix(scriptName, ".sh"))
  282. err := os.WriteFile(scriptPath, []byte(template), 0o755)
  283. if err != nil {
  284. return err
  285. }
  286. log("✅ Created script: %s", scriptPath)
  287. return nil
  288. }
  289. func initConfig() {
  290. wd, _ := os.Getwd()
  291. config.RunsDir = filepath.Join(wd, "runs")
  292. config.LogsDir = filepath.Join(wd, "logs")
  293. }
  294. func ensureRunsDir() error {
  295. if _, err := os.Stat(config.RunsDir); os.IsNotExist(err) {
  296. log("Creating runs directory at: %s", config.RunsDir)
  297. if err := os.MkdirAll(config.RunsDir, 0o755); err != nil {
  298. return err
  299. }
  300. exampleScript := filepath.Join(config.RunsDir, "example.sh")
  301. content := `#!/usr/bin/env bash
  302. # NAME: Example script
  303. echo "Hello from runs/example.sh!"
  304. echo "Edit this script or add your own to runs/"
  305. `
  306. if err := os.WriteFile(exampleScript, []byte(content), 0o755); err != nil {
  307. return err
  308. }
  309. log("✅ Created runs/ directory with example script")
  310. return nil
  311. }
  312. return nil
  313. }
  314. var rootCmd = &cobra.Command{
  315. Use: "dev",
  316. Short: "Development script runner",
  317. Long: "A CLI tool for managing and running development scripts",
  318. RunE: func(cmd *cobra.Command, args []string) error {
  319. // Default behavior: list scripts if no subcommand
  320. return listCmd.RunE(cmd, args)
  321. },
  322. }
  323. var runCmd = &cobra.Command{
  324. Use: "run [filters...] [-- script-args...]",
  325. Short: "Run scripts matching filters (or all if no filters)",
  326. Long: `Run scripts matching filters. Use -- to pass arguments to scripts.
  327. Examples:
  328. dev run tools/dev.sh # Run specific script
  329. dev run tools/dev.sh -- arg1 arg2 # Run with arguments
  330. dev run --verbose install -- --force # Run with flags`,
  331. ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
  332. scripts, err := findScripts(nil)
  333. if err != nil {
  334. return nil, cobra.ShellCompDirectiveNoFileComp
  335. }
  336. var completions []string
  337. for _, script := range scripts {
  338. // Add both the script name and relative path as completion options
  339. if strings.HasPrefix(script.Name, toComplete) || strings.HasPrefix(script.RelPath, toComplete) {
  340. // Use relative path as the completion value with description
  341. completions = append(completions, fmt.Sprintf("%s\t%s", script.RelPath, script.Desc))
  342. }
  343. }
  344. return completions, cobra.ShellCompDirectiveNoFileComp
  345. },
  346. RunE: func(cmd *cobra.Command, args []string) error {
  347. // Parse arguments manually to handle -- separator
  348. var filters []string
  349. var scriptArgs []string
  350. // Find "run" command position and -- separator in raw args
  351. rawArgs := os.Args[1:] // Skip program name
  352. runIndex := -1
  353. dashDashIndex := -1
  354. for i, arg := range rawArgs {
  355. if arg == "run" {
  356. runIndex = i
  357. }
  358. if arg == "--" && runIndex >= 0 {
  359. dashDashIndex = i
  360. break
  361. }
  362. }
  363. if dashDashIndex >= 0 {
  364. // Extract everything between "run" and "--" as filters (skip flags)
  365. for i := runIndex + 1; i < dashDashIndex; i++ {
  366. arg := rawArgs[i]
  367. // Skip flags
  368. if !strings.HasPrefix(arg, "-") {
  369. filters = append(filters, arg)
  370. }
  371. }
  372. // Everything after "--" are script args
  373. scriptArgs = rawArgs[dashDashIndex+1:]
  374. } else {
  375. // No --, use cobra's args as filters
  376. filters = args
  377. scriptArgs = []string{}
  378. }
  379. if err := ensureRunsDir(); err != nil {
  380. return err
  381. }
  382. if err := os.MkdirAll(config.LogsDir, 0o755); err != nil {
  383. return err
  384. }
  385. scripts, err := findScripts(filters)
  386. if err != nil {
  387. return err
  388. }
  389. if len(scripts) == 0 {
  390. if len(filters) > 0 {
  391. warn("No scripts match filters: %s", strings.Join(filters, ", "))
  392. } else {
  393. warn("No scripts found. Use 'dev new <n>' to create one")
  394. }
  395. return nil
  396. }
  397. // CLI execution
  398. for _, script := range scripts {
  399. if config.DryRun {
  400. argsStr := ""
  401. if len(scriptArgs) > 0 {
  402. argsStr = fmt.Sprintf(" with args: %s", strings.Join(scriptArgs, " "))
  403. }
  404. fmt.Printf("[DRY] Would run: %s - %s%s\n", script.RelPath, script.Desc, argsStr)
  405. continue
  406. }
  407. argsDisplay := ""
  408. if len(scriptArgs) > 0 {
  409. argsDisplay = fmt.Sprintf(" with args: %s", strings.Join(scriptArgs, " "))
  410. }
  411. fmt.Printf("Running: %s%s\n", script.RelPath, argsDisplay)
  412. if err := executeScript(script, scriptArgs, config.Verbose); err != nil {
  413. errorLog("❌ %s failed", script.RelPath)
  414. if !config.Verbose {
  415. fmt.Printf(" Check log: %s\n", filepath.Join(config.LogsDir, strings.TrimSuffix(script.Name, filepath.Ext(script.Name))+".log"))
  416. }
  417. } else {
  418. log("✅ %s completed", script.RelPath)
  419. }
  420. }
  421. return nil
  422. },
  423. }
  424. var listCmd = &cobra.Command{
  425. Use: "ls",
  426. Aliases: []string{"list"},
  427. Short: "List all available scripts",
  428. RunE: func(cmd *cobra.Command, args []string) error {
  429. if err := ensureRunsDir(); err != nil {
  430. return err
  431. }
  432. scripts, err := findScripts(nil)
  433. if err != nil {
  434. return err
  435. }
  436. if len(scripts) == 0 {
  437. warn("No scripts found in %s", config.RunsDir)
  438. fmt.Println("Use 'dev new <n>' to create a new script")
  439. return nil
  440. }
  441. fmt.Printf("Available scripts in %s:\n\n", config.RunsDir)
  442. for _, script := range scripts {
  443. fmt.Printf(" %s%s%s - %s\n", Blue, script.RelPath, NC, script.Desc)
  444. }
  445. fmt.Printf("\nTotal: %d scripts\n", len(scripts))
  446. return nil
  447. },
  448. }
  449. var newCmd = &cobra.Command{
  450. Use: "new <n>",
  451. Short: "Create a new script template",
  452. Args: cobra.ExactArgs(1),
  453. RunE: func(cmd *cobra.Command, args []string) error {
  454. return createNewScript(args[0])
  455. },
  456. }
  457. var pushCmd = &cobra.Command{
  458. Use: "push",
  459. Aliases: []string{"u", "ush"},
  460. Short: "Commit, scan for secrets, and push to git origin",
  461. RunE: func(cmd *cobra.Command, args []string) error {
  462. return handlePush()
  463. },
  464. }
  465. var completionCmd = &cobra.Command{
  466. Use: "completion [bash|zsh|fish|powershell]",
  467. Short: "Generate completion script",
  468. Long: `To load completions:
  469. Bash:
  470. $ source <(dev completion bash)
  471. # To load completions for each session, execute once:
  472. # Linux:
  473. $ dev completion bash > /etc/bash_completion.d/dev
  474. # macOS:
  475. $ dev completion bash > $(brew --prefix)/etc/bash_completion.d/dev
  476. Zsh:
  477. # If shell completion is not already enabled in your environment,
  478. # you will need to enable it. You can execute the following once:
  479. $ echo "autoload -U compinit; compinit" >> ~/.zshrc
  480. # To load completions for each session, execute once:
  481. $ dev completion zsh > "${fpath[1]}/_dev"
  482. # You will need to start a new shell for this setup to take effect.
  483. `,
  484. DisableFlagsInUseLine: true,
  485. ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
  486. Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
  487. RunE: func(cmd *cobra.Command, args []string) error {
  488. switch args[0] {
  489. case "bash":
  490. return rootCmd.GenBashCompletion(os.Stdout)
  491. case "zsh":
  492. return rootCmd.GenZshCompletion(os.Stdout)
  493. case "fish":
  494. return rootCmd.GenFishCompletion(os.Stdout, true)
  495. case "powershell":
  496. return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
  497. }
  498. return nil
  499. },
  500. }
  501. func main() {
  502. initConfig()
  503. runCmd.Flags().BoolVar(&config.DryRun, "dry", false, "Show what would run without executing")
  504. runCmd.Flags().BoolVarP(&config.Verbose, "verbose", "v", false, "Show script output in terminal")
  505. // This prevents Cobra from consuming -- and everything after it
  506. runCmd.Flags().SetInterspersed(false)
  507. rootCmd.AddCommand(runCmd, listCmd, newCmd, pushCmd, completionCmd)
  508. if err := rootCmd.Execute(); err != nil {
  509. os.Exit(1)
  510. }
  511. }