mirror of
https://github.com/chenasraf/wand.git
synced 2026-05-17 17:38:02 +00:00
feat: initial commit
This commit is contained in:
13
.github/FUNDING.yml
vendored
Executable file
13
.github/FUNDING.yml
vendored
Executable 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¤cy_code=ILS&source=url"
|
||||
12
.github/workflows/manual-homebrew-release.yml
vendored
Executable file
12
.github/workflows/manual-homebrew-release.yml
vendored
Executable 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
20
.github/workflows/release.yml
vendored
Executable 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
44
.github/workflows/test.yml
vendored
Executable 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
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
wand
|
||||
/wand.yml
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
58
Makefile
Executable 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
140
README.md
Normal 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.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🚀 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
102
cmd/config.go
Normal 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
277
cmd/config_test.go
Normal 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
58
cmd/root.go
Normal 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
152
cmd/root_test.go
Normal 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
20
cmd/run.go
Normal 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
88
cmd/run_test.go
Normal 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
25
go.mod
Normal 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
56
go.sum
Normal 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=
|
||||
Reference in New Issue
Block a user