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:
Sohail Mohammad
2026-01-08 22:25:01 -06:00
committed by GitHub
parent 41a758d6d8
commit 81bfe48ed3
21 changed files with 1751 additions and 11 deletions

188
internal/state/state.go Normal file
View 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)
}

View 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")
}
}