feat: initial commit

This commit is contained in:
2026-03-30 17:45:39 +03:00
commit 711409bcd8
17 changed files with 1103 additions and 0 deletions

13
.github/FUNDING.yml vendored Executable file
View File

@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: chenasraf
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: casraf
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom:
- "https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=TSH3C3ABGQM22&currency_code=ILS&source=url"

12
.github/workflows/manual-homebrew-release.yml vendored Executable file
View File

@@ -0,0 +1,12 @@
name: Manual Homebrew Release
on:
workflow_dispatch:
jobs:
homebrew:
uses: chenasraf/workflows/.github/workflows/manual-homebrew-release.yml@master
with:
homebrew-tap-repo: chenasraf/homebrew-tap
secrets:
REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }}

20
.github/workflows/release.yml vendored Executable file
View File

@@ -0,0 +1,20 @@
name: Release
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
permissions:
contents: write
pull-requests: write
jobs:
release:
uses: chenasraf/workflows/.github/workflows/go-release.yml@master
with:
name: wand
homebrew-tap-repo: chenasraf/homebrew-tap
secrets:
REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }}

44
.github/workflows/test.yml vendored Executable file
View File

@@ -0,0 +1,44 @@
name: Test
on:
push:
branches:
- develop
pull_request:
branches:
- master
jobs:
build:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
- name: Build
run: go build -v
- name: Test
run: go test -v ./...
- name: Create dist/ dir
run: mkdir dist
- name: Generate build files
uses: chenasraf/go-cross-build@v1
with:
platforms: 'linux/amd64, darwin/amd64, windows/amd64' # , darwin/arm64' # '
package: ''
name: 'wand'
compress: 'true'
dest: 'dist'
- name: Upload builds
uses: actions/upload-artifact@v4
with:
name: dist
path: dist

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
wand
/wand.yml

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright © 2026 Chen Asraf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

58
Makefile Executable file
View File

@@ -0,0 +1,58 @@
BIN := $(notdir $(CURDIR))
all:
@if [ ! -f ".git/hooks/pre-commit" ]; then \
$(MAKE) precommit-install; \
fi
$(MAKE) build
$(MAKE) run
.PHONY: build
build:
go build -o $(BIN)
.PHONY: run
run: build
./$(BIN)
.PHONY: test
test:
go test -v ./...
.PHONY: install
install: build
cp $(BIN) ~/.local/bin/
.PHONY: uninstall
uninstall:
rm -f ~/.local/bin/$(BIN)
.PHONY: lint
lint:
golangci-lint run ./...
.PHONY: precommit-install
precommit-install:
@echo "Installing pre-commit hooks..."
@echo "#!/bin/sh\n\nmake precommit" > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Pre-commit hooks installed."
.PHONY: precommit
precommit:
@STAGED_FILES=$$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.go$$'); \
if [ -z "$$STAGED_FILES" ]; then \
echo "No staged Go files to check."; \
else \
set -e; \
echo "Running pre-commit checks..."; \
echo "go fmt"; \
go fmt ./...; \
git add $$STAGED_FILES; \
echo "go vet"; \
go vet ./...; \
echo "golangci-lint"; \
golangci-lint run ./...; \
echo "go test"; \
go test -v ./...; \
fi

140
README.md Normal file
View File

@@ -0,0 +1,140 @@
# wand
**`wand`** is a tiny, cross-platform command runner driven by a simple **YAML config file**, written
in **Go**. Define your commands and subcommands in a `wand.yml`, and run them from anywhere in your
project tree.
![Release](https://img.shields.io/github/v/release/chenasraf/wand)
![Downloads](https://img.shields.io/github/downloads/chenasraf/wand/total)
![License](https://img.shields.io/github/license/chenasraf/wand)
---
## 🚀 Features
- **Simple YAML config**: define commands, descriptions, and nested subcommands in a single file.
- **Auto-discovery**: finds `wand.yml` by searching the current directory, parent directories,
`~/`, and `~/.config/`.
- **Nested subcommands**: commands can have arbitrarily deep children.
- **Built-in help**: auto-generated `--help` for every command and subcommand.
- **Shell execution**: runs commands via your `$SHELL` with proper stdin/stdout/stderr passthrough.
---
## 🎯 Installation
### Download Precompiled Binaries
Grab the latest release for **Linux**, **macOS**, or **Windows**:
- [Releases →](https://github.com/chenasraf/wand/releases/latest)
### Homebrew (macOS/Linux)
Install directly from the tap:
```bash
brew install chenasraf/tap/wand
```
Or tap and then install the package:
```bash
brew tap chenasraf/tap
brew install wand
```
### From Source
```bash
git clone https://github.com/chenasraf/wand
cd wand
make build
```
---
## ✨ Getting Started
Create a `wand.yml` in your project root:
```yaml
main:
description: run the main command
cmd: echo hello from wand
build:
description: build the project
cmd: go build -o myapp
test:
description: run tests
cmd: go test -v ./...
children:
coverage:
description: run tests with coverage
cmd: go test -coverprofile=coverage.out ./...
```
### Run a command
```bash
# run the main (default) command
wand
# run a named command
wand build
# run a nested subcommand
wand test coverage
# show help
wand --help
wand test --help
```
---
## 📁 Config Resolution
`wand` searches for `wand.yml` (or `wand.yaml`) in the following order:
1. Current working directory (`./wand.yml`)
2. Parent directories (searching upward to the filesystem root)
3. Home directory (`~/.wand.yml`)
4. Config directory (`~/.config/wand.yml`)
The first config file found is used.
---
## 📖 Config Reference
Each top-level key defines a command. The special key `main` becomes the root (no-argument) command.
| Field | Type | Description |
| ------------- | ----------------- | ------------------------------------ |
| `description` | `string` | Short description shown in `--help` |
| `cmd` | `string` | Shell command to execute |
| `children` | `map[string]Command` | Nested subcommands (same structure) |
---
## 🛠️ Contributing
I am developing this package on my free time, so any support, whether code, issues, or just stars is
very helpful to sustaining its life. If you are feeling incredibly generous and would like to donate
just a small amount to help sustain this project, I would be very very thankful!
<a href='https://ko-fi.com/casraf' target='_blank'>
<img height='36' style='border:0px;height:36px;' src='https://cdn.ko-fi.com/cdn/kofi1.png?v=3' alt='Buy Me a Coffee at ko-fi.com' />
</a>
I welcome any issues or pull requests on GitHub. If you find a bug, or would like a new feature,
don't hesitate to open an appropriate issue and I will do my best to reply promptly.
---
## 📜 License
`wand` is licensed under the [CC0-1.0 License](/LICENSE).

102
cmd/config.go Normal file
View File

@@ -0,0 +1,102 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/samber/lo"
"github.com/spf13/viper"
)
type Command struct {
Description string `mapstructure:"description"`
Cmd string `mapstructure:"cmd"`
Children map[string]Command `mapstructure:"children"`
}
type Config struct {
Shell interface{} `mapstructure:"shell"`
}
func (c *Config) GetShell() string {
switch v := c.Shell.(type) {
case string:
return v
case map[string]interface{}:
key := runtimeOS()
if shell, ok := v[key]; ok {
if s, ok := shell.(string); ok {
return s
}
}
}
// fall back to $SHELL or sh
return lo.CoalesceOrEmpty(os.Getenv("SHELL"), "sh")
}
func runtimeOS() string {
switch runtime.GOOS {
case "darwin":
return "macos"
default:
return runtime.GOOS
}
}
func loadConfig() (*Config, map[string]Command, error) {
if err := initConfigPaths(); err != nil {
return nil, nil, err
}
var cfg Config
if sub := viper.Sub(".config"); sub != nil {
if err := sub.Unmarshal(&cfg); err != nil {
return nil, nil, fmt.Errorf("failed to parse .config: %w", err)
}
}
allEntries := make(map[string]Command)
if err := viper.Unmarshal(&allEntries); err != nil {
return nil, nil, fmt.Errorf("failed to parse config: %w", err)
}
commands := lo.OmitByKeys(allEntries, []string{".config"})
return &cfg, commands, nil
}
func initConfigPaths() error {
viper.SetConfigName("wand")
viper.SetConfigType("yaml")
// ./wand.yml
viper.AddConfigPath(".")
// search up from cwd
dir, err := os.Getwd()
if err == nil {
for {
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
viper.AddConfigPath(dir)
}
}
// ~/.wand.yml and ~/.config/wand.yml
home, err := os.UserHomeDir()
if err == nil {
viper.AddConfigPath(home)
viper.AddConfigPath(filepath.Join(home, ".config"))
}
if err := viper.ReadInConfig(); err != nil {
return fmt.Errorf("config file not found: %w", err)
}
return nil
}

277
cmd/config_test.go Normal file
View File

@@ -0,0 +1,277 @@
package cmd
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/spf13/viper"
)
func TestGetShell_String(t *testing.T) {
cfg := &Config{Shell: "/bin/zsh"}
if got := cfg.GetShell(); got != "/bin/zsh" {
t.Errorf("GetShell() = %q, want /bin/zsh", got)
}
}
func TestGetShell_PerOS(t *testing.T) {
cfg := &Config{Shell: map[string]interface{}{
"macos": "/bin/zsh",
"linux": "/bin/bash",
"windows": "cmd",
}}
got := cfg.GetShell()
expected := map[string]string{
"macos": "/bin/zsh",
"linux": "/bin/bash",
"windows": "cmd",
}
key := runtimeOS()
if got != expected[key] {
t.Errorf("GetShell() = %q, want %q (os=%s)", got, expected[key], key)
}
}
func TestGetShell_FallbackToEnv(t *testing.T) {
cfg := &Config{Shell: nil}
t.Setenv("SHELL", "/bin/bash")
if got := cfg.GetShell(); got != "/bin/bash" {
t.Errorf("GetShell() = %q, want /bin/bash", got)
}
}
func TestGetShell_FallbackToSh(t *testing.T) {
cfg := &Config{Shell: nil}
t.Setenv("SHELL", "")
if got := cfg.GetShell(); got != "sh" {
t.Errorf("GetShell() = %q, want sh", got)
}
}
func TestGetShell_PerOS_MissingKey(t *testing.T) {
// Map with no matching OS key should fall back
cfg := &Config{Shell: map[string]interface{}{
"nonexistent_os": "/bin/fake",
}}
t.Setenv("SHELL", "/bin/fallback")
if got := cfg.GetShell(); got != "/bin/fallback" {
t.Errorf("GetShell() = %q, want /bin/fallback", got)
}
}
func TestRuntimeOS(t *testing.T) {
got := runtimeOS()
if runtime.GOOS == "darwin" {
if got != "macos" {
t.Errorf("runtimeOS() = %q, want macos", got)
}
} else {
if got != runtime.GOOS {
t.Errorf("runtimeOS() = %q, want %q", got, runtime.GOOS)
}
}
}
func writeTestConfig(t *testing.T, dir, content string) {
t.Helper()
err := os.WriteFile(filepath.Join(dir, "wand.yml"), []byte(content), 0644)
if err != nil {
t.Fatal(err)
}
}
func setupTestConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
writeTestConfig(t, dir, content)
origDir, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
viper.Reset()
})
return dir
}
func TestLoadConfig_Basic(t *testing.T) {
setupTestConfig(t, `
main:
description: test main
cmd: echo hello
build:
description: build it
cmd: go build
`)
cfg, commands, err := loadConfig()
if err != nil {
t.Fatal(err)
}
if cfg == nil {
t.Fatal("cfg is nil")
}
if len(commands) != 2 {
t.Fatalf("expected 2 commands, got %d", len(commands))
}
if commands["main"].Description != "test main" {
t.Errorf("main description = %q", commands["main"].Description)
}
if commands["main"].Cmd != "echo hello" {
t.Errorf("main cmd = %q", commands["main"].Cmd)
}
if commands["build"].Description != "build it" {
t.Errorf("build description = %q", commands["build"].Description)
}
}
func TestLoadConfig_WithChildren(t *testing.T) {
setupTestConfig(t, `
parent:
description: parent cmd
cmd: echo parent
children:
child:
description: child cmd
cmd: echo child
children:
grandchild:
description: grandchild cmd
cmd: echo grandchild
`)
_, commands, err := loadConfig()
if err != nil {
t.Fatal(err)
}
parent := commands["parent"]
if len(parent.Children) != 1 {
t.Fatalf("expected 1 child, got %d", len(parent.Children))
}
child := parent.Children["child"]
if child.Description != "child cmd" {
t.Errorf("child description = %q", child.Description)
}
grandchild := child.Children["grandchild"]
if grandchild.Cmd != "echo grandchild" {
t.Errorf("grandchild cmd = %q", grandchild.Cmd)
}
}
func TestLoadConfig_WithShellString(t *testing.T) {
setupTestConfig(t, `
.config:
shell: /bin/zsh
main:
description: test
cmd: echo test
`)
cfg, commands, err := loadConfig()
if err != nil {
t.Fatal(err)
}
if cfg.GetShell() != "/bin/zsh" {
t.Errorf("shell = %q, want /bin/zsh", cfg.GetShell())
}
if _, ok := commands[".config"]; ok {
t.Error(".config should not appear in commands")
}
}
func TestLoadConfig_WithShellPerOS(t *testing.T) {
setupTestConfig(t, `
.config:
shell:
macos: /bin/zsh
linux: /bin/bash
windows: cmd
main:
description: test
cmd: echo test
`)
cfg, _, err := loadConfig()
if err != nil {
t.Fatal(err)
}
key := runtimeOS()
expected := map[string]string{
"macos": "/bin/zsh",
"linux": "/bin/bash",
"windows": "cmd",
}
if cfg.GetShell() != expected[key] {
t.Errorf("shell = %q, want %q", cfg.GetShell(), expected[key])
}
}
func TestLoadConfig_NoConfigFile(t *testing.T) {
dir := t.TempDir()
origDir, _ := os.Getwd()
_ = os.Chdir(dir)
defer func() {
_ = os.Chdir(origDir)
viper.Reset()
}()
// Remove HOME to prevent finding a real ~/.wand.yml
t.Setenv("HOME", dir)
_, _, err := loadConfig()
if err == nil {
t.Error("expected error for missing config, got nil")
}
}
func TestLoadConfig_SearchUpward(t *testing.T) {
parent := t.TempDir()
child := filepath.Join(parent, "subdir")
if err := os.Mkdir(child, 0755); err != nil {
t.Fatal(err)
}
writeTestConfig(t, parent, `
main:
description: found it
cmd: echo found
`)
origDir, _ := os.Getwd()
_ = os.Chdir(child)
defer func() {
_ = os.Chdir(origDir)
viper.Reset()
}()
_, commands, err := loadConfig()
if err != nil {
t.Fatal(err)
}
if commands["main"].Description != "found it" {
t.Errorf("expected to find config in parent dir, got description=%q", commands["main"].Description)
}
}

58
cmd/root.go Normal file
View File

@@ -0,0 +1,58 @@
package cmd
import (
"github.com/samber/lo"
"github.com/spf13/cobra"
)
func Execute() error {
cfg, commands, err := loadConfig()
if err != nil {
return err
}
rootCmd := &cobra.Command{
Use: "wand",
SilenceUsage: true,
SilenceErrors: true,
}
if main, ok := commands["main"]; ok {
rootCmd.Short = main.Description
rootCmd.RunE = runShellCmd(cfg, main.Cmd)
}
subcommands := lo.OmitByKeys(commands, []string{"main"})
lo.ForEach(
lo.MapToSlice(subcommands, func(name string, cmd Command) *cobra.Command {
return buildCobraCommand(cfg, name, cmd)
}),
func(c *cobra.Command, _ int) {
rootCmd.AddCommand(c)
},
)
return rootCmd.Execute()
}
func buildCobraCommand(cfg *Config, name string, cmd Command) *cobra.Command {
c := &cobra.Command{
Use: name,
Short: cmd.Description,
}
if cmd.Cmd != "" {
c.RunE = runShellCmd(cfg, cmd.Cmd)
}
lo.ForEach(
lo.MapToSlice(cmd.Children, func(childName string, child Command) *cobra.Command {
return buildCobraCommand(cfg, childName, child)
}),
func(child *cobra.Command, _ int) {
c.AddCommand(child)
},
)
return c
}

152
cmd/root_test.go Normal file
View File

@@ -0,0 +1,152 @@
package cmd
import (
"testing"
)
func TestBuildCobraCommand_Basic(t *testing.T) {
cfg := &Config{}
cmd := Command{
Description: "test command",
Cmd: "echo test",
}
c := buildCobraCommand(cfg, "mycommand", cmd)
if c.Use != "mycommand" {
t.Errorf("Use = %q, want mycommand", c.Use)
}
if c.Short != "test command" {
t.Errorf("Short = %q, want 'test command'", c.Short)
}
if c.RunE == nil {
t.Error("RunE should be set when Cmd is non-empty")
}
}
func TestBuildCobraCommand_NoCmd(t *testing.T) {
cfg := &Config{}
cmd := Command{
Description: "parent only",
Children: map[string]Command{
"child": {Description: "child cmd", Cmd: "echo child"},
},
}
c := buildCobraCommand(cfg, "parent", cmd)
if c.RunE != nil {
t.Error("RunE should be nil when Cmd is empty")
}
if !c.HasSubCommands() {
t.Error("expected subcommands")
}
}
func TestBuildCobraCommand_Children(t *testing.T) {
cfg := &Config{}
cmd := Command{
Description: "parent",
Cmd: "echo parent",
Children: map[string]Command{
"child1": {Description: "first child", Cmd: "echo child1"},
"child2": {
Description: "second child",
Cmd: "echo child2",
Children: map[string]Command{
"grandchild": {Description: "grandchild", Cmd: "echo grandchild"},
},
},
},
}
c := buildCobraCommand(cfg, "parent", cmd)
children := c.Commands()
if len(children) != 2 {
t.Fatalf("expected 2 children, got %d", len(children))
}
// Find child2 and check its grandchild
var child2Found bool
for _, child := range children {
if child.Use == "child2" {
child2Found = true
grandchildren := child.Commands()
if len(grandchildren) != 1 {
t.Fatalf("expected 1 grandchild, got %d", len(grandchildren))
}
if grandchildren[0].Use != "grandchild" {
t.Errorf("grandchild Use = %q", grandchildren[0].Use)
}
}
}
if !child2Found {
t.Error("child2 not found in subcommands")
}
}
func TestExecute_WithConfig(t *testing.T) {
setupTestConfig(t, `
main:
description: test main
cmd: echo hello
`)
// Execute should succeed - the root command runs "echo hello"
err := Execute()
if err != nil {
t.Fatalf("Execute() failed: %v", err)
}
}
func TestExecute_Subcommand(t *testing.T) {
setupTestConfig(t, `
greet:
description: say hi
cmd: echo hi
`)
// Simulate running "wand greet" by setting os.Args
origArgs := setArgs("wand", "greet")
defer restoreArgs(origArgs)
err := Execute()
if err != nil {
t.Fatalf("Execute() failed: %v", err)
}
}
func TestExecute_NestedSubcommand(t *testing.T) {
setupTestConfig(t, `
parent:
description: parent
cmd: echo parent
children:
child:
description: child
cmd: echo child
`)
origArgs := setArgs("wand", "parent", "child")
defer restoreArgs(origArgs)
err := Execute()
if err != nil {
t.Fatalf("Execute() failed: %v", err)
}
}
func TestExecute_NoMain(t *testing.T) {
setupTestConfig(t, `
build:
description: build
cmd: echo build
`)
// Running with no args and no "main" should print help (no error)
err := Execute()
if err != nil {
t.Fatalf("Execute() failed: %v", err)
}
}

20
cmd/run.go Normal file
View File

@@ -0,0 +1,20 @@
package cmd
import (
"os"
"os/exec"
"github.com/spf13/cobra"
)
func runShellCmd(cfg *Config, command string) func(*cobra.Command, []string) error {
return func(_ *cobra.Command, args []string) error {
shell := cfg.GetShell()
cmdArgs := append([]string{"-c", command}, args...)
cmd := exec.Command(shell, cmdArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
}

88
cmd/run_test.go Normal file
View File

@@ -0,0 +1,88 @@
package cmd
import (
"bytes"
"os"
"strings"
"testing"
)
func setArgs(args ...string) []string {
orig := os.Args
os.Args = args
return orig
}
func restoreArgs(orig []string) {
os.Args = orig
}
func TestRunShellCmd_Basic(t *testing.T) {
cfg := &Config{Shell: "sh"}
var buf bytes.Buffer
origStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
fn := runShellCmd(cfg, "echo hello")
err := fn(nil, nil)
_ = w.Close()
os.Stdout = origStdout
_, _ = buf.ReadFrom(r)
if err != nil {
t.Fatalf("runShellCmd failed: %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "hello" {
t.Errorf("output = %q, want hello", got)
}
}
func TestRunShellCmd_UsesConfiguredShell(t *testing.T) {
cfg := &Config{Shell: "/bin/sh"}
var buf bytes.Buffer
origStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
fn := runShellCmd(cfg, "echo running")
err := fn(nil, nil)
_ = w.Close()
os.Stdout = origStdout
_, _ = buf.ReadFrom(r)
if err != nil {
t.Fatalf("runShellCmd failed: %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "running" {
t.Errorf("output = %q, want running", got)
}
}
func TestRunShellCmd_FailingCommand(t *testing.T) {
cfg := &Config{Shell: "sh"}
fn := runShellCmd(cfg, "exit 1")
err := fn(nil, nil)
if err == nil {
t.Error("expected error for failing command")
}
}
func TestRunShellCmd_InvalidShell(t *testing.T) {
cfg := &Config{Shell: "/nonexistent/shell"}
fn := runShellCmd(cfg, "echo hello")
err := fn(nil, nil)
if err == nil {
t.Error("expected error for invalid shell")
}
}

25
go.mod Normal file
View File

@@ -0,0 +1,25 @@
module github.com/chenasraf/wand
go 1.25.5
require (
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
)
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.28.0 // indirect
)

56
go.sum Normal file
View File

@@ -0,0 +1,56 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

15
main.go Normal file
View File

@@ -0,0 +1,15 @@
package main
import (
"fmt"
"os"
"github.com/chenasraf/wand/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}