Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 }}
35 changes: 35 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -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:"
5 changes: 4 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
63 changes: 61 additions & 2 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -31,12 +35,21 @@ var configShowCmd = &cobra.Command{

var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
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
},
}
Expand All @@ -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
}
120 changes: 114 additions & 6 deletions cmd/install.go
Original file line number Diff line number Diff line change
@@ -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:]
}
Loading
Loading