diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..088ab61 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build + run: go build -o /dev/null . + + release: + runs-on: ubuntu-latest + needs: test + if: startsWith(github.ref, 'refs/tags/v') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Release + uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..5170e38 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,35 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - format: tar.gz + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + format: zip + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" diff --git a/Taskfile.yml b/Taskfile.yml index f7b1ae7..f4c9412 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -17,9 +17,12 @@ tasks: build: desc: Build mulonda binary into ./bin + vars: + VERSION: + sh: git describe --tags --always --dirty 2>/dev/null || echo "dev" cmds: - mkdir -p {{.BIN_DIR}} - - go build -o {{.BIN_DIR}}/{{.BINARY}} . + - go build -ldflags "-s -w -X main.version={{.VERSION}}" -o {{.BIN_DIR}}/{{.BINARY}} . run: desc: Run mulonda with optional CLI_ARGS, e.g. task run CLI_ARGS="list" diff --git a/cmd/config.go b/cmd/config.go index d8b7c3a..5daa9e5 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -2,10 +2,14 @@ package cmd import ( "fmt" + "os" + "path/filepath" + "strconv" "strings" "github.com/cod3ddy/mulonda/internal/config" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) var configCmd = &cobra.Command{ @@ -31,12 +35,21 @@ var configShowCmd = &cobra.Command{ var configSetCmd = &cobra.Command{ Use: "set ", - Short: "Set config value (stub)", + Short: "Set a config value (e.g. timeout_seconds=60, non_interactive.passthrough=false)", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { key := strings.TrimSpace(args[0]) value := strings.TrimSpace(args[1]) - fmt.Printf("config set stub: TODO persist %s=%s in %s\n", key, value, configFile) + + path := configFile + if path == "" { + path = config.DefaultConfigPath + } + + if err := setConfigValue(path, key, value); err != nil { + return err + } + fmt.Printf("set %s = %s\n", key, value) return nil }, } @@ -46,3 +59,49 @@ func init() { configCmd.AddCommand(configSetCmd) rootCmd.AddCommand(configCmd) } + +func setConfigValue(path, key, value string) error { + data := map[string]any{} + if content, err := os.ReadFile(path); err == nil { + _ = yaml.Unmarshal(content, &data) + } + + setNestedKey(data, key, coerceValue(value)) + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + out, err := yaml.Marshal(data) + if err != nil { + return err + } + return os.WriteFile(path, out, 0o644) +} + +func setNestedKey(m map[string]interface{}, key string, value interface{}) { + parts := strings.SplitN(key, ".", 2) + if len(parts) == 1 { + m[key] = value + return + } + sub, ok := m[parts[0]].(map[string]any) + if !ok { + sub = map[string]any{} + } + setNestedKey(sub, parts[1], value) + m[parts[0]] = sub +} + +func coerceValue(s string) interface{} { + switch strings.ToLower(s) { + case "true": + return true + case "false": + return false + } + if n, err := strconv.Atoi(s); err == nil { + return n + } + return s +} diff --git a/cmd/install.go b/cmd/install.go index 6c6d925..7c9a935 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -1,24 +1,132 @@ package cmd import ( + "fmt" "os" - "os/exec" + "path/filepath" + "strings" + "github.com/cod3ddy/mulonda/internal/watchlist" "github.com/spf13/cobra" ) +const ( + aliasBlockStart = "# >>> mulonda >>>" + aliasBlockEnd = "# <<< mulonda <<<" +) + var installCmd = &cobra.Command{ Use: "install", Short: "Install Mulonda shell aliases", RunE: func(cmd *cobra.Command, args []string) error { - install := exec.Command("bash", "scripts/install.sh") - install.Stdout = os.Stdout - install.Stderr = os.Stderr - install.Stdin = os.Stdin - return install.Run() + shell := detectShell() + cmds := defaultWatchedCommands() + block := buildAliasBlock(shell, cmds) + + rcFiles := shellRCFiles(shell) + for _, rc := range rcFiles { + if err := injectAliasBlock(rc, block); err != nil { + return fmt.Errorf("inject into %s: %w", rc, err) + } + } + + fmt.Println("Mulonda aliases installed.") + fmt.Println("Restart your shell or run:") + for _, f := range rcFiles { + fmt.Printf(" source %s\n", f) + } + return nil }, } func init() { rootCmd.AddCommand(installCmd) } + +func detectShell() string { + shell := os.Getenv("SHELL") + if shell == "" { + return "bash" + } + return filepath.Base(shell) +} + +// shellRCFiles returns the rc file paths for the given shell. +func shellRCFiles(shell string) []string { + home, _ := os.UserHomeDir() + switch shell { + case "zsh": + return []string{filepath.Join(home, ".zshrc")} + case "fish": + cfgDir, _ := os.UserConfigDir() + + return []string{filepath.Join(cfgDir, "fish", "conf.d", "mulonda.fish")} + default: + return []string{filepath.Join(home, ".bashrc")} + } +} + +func defaultWatchedCommands() []string { + cmds := make([]string, 0, len(watchlist.DefaultRules)) + for _, r := range watchlist.DefaultRules { + cmds = append(cmds, r.Command) + } + return cmds +} + +func buildAliasBlock(shell string, cmds []string) string { + var sb strings.Builder + sb.WriteString(aliasBlockStart + "\n") + for _, c := range cmds { + if shell == "fish" { + fmt.Fprintf(&sb, "alias %s \"mulonda %s\"\n", c, c) + } else { + fmt.Fprintf(&sb, "alias %s=\"mulonda %s\"\n", c, c) + } + } + sb.WriteString(aliasBlockEnd + "\n") + return sb.String() +} + +func injectAliasBlock(rcFile, block string) error { + content, err := os.ReadFile(rcFile) + if err != nil && !os.IsNotExist(err) { + return err + } + + s := string(content) + + if strings.Contains(s, aliasBlockStart) { + return os.WriteFile(rcFile, []byte(replaceAliasBlock(s, block)), 0o644) + } + + if err := os.MkdirAll(filepath.Dir(rcFile), 0o755); err != nil { + return err + } + + f, err := os.OpenFile(rcFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + + prefix := "\n" + if len(s) == 0 { + prefix = "" + } + _, err = fmt.Fprintf(f, "%s%s\n", prefix, block) + return err +} + +func replaceAliasBlock(content, newBlock string) string { + before, _, ok := strings.Cut(content, aliasBlockStart) + end := strings.Index(content, aliasBlockEnd) + if !ok || end == -1 { + return content + } + end += len(aliasBlockEnd) + if end < len(content) && content[end] == '\n' { + end++ + } + return before + newBlock + "\n" + content[end:] +} diff --git a/cmd/proxy_execution_test.go b/cmd/proxy_execution_test.go index d2d7563..def6dbd 100644 --- a/cmd/proxy_execution_test.go +++ b/cmd/proxy_execution_test.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "io" "os" @@ -72,7 +73,7 @@ func TestExecuteProxy_MatchedInteractive_ConfirmYesExecutes(t *testing.T) { } isInteractiveFn = func() bool { return true } confirmPromptFn = func(message string) bool { return true } - commandFactoryFn = func(name string, args ...string) *exec.Cmd { + commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd { return exec.Command("bash", "-lc", "true") } @@ -96,7 +97,7 @@ func TestExecuteProxy_MatchedInteractive_ConfirmNoAborts(t *testing.T) { confirmPromptFn = func(message string) bool { return false } commandCalled := false - commandFactoryFn = func(name string, args ...string) *exec.Cmd { + commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd { commandCalled = true return exec.Command("bash", "-lc", "true") } @@ -121,7 +122,7 @@ func TestExecuteProxy_MatchedNonInteractive_PassthroughTrueExecutes(t *testing.T return watchlist.Rule{Command: "danger"}, true } isInteractiveFn = func() bool { return false } - commandFactoryFn = func(name string, args ...string) *exec.Cmd { + commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd { return exec.Command("bash", "-lc", "true") } @@ -168,7 +169,7 @@ func TestExecuteProxy_UnmatchedCommandExecutesWithoutPrompt(t *testing.T) { return true } - commandFactoryFn = func(name string, args ...string) *exec.Cmd { + commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd { return exec.Command("bash", "-lc", "true") } @@ -197,7 +198,7 @@ func TestExecuteProxy_BlankWarningUsesFallbackAndFormatsZeroArgsPrompt(t *testin return true } - commandFactoryFn = func(name string, args ...string) *exec.Cmd { + commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd { return exec.Command("bash", "-lc", "true") } @@ -220,7 +221,7 @@ func TestExecuteProxy_CommandFailureIsWrapped(t *testing.T) { matchRuleFn = func(command string, args []string, rules []watchlist.Rule) (watchlist.Rule, bool) { return watchlist.Rule{}, false } - commandFactoryFn = func(name string, args ...string) *exec.Cmd { + commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd { return exec.Command("bash", "-lc", "exit 7") } @@ -241,7 +242,7 @@ func TestExecuteProxy_ForwardsStdIOToCommand(t *testing.T) { matchRuleFn = func(command string, args []string, rules []watchlist.Rule) (watchlist.Rule, bool) { return watchlist.Rule{}, false } - commandFactoryFn = func(name string, args ...string) *exec.Cmd { + commandFactoryFn = func(ctx context.Context, name string, args ...string) *exec.Cmd { return exec.Command("bash", "-lc", "cat") } diff --git a/cmd/remove.go b/cmd/remove.go new file mode 100644 index 0000000..726215c --- /dev/null +++ b/cmd/remove.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/cod3ddy/mulonda/internal/watchlist" + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a rule from the watchlist", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + command := strings.TrimSpace(args[0]) + if command == "" { + return fmt.Errorf("command cannot be empty") + } + + removed, err := watchlist.RemoveRule(watchlistFile, command) + if err != nil { + return err + } + + if removed { + fmt.Printf("removed from watchlist: %s\n", command) + } else { + fmt.Printf("not found in watchlist: %s\n", command) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(removeCmd) +} diff --git a/cmd/root.go b/cmd/root.go index 899ad5e..104f4fa 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,9 +1,11 @@ package cmd import ( + "context" "fmt" "os" "strings" + "time" "github.com/cod3ddy/mulonda/internal/config" "github.com/cod3ddy/mulonda/internal/executor" @@ -13,7 +15,6 @@ import ( "github.com/spf13/cobra" ) -// rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "mulonda", Short: "Guard dangerous shell commands with a confirmation prompt", @@ -33,8 +34,12 @@ var ( isInteractiveFn = isInteractiveSession ) +// SetVersion wires the build-time version string into the root command. +func SetVersion(v string) { + rootCmd.Version = v +} + // Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { configPath, watchlistPath, passthroughArgs, parseErr := parseGlobalFlags(os.Args[1:]) if parseErr == nil && len(passthroughArgs) > 0 && !isManagementCommand(passthroughArgs[0]) { @@ -89,7 +94,16 @@ func executeProxy(configPath, watchlistPath string, args []string) error { } } - cmd := commandFactoryFn(command, commandArgs...) + var ctx context.Context + var cancel context.CancelFunc + if cfg.TimeoutSeconds > 0 { + ctx, cancel = context.WithTimeout(context.Background(), time.Duration(cfg.TimeoutSeconds)*time.Second) + } else { + ctx, cancel = context.WithCancel(context.Background()) + } + defer cancel() + + cmd := commandFactoryFn(ctx, command, commandArgs...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -152,7 +166,7 @@ func parseGlobalFlags(args []string) (configPath, watchPath string, passthrough func isManagementCommand(name string) bool { switch name { - case "add", "list", "install", "uninstall", "config", "completion", "help": + case "add", "remove", "list", "install", "uninstall", "config", "completion", "help": return true default: return false diff --git a/cmd/root_test.go b/cmd/root_test.go index d2b8fa0..e9c3a27 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -4,6 +4,7 @@ import ( "bytes" "testing" + "github.com/cod3ddy/mulonda/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -51,12 +52,12 @@ func TestParseGlobalFlags(t *testing.T) { name: "config equals form parsed", args: []string{"--config=custom.yml"}, wantConfig: "custom.yml", - wantWatchlist: "data/watchlist.yaml", + wantWatchlist: config.DefaultWatchlistPath, }, { name: "watchlist equals form parsed", args: []string{"--watchlist=custom-watch.yml"}, - wantConfig: "mulonda.yaml", + wantConfig: config.DefaultConfigPath, wantWatchlist: "custom-watch.yml", }, { @@ -67,15 +68,15 @@ func TestParseGlobalFlags(t *testing.T) { { name: "delimiter passes through remaining args", args: []string{"--", "echo", "hello"}, - wantConfig: "mulonda.yaml", - wantWatchlist: "data/watchlist.yaml", + wantConfig: config.DefaultConfigPath, + wantWatchlist: config.DefaultWatchlistPath, wantPass: []string{"echo", "hello"}, }, { name: "non management command becomes passthrough", args: []string{"echo", "hello"}, - wantConfig: "mulonda.yaml", - wantWatchlist: "data/watchlist.yaml", + wantConfig: config.DefaultConfigPath, + wantWatchlist: config.DefaultWatchlistPath, wantPass: []string{"echo", "hello"}, }, { @@ -94,8 +95,8 @@ func TestParseGlobalFlags(t *testing.T) { { name: "management command name passes through from parser", args: []string{"list"}, - wantConfig: "mulonda.yaml", - wantWatchlist: "data/watchlist.yaml", + wantConfig: config.DefaultConfigPath, + wantWatchlist: config.DefaultWatchlistPath, wantPass: []string{"list"}, }, } @@ -124,6 +125,7 @@ func TestIsManagementCommand(t *testing.T) { want bool }{ {name: "add", cmd: "add", want: true}, + {name: "remove", cmd: "remove", want: true}, {name: "list", cmd: "list", want: true}, {name: "install", cmd: "install", want: true}, {name: "uninstall", cmd: "uninstall", want: true}, diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 9d4f3d9..d11ae50 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -2,6 +2,8 @@ package cmd import ( "fmt" + "os" + "strings" "github.com/spf13/cobra" ) @@ -9,11 +11,70 @@ import ( var uninstallCmd = &cobra.Command{ Use: "uninstall", Short: "Remove Mulonda shell aliases", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("uninstall stub: TODO remove aliases from shell rc files") + RunE: func(cmd *cobra.Command, args []string) error { + shells := []string{"bash", "zsh", "fish"} + removed := []string{} + + for _, shell := range shells { + for _, rc := range shellRCFiles(shell) { + ok, err := removeAliasBlock(rc) + if err != nil { + return fmt.Errorf("uninstall from %s: %w", rc, err) + } + if ok { + removed = append(removed, rc) + } + } + } + + if len(removed) == 0 { + fmt.Println("No Mulonda aliases found.") + return nil + } + + fmt.Println("Mulonda aliases removed from:") + for _, f := range removed { + fmt.Printf(" %s\n", f) + } + fmt.Println("Restart your shell to apply.") + return nil }, } func init() { rootCmd.AddCommand(uninstallCmd) } + +func removeAliasBlock(rcFile string) (bool, error) { + content, err := os.ReadFile(rcFile) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + + s := string(content) + if !strings.Contains(s, aliasBlockStart) { + return false, nil + } + + start := strings.Index(s, aliasBlockStart) + end := strings.Index(s, aliasBlockEnd) + if start == -1 || end == -1 { + return false, nil + } + + end += len(aliasBlockEnd) + if end < len(s) && s[end] == '\n' { + end++ + } + + // just remove a preceding blank line if the block was appended with one + if start > 0 && s[start-1] == '\n' { + start-- + } + + result := s[:start] + s[end:] + return true, os.WriteFile(rcFile, []byte(result), 0o644) +} diff --git a/internal/config/config.go b/internal/config/config.go index 345550e..2105da9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "errors" + "os" "github.com/spf13/viper" ) @@ -21,6 +22,7 @@ type NonInteractiveConfig struct { func Default() Config { cfg := Config{TimeoutSeconds: 30} cfg.NonInteractive.Passthrough = true + cfg.Files = &FileConfig{WatchlistDirectory: DefaultWatchlistPath} return cfg } @@ -41,15 +43,23 @@ func Load(path string) (Config, error) { } if err := v.ReadInConfig(); err != nil { - var notFound viper.ConfigFileNotFoundError - if !errors.As(err, ¬Found) { + if !isConfigMissing(err) { + return cfg, err + } + } else { + if err := v.Unmarshal(&cfg); err != nil { return cfg, err } } - if err := v.Unmarshal(&cfg); err != nil { - return cfg, err + if cfg.Files == nil { + cfg.Files = &FileConfig{WatchlistDirectory: DefaultWatchlistPath} } return cfg, nil } + +func isConfigMissing(err error) bool { + var notFound viper.ConfigFileNotFoundError + return errors.As(err, ¬Found) || errors.Is(err, os.ErrNotExist) +} diff --git a/internal/config/files.go b/internal/config/files.go index 3a04256..b2d4b0a 100644 --- a/internal/config/files.go +++ b/internal/config/files.go @@ -1,10 +1,33 @@ package config -const ( - DefaultConfigPath = "mulonda.yaml" - DefaultWatchlistPath = "data/watchlist.yaml" +import ( + "os" + "path/filepath" ) +var ( + DefaultConfigPath = computeDefaultConfigPath() + DefaultWatchlistPath = computeDefaultWatchlistPath() +) + +// FileConfig holds file-path preferences read from config. type FileConfig struct { WatchlistDirectory string `mapstructure:"watchlist_dir"` } + +func mulondaConfigDir() string { + dir, err := os.UserConfigDir() + if err != nil { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, ".config") + } + return filepath.Join(dir, "mulonda") +} + +func computeDefaultConfigPath() string { + return filepath.Join(mulondaConfigDir(), "config.yaml") +} + +func computeDefaultWatchlistPath() string { + return filepath.Join(mulondaConfigDir(), "watchlist.yaml") +} diff --git a/internal/executor/exec.go b/internal/executor/exec.go index a299d02..3d725f2 100644 --- a/internal/executor/exec.go +++ b/internal/executor/exec.go @@ -1,8 +1,11 @@ package executor -import "os/exec" +import ( + "context" + "os/exec" +) -// Command proxies execution to the underlying system binary. -func Command(name string, args ...string) *exec.Cmd { - return exec.Command(name, args...) +// Command returns an exec.Cmd bound to ctx — the process is killed when ctx is done. +func Command(ctx context.Context, name string, args ...string) *exec.Cmd { + return exec.CommandContext(ctx, name, args...) } diff --git a/internal/watchlist/loader.go b/internal/watchlist/loader.go index 637c89d..5bab0eb 100644 --- a/internal/watchlist/loader.go +++ b/internal/watchlist/loader.go @@ -130,17 +130,50 @@ func AddRule(path string, rule Rule) error { return nil } -func containsRule(rules []Rule, target Rule) bool { - for _, r := range rules { - if equalRule(r, target) { - return true +// RemoveRule deletes all rules for command from the watchlist file. +// Returns true if at least one rule was removed. +func RemoveRule(path, command string) (bool, error) { + if path == "" { + path = config.DefaultWatchlistPath + } + command = strings.TrimSpace(command) + if command == "" { + return false, nil + } + + v := viper.New() + v.SetConfigType("yaml") + v.SetConfigFile(path) + + f := fileRules{} + if err := v.ReadInConfig(); err != nil { + if isConfigMissing(err) { + return false, nil } + return false, err + } + if err := v.Unmarshal(&f); err != nil { + return false, err } - return false -} -func equalRule(a, b Rule) bool { - return equalIdentity(a, b) && a.Warning == b.Warning + filtered := f.Rules[:0] + removed := false + for _, r := range f.Rules { + if strings.TrimSpace(r.Command) == command { + removed = true + continue + } + filtered = append(filtered, r) + } + + if !removed { + return false, nil + } + + f.Rules = filtered + v.Set("rules", f.Rules) + v.Set("commands", nil) + return true, v.WriteConfig() } func equalIdentity(a, b Rule) bool { diff --git a/main.go b/main.go index caf713c..ebc7145 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,10 @@ -/* -Copyright © 2026 NAME HERE -*/ package main import "github.com/cod3ddy/mulonda/cmd" +var version = "dev" + func main() { + cmd.SetVersion(version) cmd.Execute() }