diff --git a/.gitignore b/.gitignore index 3c4ebb2..33a11fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /bin/ +.dev-home/ *.exe *.test *.out diff --git a/Makefile b/Makefile index 88acaca..6ce7d5b 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,16 @@ -BINARY := task-master +BINARY := tm CMD := ./cmd/task-master VERSION_PKG := task/internal/version GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH) -.PHONY: build run version demo +# Default: Go workspace bin (e.g. ~/go/bin). Override with: make install PREFIX=... +GOPATH ?= $(shell go env GOPATH) +PREFIX ?= $(GOPATH) +INSTALL_BIN := $(PREFIX)/bin +DEV_HOME := $(CURDIR)/.dev-home + +.PHONY: build run run-dev install uninstall test version demo build: go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD) @@ -12,6 +18,22 @@ build: run: build ./bin/$(BINARY) +# Sandbox: isolated config/data under ./.dev-home (does not touch ~/.config) +run-dev: build + TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY) + +install: build + install -d $(INSTALL_BIN) + install -m 755 bin/$(BINARY) $(INSTALL_BIN)/$(BINARY) + @echo "installed $(INSTALL_BIN)/$(BINARY)" + +uninstall: + rm -f $(INSTALL_BIN)/$(BINARY) + @echo "removed $(INSTALL_BIN)/$(BINARY)" + +test: + go test ./... + version: build ./bin/$(BINARY) version diff --git a/cmd/task-master/main.go b/cmd/task-master/main.go index 2da5266..e28394d 100644 --- a/cmd/task-master/main.go +++ b/cmd/task-master/main.go @@ -18,7 +18,7 @@ func main() { if len(os.Args) > 1 { switch os.Args[1] { case "version", "--version", "-v": - fmt.Printf("%s %s\n", config.AppName, version.BuildVersion) + fmt.Printf("%s %s\n", config.CliName, version.BuildVersion) return case "help", "--help", "-h": printHelp() @@ -69,5 +69,9 @@ Usage: %s version Print build version %s help Show this help -`, config.AppName, config.AppName, config.AppName, config.AppName) +Env: + TASK_MASTER_HOME Override home for config/data (dev/demo sandbox) + TASK_MASTER_DATA_DIR Override data directory only + +`, config.CliName, config.CliName, config.CliName, config.CliName) } diff --git a/demo.tape b/demo.tape index c0c04b5..3613260 100644 --- a/demo.tape +++ b/demo.tape @@ -18,7 +18,7 @@ Set Padding 12 Set Framerate 30 # Isolated config/data so the demo never touches your real ~/.config -Env HOME "/tmp/task-master-vhs" +Env TASK_MASTER_HOME "/tmp/task-master-vhs" Env TERM "xterm-256color" Env EDITOR "vim" @@ -33,7 +33,7 @@ Sleep 5s Show # --- launch ------------------------------------------------------------------ -Type "./bin/task-master" +Type "./bin/tm" Enter Sleep 1s diff --git a/internal/config/config.go b/internal/config/config.go index cbc3cec..7134761 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,17 +3,23 @@ package config import "path/filepath" const ( - AppName = "task-master" - DefaultProject = "inbox" - DefaultTheme = "default" - configFileName = "config.json" + // AppName is used for config/data directory names under XDG paths. + AppName = "task-master" + // CliName is the installed binary / command name. + CliName = "tm" + DefaultProject = "inbox" + DefaultTheme = "default" + configFileName = "config.json" + + envHome = "TASK_MASTER_HOME" + envDataDir = "TASK_MASTER_DATA_DIR" ) // Config holds application settings persisted under ~/.config/task-master. type Config struct { - ActiveProject string `json:"activeProject"` - DataDir string `json:"dataDir"` - Theme string `json:"theme"` + ActiveProject string `json:"activeProject"` + DataDir string `json:"dataDir"` + Theme string `json:"theme"` // ProjectLists maps project ID → comma-separated board list ids // (e.g. "all" or "backlog,in_progress"). ProjectLists map[string]string `json:"projectLists,omitempty"` diff --git a/internal/config/loader.go b/internal/config/loader.go index d0cecc6..2eac7c0 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -5,14 +5,28 @@ import ( "fmt" "os" "path/filepath" + "strings" ) +// ResolveHome returns the effective home for config/data paths. +// TASK_MASTER_HOME overrides the real user home (useful for demos/dev sandboxes). +func ResolveHome() (string, error) { + if h := strings.TrimSpace(os.Getenv(envHome)); h != "" { + return filepath.Clean(h), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return home, nil +} + // LoadOrCreate loads the config from disk, creating defaults if missing. // It also ensures DataDir and the active project directory exist. func LoadOrCreate() (*Config, error) { - home, err := os.UserHomeDir() + home, err := ResolveHome() if err != nil { - return nil, fmt.Errorf("resolve home directory: %w", err) + return nil, err } cfgPath := Path(home) @@ -26,6 +40,10 @@ func LoadOrCreate() (*Config, error) { return nil, err } + if d := strings.TrimSpace(os.Getenv(envDataDir)); d != "" { + cfg.DataDir = filepath.Clean(d) + } + if err := ensureDataDirs(cfg); err != nil { return nil, err } @@ -54,11 +72,12 @@ func readOrCreate(cfgPath, home string) (*Config, error) { return &cfg, nil } -// Save writes the config to ~/.config/task-master/config.json. +// Save writes the config to ~/.config/task-master/config.json +// (or under TASK_MASTER_HOME when set). func Save(cfg *Config) error { - home, err := os.UserHomeDir() + home, err := ResolveHome() if err != nil { - return fmt.Errorf("resolve home directory: %w", err) + return err } cfgPath := Path(home) diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index d1ed440..4ec01bd 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -30,6 +30,8 @@ func TestDefaultsAndApplyDefaults(t *testing.T) { func TestSaveRoundTrip(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("TASK_MASTER_HOME", "") + t.Setenv("TASK_MASTER_DATA_DIR", "") cfg := config.Defaults(home) cfg.ActiveProject = "work" @@ -54,6 +56,8 @@ func TestSaveRoundTrip(t *testing.T) { func TestLoadOrCreate(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("TASK_MASTER_HOME", "") + t.Setenv("TASK_MASTER_DATA_DIR", "") cfg, err := config.LoadOrCreate() if err != nil { @@ -81,3 +85,38 @@ func TestLoadOrCreate(t *testing.T) { t.Fatalf("DataDir mismatch") } } + +func TestTASK_MASTER_HOME(t *testing.T) { + sandbox := t.TempDir() + t.Setenv("TASK_MASTER_HOME", sandbox) + t.Setenv("TASK_MASTER_DATA_DIR", "") + + cfg, err := config.LoadOrCreate() + if err != nil { + t.Fatalf("LoadOrCreate: %v", err) + } + wantCfg := config.Path(sandbox) + if _, err := os.Stat(wantCfg); err != nil { + t.Fatalf("config under sandbox missing: %v", err) + } + wantData := filepath.Join(sandbox, ".local", "share", config.AppName) + if cfg.DataDir != wantData { + t.Fatalf("DataDir = %q, want %q", cfg.DataDir, wantData) + } +} + +func TestTASK_MASTER_DATA_DIR(t *testing.T) { + home := t.TempDir() + data := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("TASK_MASTER_HOME", "") + t.Setenv("TASK_MASTER_DATA_DIR", data) + + cfg, err := config.LoadOrCreate() + if err != nil { + t.Fatalf("LoadOrCreate: %v", err) + } + if cfg.DataDir != data { + t.Fatalf("DataDir = %q, want %q", cfg.DataDir, data) + } +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 76a67f7..687496b 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -10,7 +10,7 @@ import ( ) func (m Model) renderHeader() string { - brand := m.styles.HeaderBrand.Render(config.AppName) + brand := m.styles.HeaderBrand.Render(config.CliName) project := m.styles.HeaderMeta.Render(fmt.Sprintf("project: %s", m.cfg.ActiveProject)) board := m.styles.FilterHint.Render(fmt.Sprintf("list: %s", m.boardFilter.Label())) count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount()))