feat: Set and Forget - Seamless Gas Town Integration (#255)
Adds shell integration for automatic Gas Town context detection. Features: - `gt enable` / `gt disable` - Global on/off switch - `gt shell install|remove|status` - Shell integration management - `gt rig quick-add [path]` - One-command project setup - `gt uninstall` - Clean removal with options - Shell hook auto-sets GT_TOWN_ROOT/GT_RIG on cd Implementation: - XDG-compliant state storage (~/.local/state/gastown/) - Safe RC file manipulation with block markers - Environment overrides (GASTOWN_DISABLED/ENABLED) - Doctor check for global state validation Co-authored-by: Sohail Mohammad <sohailm25@gmail.com>
This commit is contained in:
188
internal/state/state.go
Normal file
188
internal/state/state.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// ABOUTME: Global state management for Gas Town enable/disable toggle.
|
||||
// ABOUTME: Uses XDG-compliant paths for per-machine state storage.
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// State represents the global Gas Town state.
|
||||
type State struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Version string `json:"version"`
|
||||
MachineID string `json:"machine_id"`
|
||||
InstalledAt time.Time `json:"installed_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ShellIntegration string `json:"shell_integration,omitempty"`
|
||||
LastDoctorRun time.Time `json:"last_doctor_run,omitempty"`
|
||||
}
|
||||
|
||||
// StateDir returns the XDG-compliant state directory.
|
||||
// Uses ~/.local/state/gastown/ (per XDG Base Directory Specification).
|
||||
func StateDir() string {
|
||||
// Check XDG_STATE_HOME first
|
||||
if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "gastown")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".local", "state", "gastown")
|
||||
}
|
||||
|
||||
// ConfigDir returns the XDG-compliant config directory.
|
||||
// Uses ~/.config/gastown/
|
||||
func ConfigDir() string {
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "gastown")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gastown")
|
||||
}
|
||||
|
||||
// CacheDir returns the XDG-compliant cache directory.
|
||||
// Uses ~/.cache/gastown/
|
||||
func CacheDir() string {
|
||||
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "gastown")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".cache", "gastown")
|
||||
}
|
||||
|
||||
// StatePath returns the path to state.json.
|
||||
func StatePath() string {
|
||||
return filepath.Join(StateDir(), "state.json")
|
||||
}
|
||||
|
||||
// IsEnabled checks if Gas Town is globally enabled.
|
||||
// Priority: env override > state file > default (false)
|
||||
func IsEnabled() bool {
|
||||
// Environment overrides take priority
|
||||
if os.Getenv("GASTOWN_DISABLED") == "1" {
|
||||
return false
|
||||
}
|
||||
if os.Getenv("GASTOWN_ENABLED") == "1" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check state file
|
||||
state, err := Load()
|
||||
if err != nil {
|
||||
return false // Default to disabled if state unreadable
|
||||
}
|
||||
return state.Enabled
|
||||
}
|
||||
|
||||
// Load reads the state from disk.
|
||||
func Load() (*State, error) {
|
||||
data, err := os.ReadFile(StatePath())
|
||||
if os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// Save writes the state to disk atomically.
|
||||
func Save(s *State) error {
|
||||
dir := StateDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.UpdatedAt = time.Now()
|
||||
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Atomic write via temp file
|
||||
tmp := StatePath() + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, StatePath())
|
||||
}
|
||||
|
||||
// Enable enables Gas Town globally.
|
||||
func Enable(version string) error {
|
||||
s, err := Load()
|
||||
if err != nil {
|
||||
// Create new state
|
||||
s = &State{
|
||||
InstalledAt: time.Now(),
|
||||
MachineID: generateMachineID(),
|
||||
}
|
||||
}
|
||||
|
||||
s.Enabled = true
|
||||
s.Version = version
|
||||
return Save(s)
|
||||
}
|
||||
|
||||
// Disable disables Gas Town globally.
|
||||
func Disable() error {
|
||||
s, err := Load()
|
||||
if err != nil {
|
||||
// Nothing to disable, create disabled state
|
||||
s = &State{
|
||||
InstalledAt: time.Now(),
|
||||
MachineID: generateMachineID(),
|
||||
Enabled: false,
|
||||
}
|
||||
return Save(s)
|
||||
}
|
||||
|
||||
s.Enabled = false
|
||||
return Save(s)
|
||||
}
|
||||
|
||||
// generateMachineID creates a unique machine identifier.
|
||||
func generateMachineID() string {
|
||||
return uuid.New().String()[:8]
|
||||
}
|
||||
|
||||
// GetMachineID returns the machine ID, creating one if needed.
|
||||
func GetMachineID() string {
|
||||
s, err := Load()
|
||||
if err != nil || s.MachineID == "" {
|
||||
return generateMachineID()
|
||||
}
|
||||
return s.MachineID
|
||||
}
|
||||
|
||||
// SetShellIntegration records which shell integration is installed.
|
||||
func SetShellIntegration(shell string) error {
|
||||
s, err := Load()
|
||||
if err != nil {
|
||||
s = &State{
|
||||
InstalledAt: time.Now(),
|
||||
MachineID: generateMachineID(),
|
||||
}
|
||||
}
|
||||
s.ShellIntegration = shell
|
||||
return Save(s)
|
||||
}
|
||||
|
||||
// RecordDoctorRun records when doctor was last run.
|
||||
func RecordDoctorRun() error {
|
||||
s, err := Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.LastDoctorRun = time.Now()
|
||||
return Save(s)
|
||||
}
|
||||
131
internal/state/state_test.go
Normal file
131
internal/state/state_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// ABOUTME: Tests for global state management.
|
||||
// ABOUTME: Verifies enable/disable toggle and XDG path resolution.
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStateDir(t *testing.T) {
|
||||
home, _ := os.UserHomeDir()
|
||||
expected := filepath.Join(home, ".local", "state", "gastown")
|
||||
|
||||
os.Unsetenv("XDG_STATE_HOME")
|
||||
if got := StateDir(); got != expected {
|
||||
t.Errorf("StateDir() = %q, want %q", got, expected)
|
||||
}
|
||||
|
||||
os.Setenv("XDG_STATE_HOME", "/custom/state")
|
||||
defer os.Unsetenv("XDG_STATE_HOME")
|
||||
if got := StateDir(); got != "/custom/state/gastown" {
|
||||
t.Errorf("StateDir() with XDG = %q, want /custom/state/gastown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDir(t *testing.T) {
|
||||
home, _ := os.UserHomeDir()
|
||||
expected := filepath.Join(home, ".config", "gastown")
|
||||
|
||||
os.Unsetenv("XDG_CONFIG_HOME")
|
||||
if got := ConfigDir(); got != expected {
|
||||
t.Errorf("ConfigDir() = %q, want %q", got, expected)
|
||||
}
|
||||
|
||||
os.Setenv("XDG_CONFIG_HOME", "/custom/config")
|
||||
defer os.Unsetenv("XDG_CONFIG_HOME")
|
||||
if got := ConfigDir(); got != "/custom/config/gastown" {
|
||||
t.Errorf("ConfigDir() with XDG = %q, want /custom/config/gastown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheDir(t *testing.T) {
|
||||
home, _ := os.UserHomeDir()
|
||||
expected := filepath.Join(home, ".cache", "gastown")
|
||||
|
||||
os.Unsetenv("XDG_CACHE_HOME")
|
||||
if got := CacheDir(); got != expected {
|
||||
t.Errorf("CacheDir() = %q, want %q", got, expected)
|
||||
}
|
||||
|
||||
os.Setenv("XDG_CACHE_HOME", "/custom/cache")
|
||||
defer os.Unsetenv("XDG_CACHE_HOME")
|
||||
if got := CacheDir(); got != "/custom/cache/gastown" {
|
||||
t.Errorf("CacheDir() with XDG = %q, want /custom/cache/gastown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEnabled_EnvOverride(t *testing.T) {
|
||||
os.Setenv("GASTOWN_DISABLED", "1")
|
||||
defer os.Unsetenv("GASTOWN_DISABLED")
|
||||
if IsEnabled() {
|
||||
t.Error("IsEnabled() should return false when GASTOWN_DISABLED=1")
|
||||
}
|
||||
|
||||
os.Unsetenv("GASTOWN_DISABLED")
|
||||
os.Setenv("GASTOWN_ENABLED", "1")
|
||||
defer os.Unsetenv("GASTOWN_ENABLED")
|
||||
if !IsEnabled() {
|
||||
t.Error("IsEnabled() should return true when GASTOWN_ENABLED=1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEnabled_DisabledOverridesEnabled(t *testing.T) {
|
||||
os.Setenv("GASTOWN_DISABLED", "1")
|
||||
os.Setenv("GASTOWN_ENABLED", "1")
|
||||
defer os.Unsetenv("GASTOWN_DISABLED")
|
||||
defer os.Unsetenv("GASTOWN_ENABLED")
|
||||
|
||||
if IsEnabled() {
|
||||
t.Error("GASTOWN_DISABLED should take precedence over GASTOWN_ENABLED")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableDisable(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
os.Setenv("XDG_STATE_HOME", tmpDir)
|
||||
defer os.Unsetenv("XDG_STATE_HOME")
|
||||
os.Unsetenv("GASTOWN_DISABLED")
|
||||
os.Unsetenv("GASTOWN_ENABLED")
|
||||
|
||||
if err := Enable("1.0.0"); err != nil {
|
||||
t.Fatalf("Enable() failed: %v", err)
|
||||
}
|
||||
|
||||
if !IsEnabled() {
|
||||
t.Error("IsEnabled() should return true after Enable()")
|
||||
}
|
||||
|
||||
s, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() failed: %v", err)
|
||||
}
|
||||
if s.Version != "1.0.0" {
|
||||
t.Errorf("State.Version = %q, want %q", s.Version, "1.0.0")
|
||||
}
|
||||
if s.MachineID == "" {
|
||||
t.Error("State.MachineID should not be empty")
|
||||
}
|
||||
|
||||
if err := Disable(); err != nil {
|
||||
t.Fatalf("Disable() failed: %v", err)
|
||||
}
|
||||
|
||||
if IsEnabled() {
|
||||
t.Error("IsEnabled() should return false after Disable()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMachineID(t *testing.T) {
|
||||
id1 := generateMachineID()
|
||||
id2 := generateMachineID()
|
||||
|
||||
if len(id1) != 8 {
|
||||
t.Errorf("generateMachineID() length = %d, want 8", len(id1))
|
||||
}
|
||||
if id1 == id2 {
|
||||
t.Error("generateMachineID() should generate unique IDs")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user