diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-06 23:56:32 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-06 23:56:32 +0300 |
| commit | 62ff4e782bc057d277a7c41fa35a665e2aeca045 (patch) | |
| tree | 5ca14104e68a9815f20ae6802c78341c6dc94371 | |
| parent | 5600bedb927e291e2e78e6140de256e1a1d4475c (diff) | |
initial package management
| -rw-r--r-- | TODO.md | 5 | ||||
| -rw-r--r-- | api/api.go | 4 | ||||
| -rw-r--r-- | api/options/option.go | 28 | ||||
| -rw-r--r-- | examples/examples.go | 14 | ||||
| -rw-r--r-- | internal/exec/exec.go | 38 | ||||
| -rw-r--r-- | internal/exec/exec_test.go | 69 | ||||
| -rw-r--r-- | internal/resource/dir/dir.go | 2 | ||||
| -rw-r--r-- | internal/resource/dir/dir_test.go | 10 | ||||
| -rw-r--r-- | internal/resource/file/file.go | 2 | ||||
| -rw-r--r-- | internal/resource/file/file_test.go | 2 | ||||
| -rw-r--r-- | internal/resource/link/link.go | 2 | ||||
| -rw-r--r-- | internal/resource/link/link_test.go | 2 | ||||
| -rw-r--r-- | internal/resource/pkg/dnf.go | 34 | ||||
| -rw-r--r-- | internal/resource/pkg/pkg.go | 47 |
14 files changed, 224 insertions, 35 deletions
@@ -19,10 +19,7 @@ DONE! ## 4. Glob / multi-file installs -The `ensure_dir` helper installs `"$DOT/foo/*"` into a destination dir. gonf -`file.Have` handles a single file. Need a way to install a glob of source files -into a destination directory (helix, ghostty, hexai, lazygit, opencode, tmux, -sway, waybar, scripts, systemd units, calendar, pipewire). +MAYBE LATER, JUST USE NATIVE GO GLOB FOR NOW! ## 5. Prune / reconcile stale files @@ -36,3 +36,7 @@ func Link(path string, opts ...options.Option) Resource { func NoLink(path string, opts ...options.Option) Resource { return link.Absent(path, opts...) } + +// func Package(name string, ...options.Options) Resource { +// return pkg.Present(name, opts...) +// } diff --git a/api/options/option.go b/api/options/option.go index 1c757c4..fcffccd 100644 --- a/api/options/option.go +++ b/api/options/option.go @@ -96,27 +96,27 @@ func WithFileMode(mode os.FileMode) Option { // WithPrune enables reconciliation of extra destination entries during a // source copy, and recursive removal during IsAbsent(). -func WithPrune() Option { - return func(t any) { - r, ok := t.(Prunable) - if !ok { - log.Fatalf("%T does not support WithPrune", t) - } - r.SetPrune() +var WithPrune = func(t any) { + r, ok := t.(Prunable) + if !ok { + log.Fatalf("%T does not support WithPrune", t) } + r.SetPrune() } +func WithPruneFunc() Option { return WithPrune } + // IsAbsent marks the resource for removal. -func IsAbsent() Option { - return func(t any) { - r, ok := t.(Absentable) - if !ok { - log.Fatalf("%T does not support IsAbsent", t) - } - r.SetAbsent() +var IsAbsent = func(t any) { + r, ok := t.(Absentable) + if !ok { + log.Fatalf("%T does not support IsAbsent", t) } + r.SetAbsent() } +func IsAbsentFunc() Option { return IsAbsent } + // WithSymlink makes the resource a symbolic link pointing at target. func WithSymlink(target string) Option { return func(t any) { diff --git a/examples/examples.go b/examples/examples.go index 2168a40..51508a4 100644 --- a/examples/examples.go +++ b/examples/examples.go @@ -32,7 +32,7 @@ func Run() error { // 7. Ensuring something is absent (two equivalent styles; each resource // path may only be declared once per run, so they use distinct paths) - File("/tmp/gonf_old.txt", IsAbsent()) + File("/tmp/gonf_old.txt", IsAbsent) NoFile("/tmp/gonf_old_alt.txt") // Alternative way // 8. A directory tree copied from source, reconciled, with a distinct @@ -40,7 +40,7 @@ func Run() error { Dir( "/tmp/gonf_dir_from_source", WithSource("assets/testfiles"), - WithPrune(), + WithPrune, WithFileMode(0o644), ) @@ -49,23 +49,23 @@ func Run() error { // (rather than reusing #8's path) to give WithPrune's recursive removal // something real to demonstrate. _ = os.MkdirAll("/tmp/gonf_stale_dir/nested", 0o755) - Dir("/tmp/gonf_stale_dir", IsAbsent(), WithPrune()) + Dir("/tmp/gonf_stale_dir", IsAbsent, WithPrune) _ = os.MkdirAll("/tmp/gonf_stale_dir_alt/nested", 0o755) - NoDir("/tmp/gonf_stale_dir_alt", WithPrune()) // Alternative way + NoDir("/tmp/gonf_stale_dir_alt", WithPrune) // Alternative way // 10. Non-recursively removing an empty directory _ = os.Mkdir("/tmp/gonf_stale_empty_dir", 0o755) - Dir("/tmp/gonf_stale_empty_dir", IsAbsent()) + Dir("/tmp/gonf_stale_empty_dir", IsAbsent) // 11. Ensuring a symlink is absent _ = os.Symlink("/tmp/gonf_hello.txt", "/tmp/gonf_stale_link") - Link("/tmp/gonf_stale_link", IsAbsent()) + Link("/tmp/gonf_stale_link", IsAbsent) _ = os.Symlink("/tmp/gonf_hello.txt", "/tmp/gonf_stale_link_alt") NoLink("/tmp/gonf_stale_link_alt") // Alternative way // 12. Ensuring a hardlink is absent _ = os.Link("/tmp/gonf_hello.txt", "/tmp/gonf_stale_hardlink") - Link("/tmp/gonf_stale_hardlink", IsAbsent()) + Link("/tmp/gonf_stale_hardlink", IsAbsent) return Apply() } diff --git a/internal/exec/exec.go b/internal/exec/exec.go new file mode 100644 index 0000000..87e861b --- /dev/null +++ b/internal/exec/exec.go @@ -0,0 +1,38 @@ +package exec + +import ( + "bytes" + "os/exec" +) + +// Run executes a shell command with the given arguments and returns stdout, stderr, exit code, and any error encountered. +func Run(name string, args ...string) (stdout, stderr string, exitCode int, err error) { + cmd := exec.Command(name, args...) + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + err = cmd.Run() + + stdout = stdoutBuf.String() + stderr = stderrBuf.String() + + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + exitCode = exitError.ExitCode() + // In this case, the error is just the non-zero exit code, + // which we've already captured. We return nil for err to indicate + // that the command actually ran and exited (even if non-zero). + err = nil + } else { + // This is a "real" error, e.g., binary not found + exitCode = -1 + return stdout, stderr, exitCode, err + } + } else { + exitCode = 0 + } + + return stdout, stderr, exitCode, nil +} diff --git a/internal/exec/exec_test.go b/internal/exec/exec_test.go new file mode 100644 index 0000000..4dc6505 --- /dev/null +++ b/internal/exec/exec_test.go @@ -0,0 +1,69 @@ +package exec + +import ( + "testing" +) + +func TestRun(t *testing.T) { + tests := []struct { + name string + cmd string + args []string + wantStdout string + wantStderr string + wantExitCode int + wantErr bool + }{ + { + name: "success", + cmd: "echo", + args: []string{"hello world"}, + wantStdout: "hello world\n", + wantStderr: "", + wantExitCode: 0, + wantErr: false, + }, + { + name: "fail-exit-code", + cmd: "ls", + args: []string{"/non-existent-directory-12345"}, + wantStdout: "", + wantStderr: "", // ls stderr varies by OS, but should not be empty usually. + wantExitCode: 2, // Typical for ls non-existent + wantErr: false, + }, + { + name: "fail-binary-not-found", + cmd: "non-existent-command-12345", + args: []string{}, + wantStdout: "", + wantStderr: "", + wantExitCode: -1, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout, stderr, exitCode, err := Run(tt.cmd, tt.args...) + + if (err != nil) != tt.wantErr { + t.Errorf("Run() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if exitCode != tt.wantExitCode { + t.Errorf("Run() exitCode = %v, want %v", exitCode, tt.wantExitCode) + } + + if tt.name == "success" && stdout != tt.wantStdout { + t.Errorf("Run() stdout = %q, want %q", stdout, tt.wantStdout) + } + + // For ls error, we just check that stderr is not empty since exact text varies + if tt.name == "fail-exit-code" && stderr == "" { + t.Errorf("Run() stderr = %q, want non-empty", stderr) + } + }) + } +} diff --git a/internal/resource/dir/dir.go b/internal/resource/dir/dir.go index 94220b7..83c017c 100644 --- a/internal/resource/dir/dir.go +++ b/internal/resource/dir/dir.go @@ -200,6 +200,6 @@ func Present(path string, opts ...opt.Option) resource.Resource { } func Absent(path string, opts ...opt.Option) resource.Resource { - opts = append(opts, opt.IsAbsent()) + opts = append(opts, opt.IsAbsent) return Present(path, opts...) } diff --git a/internal/resource/dir/dir_test.go b/internal/resource/dir/dir_test.go index c7ec92e..21af8e1 100644 --- a/internal/resource/dir/dir_test.go +++ b/internal/resource/dir/dir_test.go @@ -78,7 +78,7 @@ func TestPresentAbsentNonEmptyDirWithoutPruneFails(t *testing.T) { t.Fatal(err) } - Present(path, IsAbsent()) + Present(path, IsAbsent) if err := resource.Apply(); err == nil { t.Error("expected Apply to fail when removing non-empty directory without prune") } @@ -95,7 +95,7 @@ func TestPresentAbsentPruneDirectoryRecursive(t *testing.T) { t.Fatal(err) } - Present(path, IsAbsent(), WithPrune()) + Present(path, IsAbsent, WithPrune) if err := resource.Apply(); err != nil { t.Fatalf("Apply failed: %v", err) } @@ -116,7 +116,7 @@ func TestAbsentPruneDirectoryRecursive(t *testing.T) { t.Fatal(err) } - Absent(path, WithPrune()) + Absent(path, WithPrune) if err := resource.Apply(); err != nil { t.Fatalf("Apply failed: %v", err) } @@ -168,7 +168,7 @@ func TestPresentDirectoryWithSource(t *testing.T) { t.Fatal(err) } - Present(dst, WithSource(src), WithPrune()) + Present(dst, WithSource(src), WithPrune) if err := resource.Apply(); err != nil { t.Fatalf("Apply failed: %v", err) } @@ -284,7 +284,7 @@ func TestSourceCopyWithPruneKeepsTemplatedFile(t *testing.T) { // Now apply with prune resource.ResetRepository() - Present(dst, WithSource(src), WithPrune()) + Present(dst, WithSource(src), WithPrune) if err := resource.Apply(); err != nil { t.Fatalf("Apply failed: %v", err) } diff --git a/internal/resource/file/file.go b/internal/resource/file/file.go index 9d8a0d9..88b0bca 100644 --- a/internal/resource/file/file.go +++ b/internal/resource/file/file.go @@ -231,6 +231,6 @@ func Present(path string, opts ...opt.Option) resource.Resource { } func Absent(path string, opts ...opt.Option) resource.Resource { - opts = append(opts, opt.IsAbsent()) + opts = append(opts, opt.IsAbsent) return Present(path, opts...) } diff --git a/internal/resource/file/file_test.go b/internal/resource/file/file_test.go index b158657..3ae183c 100644 --- a/internal/resource/file/file_test.go +++ b/internal/resource/file/file_test.go @@ -194,7 +194,7 @@ func TestPresentAbsent(t *testing.T) { t.Fatal(err) } - Present(path, IsAbsent()) + Present(path, IsAbsent) if err := resource.Apply(); err != nil { t.Fatalf("Apply failed: %v", err) } diff --git a/internal/resource/link/link.go b/internal/resource/link/link.go index a5c5122..b40ce65 100644 --- a/internal/resource/link/link.go +++ b/internal/resource/link/link.go @@ -95,7 +95,7 @@ func Present(path string, opts ...opt.Option) resource.Resource { } func Absent(path string, opts ...opt.Option) resource.Resource { - opts = append(opts, opt.IsAbsent()) + opts = append(opts, opt.IsAbsent) return Present(path, opts...) } diff --git a/internal/resource/link/link_test.go b/internal/resource/link/link_test.go index 862d5f7..170fbdd 100644 --- a/internal/resource/link/link_test.go +++ b/internal/resource/link/link_test.go @@ -183,7 +183,7 @@ func TestPresentAbsentSymlink(t *testing.T) { t.Fatal(err) } - Present(path, IsAbsent()) + Present(path, IsAbsent) if err := resource.Apply(); err != nil { t.Fatalf("Apply failed: %v", err) } diff --git a/internal/resource/pkg/dnf.go b/internal/resource/pkg/dnf.go new file mode 100644 index 0000000..ad03da2 --- /dev/null +++ b/internal/resource/pkg/dnf.go @@ -0,0 +1,34 @@ +package pkg + +import ( + "fmt" + "log" + + "codeberg.org/snonux/gonf/internal/exec" +) + +func applyDNF(name string, ensure Ensure) error { + var args []string + + switch ensure { + case PkgPresent: + args = []string{"install", "-y", name} + case PkgAbsent: + args = []string{"remove", "-y", name} + case PkgLatest: + args = []string{"install", "-y", name} + default: + log.Fatalf("unsupported ensure state: %v", ensure) + } + + stdout, stderr, exitCode, err := exec.Run("dnf", args...) + if err != nil { + return fmt.Errorf("failed to execute dnf: %w", err) + } + + if exitCode != 0 { + return fmt.Errorf("dnf failed with exit code %d: %s\n%s", exitCode, stdout, stderr) + } + + return nil +} diff --git a/internal/resource/pkg/pkg.go b/internal/resource/pkg/pkg.go new file mode 100644 index 0000000..72f098c --- /dev/null +++ b/internal/resource/pkg/pkg.go @@ -0,0 +1,47 @@ +package pkg + +import ( + "errors" + "os" +) + +type Ensure int + +const ( + PkgPresent Ensure = iota + PkgAbsent + PkgLatest +) + +type applyFunc func(name string, ensure Ensure) error + +func Present(name string, ensure Ensure) error { + bin, err := detect() + if err != nil { + return err + } + + var applyFunc applyFunc + + switch bin { + case "dnf": + applyFunc = applyDNF + } + + return applyFunc(name, ensure) +} + +func detect() (string, error) { + switch { + case exists("/etc/fedora-release"): + fallthrough + case exists("/etc/rocky-release"): + return "dnf", nil + } + return "", errors.New("unable to detect package manager!") +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} |
