Adds support for managing multiple Claude Code accounts in Gas Town: - accounts.json config parsing in mayor/ directory - gt account list/add/default commands - GT_ACCOUNT env var support with priority resolution - --account flag on gt spawn and gt crew at commands - CLAUDE_CONFIG_DIR injection into tmux sessions Priority order: GT_ACCOUNT env var > --account flag > default from config 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
552 lines
13 KiB
Go
552 lines
13 KiB
Go
package config
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestTownConfigRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "mayor", "town.json")
|
|
|
|
original := &TownConfig{
|
|
Type: "town",
|
|
Version: 1,
|
|
Name: "test-town",
|
|
CreatedAt: time.Now().Truncate(time.Second),
|
|
}
|
|
|
|
if err := SaveTownConfig(path, original); err != nil {
|
|
t.Fatalf("SaveTownConfig: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadTownConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadTownConfig: %v", err)
|
|
}
|
|
|
|
if loaded.Name != original.Name {
|
|
t.Errorf("Name = %q, want %q", loaded.Name, original.Name)
|
|
}
|
|
if loaded.Type != original.Type {
|
|
t.Errorf("Type = %q, want %q", loaded.Type, original.Type)
|
|
}
|
|
}
|
|
|
|
func TestRigsConfigRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "mayor", "rigs.json")
|
|
|
|
original := &RigsConfig{
|
|
Version: 1,
|
|
Rigs: map[string]RigEntry{
|
|
"gastown": {
|
|
GitURL: "git@github.com:steveyegge/gastown.git",
|
|
AddedAt: time.Now().Truncate(time.Second),
|
|
BeadsConfig: &BeadsConfig{
|
|
Repo: "local",
|
|
Prefix: "gt-",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := SaveRigsConfig(path, original); err != nil {
|
|
t.Fatalf("SaveRigsConfig: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadRigsConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadRigsConfig: %v", err)
|
|
}
|
|
|
|
if len(loaded.Rigs) != 1 {
|
|
t.Errorf("Rigs count = %d, want 1", len(loaded.Rigs))
|
|
}
|
|
|
|
rig, ok := loaded.Rigs["gastown"]
|
|
if !ok {
|
|
t.Fatal("missing 'gastown' rig")
|
|
}
|
|
if rig.BeadsConfig == nil || rig.BeadsConfig.Prefix != "gt-" {
|
|
t.Errorf("BeadsConfig.Prefix = %v, want 'gt-'", rig.BeadsConfig)
|
|
}
|
|
}
|
|
|
|
func TestAgentStateRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "state.json")
|
|
|
|
original := &AgentState{
|
|
Role: "mayor",
|
|
LastActive: time.Now().Truncate(time.Second),
|
|
Session: "abc123",
|
|
Extra: map[string]any{
|
|
"custom": "value",
|
|
},
|
|
}
|
|
|
|
if err := SaveAgentState(path, original); err != nil {
|
|
t.Fatalf("SaveAgentState: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadAgentState(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadAgentState: %v", err)
|
|
}
|
|
|
|
if loaded.Role != original.Role {
|
|
t.Errorf("Role = %q, want %q", loaded.Role, original.Role)
|
|
}
|
|
if loaded.Session != original.Session {
|
|
t.Errorf("Session = %q, want %q", loaded.Session, original.Session)
|
|
}
|
|
}
|
|
|
|
func TestLoadTownConfigNotFound(t *testing.T) {
|
|
_, err := LoadTownConfig("/nonexistent/path.json")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestValidationErrors(t *testing.T) {
|
|
// Missing name
|
|
tc := &TownConfig{Type: "town", Version: 1}
|
|
if err := validateTownConfig(tc); err == nil {
|
|
t.Error("expected error for missing name")
|
|
}
|
|
|
|
// Wrong type
|
|
tc = &TownConfig{Type: "wrong", Version: 1, Name: "test"}
|
|
if err := validateTownConfig(tc); err == nil {
|
|
t.Error("expected error for wrong type")
|
|
}
|
|
|
|
// Missing role
|
|
as := &AgentState{}
|
|
if err := validateAgentState(as); err == nil {
|
|
t.Error("expected error for missing role")
|
|
}
|
|
}
|
|
|
|
func TestRigConfigRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.json")
|
|
|
|
original := NewRigConfig("gastown", "git@github.com:test/gastown.git")
|
|
original.CreatedAt = time.Now().Truncate(time.Second)
|
|
original.Beads = &BeadsConfig{Prefix: "gt-"}
|
|
|
|
if err := SaveRigConfig(path, original); err != nil {
|
|
t.Fatalf("SaveRigConfig: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadRigConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadRigConfig: %v", err)
|
|
}
|
|
|
|
if loaded.Type != "rig" {
|
|
t.Errorf("Type = %q, want 'rig'", loaded.Type)
|
|
}
|
|
if loaded.Version != CurrentRigConfigVersion {
|
|
t.Errorf("Version = %d, want %d", loaded.Version, CurrentRigConfigVersion)
|
|
}
|
|
if loaded.Name != "gastown" {
|
|
t.Errorf("Name = %q, want 'gastown'", loaded.Name)
|
|
}
|
|
if loaded.GitURL != "git@github.com:test/gastown.git" {
|
|
t.Errorf("GitURL = %q, want expected URL", loaded.GitURL)
|
|
}
|
|
if loaded.Beads == nil || loaded.Beads.Prefix != "gt-" {
|
|
t.Error("Beads.Prefix not preserved")
|
|
}
|
|
}
|
|
|
|
func TestRigSettingsRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "settings", "config.json")
|
|
|
|
original := NewRigSettings()
|
|
|
|
if err := SaveRigSettings(path, original); err != nil {
|
|
t.Fatalf("SaveRigSettings: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadRigSettings(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadRigSettings: %v", err)
|
|
}
|
|
|
|
if loaded.Type != "rig-settings" {
|
|
t.Errorf("Type = %q, want 'rig-settings'", loaded.Type)
|
|
}
|
|
if loaded.MergeQueue == nil {
|
|
t.Fatal("MergeQueue is nil")
|
|
}
|
|
if !loaded.MergeQueue.Enabled {
|
|
t.Error("MergeQueue.Enabled = false, want true")
|
|
}
|
|
if loaded.MergeQueue.TargetBranch != "main" {
|
|
t.Errorf("MergeQueue.TargetBranch = %q, want 'main'", loaded.MergeQueue.TargetBranch)
|
|
}
|
|
}
|
|
|
|
func TestRigSettingsWithCustomMergeQueue(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "settings.json")
|
|
|
|
original := &RigSettings{
|
|
Type: "rig-settings",
|
|
Version: 1,
|
|
MergeQueue: &MergeQueueConfig{
|
|
Enabled: true,
|
|
TargetBranch: "develop",
|
|
IntegrationBranches: false,
|
|
OnConflict: OnConflictAutoRebase,
|
|
RunTests: true,
|
|
TestCommand: "make test",
|
|
DeleteMergedBranches: false,
|
|
RetryFlakyTests: 3,
|
|
PollInterval: "1m",
|
|
MaxConcurrent: 2,
|
|
},
|
|
}
|
|
|
|
if err := SaveRigSettings(path, original); err != nil {
|
|
t.Fatalf("SaveRigSettings: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadRigSettings(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadRigSettings: %v", err)
|
|
}
|
|
|
|
mq := loaded.MergeQueue
|
|
if mq.TargetBranch != "develop" {
|
|
t.Errorf("TargetBranch = %q, want 'develop'", mq.TargetBranch)
|
|
}
|
|
if mq.OnConflict != OnConflictAutoRebase {
|
|
t.Errorf("OnConflict = %q, want %q", mq.OnConflict, OnConflictAutoRebase)
|
|
}
|
|
if mq.TestCommand != "make test" {
|
|
t.Errorf("TestCommand = %q, want 'make test'", mq.TestCommand)
|
|
}
|
|
if mq.RetryFlakyTests != 3 {
|
|
t.Errorf("RetryFlakyTests = %d, want 3", mq.RetryFlakyTests)
|
|
}
|
|
}
|
|
|
|
func TestRigConfigValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config *RigConfig
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid config",
|
|
config: &RigConfig{
|
|
Type: "rig",
|
|
Version: 1,
|
|
Name: "test-rig",
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "missing name",
|
|
config: &RigConfig{
|
|
Type: "rig",
|
|
Version: 1,
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "wrong type",
|
|
config: &RigConfig{
|
|
Type: "wrong",
|
|
Version: 1,
|
|
Name: "test",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateRigConfig(tt.config)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("validateRigConfig() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRigSettingsValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
settings *RigSettings
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid settings",
|
|
settings: &RigSettings{
|
|
Type: "rig-settings",
|
|
Version: 1,
|
|
MergeQueue: DefaultMergeQueueConfig(),
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid settings without merge queue",
|
|
settings: &RigSettings{
|
|
Type: "rig-settings",
|
|
Version: 1,
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "wrong type",
|
|
settings: &RigSettings{
|
|
Type: "wrong",
|
|
Version: 1,
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid on_conflict",
|
|
settings: &RigSettings{
|
|
Type: "rig-settings",
|
|
Version: 1,
|
|
MergeQueue: &MergeQueueConfig{
|
|
OnConflict: "invalid",
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid poll_interval",
|
|
settings: &RigSettings{
|
|
Type: "rig-settings",
|
|
Version: 1,
|
|
MergeQueue: &MergeQueueConfig{
|
|
PollInterval: "not-a-duration",
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateRigSettings(tt.settings)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("validateRigSettings() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDefaultMergeQueueConfig(t *testing.T) {
|
|
cfg := DefaultMergeQueueConfig()
|
|
|
|
if !cfg.Enabled {
|
|
t.Error("Enabled should be true by default")
|
|
}
|
|
if cfg.TargetBranch != "main" {
|
|
t.Errorf("TargetBranch = %q, want 'main'", cfg.TargetBranch)
|
|
}
|
|
if !cfg.IntegrationBranches {
|
|
t.Error("IntegrationBranches should be true by default")
|
|
}
|
|
if cfg.OnConflict != OnConflictAssignBack {
|
|
t.Errorf("OnConflict = %q, want %q", cfg.OnConflict, OnConflictAssignBack)
|
|
}
|
|
if !cfg.RunTests {
|
|
t.Error("RunTests should be true by default")
|
|
}
|
|
if cfg.TestCommand != "go test ./..." {
|
|
t.Errorf("TestCommand = %q, want 'go test ./...'", cfg.TestCommand)
|
|
}
|
|
if !cfg.DeleteMergedBranches {
|
|
t.Error("DeleteMergedBranches should be true by default")
|
|
}
|
|
if cfg.RetryFlakyTests != 1 {
|
|
t.Errorf("RetryFlakyTests = %d, want 1", cfg.RetryFlakyTests)
|
|
}
|
|
if cfg.PollInterval != "30s" {
|
|
t.Errorf("PollInterval = %q, want '30s'", cfg.PollInterval)
|
|
}
|
|
if cfg.MaxConcurrent != 1 {
|
|
t.Errorf("MaxConcurrent = %d, want 1", cfg.MaxConcurrent)
|
|
}
|
|
}
|
|
|
|
func TestLoadRigConfigNotFound(t *testing.T) {
|
|
_, err := LoadRigConfig("/nonexistent/path.json")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestLoadRigSettingsNotFound(t *testing.T) {
|
|
_, err := LoadRigSettings("/nonexistent/path.json")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestMayorConfigRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "mayor", "config.json")
|
|
|
|
original := NewMayorConfig()
|
|
original.Theme = &TownThemeConfig{
|
|
RoleDefaults: map[string]string{
|
|
"witness": "rust",
|
|
},
|
|
}
|
|
|
|
if err := SaveMayorConfig(path, original); err != nil {
|
|
t.Fatalf("SaveMayorConfig: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadMayorConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadMayorConfig: %v", err)
|
|
}
|
|
|
|
if loaded.Type != "mayor-config" {
|
|
t.Errorf("Type = %q, want 'mayor-config'", loaded.Type)
|
|
}
|
|
if loaded.Version != CurrentMayorConfigVersion {
|
|
t.Errorf("Version = %d, want %d", loaded.Version, CurrentMayorConfigVersion)
|
|
}
|
|
if loaded.Theme == nil || loaded.Theme.RoleDefaults["witness"] != "rust" {
|
|
t.Error("Theme.RoleDefaults not preserved")
|
|
}
|
|
}
|
|
|
|
func TestLoadMayorConfigNotFound(t *testing.T) {
|
|
_, err := LoadMayorConfig("/nonexistent/path.json")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestAccountsConfigRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "mayor", "accounts.json")
|
|
|
|
original := NewAccountsConfig()
|
|
original.Accounts["yegge"] = Account{
|
|
Email: "steve.yegge@gmail.com",
|
|
Description: "Personal account",
|
|
ConfigDir: "~/.claude-accounts/yegge",
|
|
}
|
|
original.Accounts["ghosttrack"] = Account{
|
|
Email: "steve@ghosttrack.com",
|
|
Description: "Business account",
|
|
ConfigDir: "~/.claude-accounts/ghosttrack",
|
|
}
|
|
original.Default = "ghosttrack"
|
|
|
|
if err := SaveAccountsConfig(path, original); err != nil {
|
|
t.Fatalf("SaveAccountsConfig: %v", err)
|
|
}
|
|
|
|
loaded, err := LoadAccountsConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadAccountsConfig: %v", err)
|
|
}
|
|
|
|
if loaded.Version != CurrentAccountsVersion {
|
|
t.Errorf("Version = %d, want %d", loaded.Version, CurrentAccountsVersion)
|
|
}
|
|
if len(loaded.Accounts) != 2 {
|
|
t.Errorf("Accounts count = %d, want 2", len(loaded.Accounts))
|
|
}
|
|
if loaded.Default != "ghosttrack" {
|
|
t.Errorf("Default = %q, want 'ghosttrack'", loaded.Default)
|
|
}
|
|
|
|
yegge := loaded.GetAccount("yegge")
|
|
if yegge == nil {
|
|
t.Fatal("GetAccount('yegge') returned nil")
|
|
}
|
|
if yegge.Email != "steve.yegge@gmail.com" {
|
|
t.Errorf("yegge.Email = %q, want 'steve.yegge@gmail.com'", yegge.Email)
|
|
}
|
|
|
|
defAcct := loaded.GetDefaultAccount()
|
|
if defAcct == nil {
|
|
t.Fatal("GetDefaultAccount() returned nil")
|
|
}
|
|
if defAcct.Email != "steve@ghosttrack.com" {
|
|
t.Errorf("default.Email = %q, want 'steve@ghosttrack.com'", defAcct.Email)
|
|
}
|
|
}
|
|
|
|
func TestAccountsConfigValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config *AccountsConfig
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid empty config",
|
|
config: NewAccountsConfig(),
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid config with accounts",
|
|
config: &AccountsConfig{
|
|
Version: 1,
|
|
Accounts: map[string]Account{
|
|
"test": {Email: "test@example.com", ConfigDir: "~/.claude-accounts/test"},
|
|
},
|
|
Default: "test",
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "default refers to nonexistent account",
|
|
config: &AccountsConfig{
|
|
Version: 1,
|
|
Accounts: map[string]Account{
|
|
"test": {Email: "test@example.com", ConfigDir: "~/.claude-accounts/test"},
|
|
},
|
|
Default: "nonexistent",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "account missing config_dir",
|
|
config: &AccountsConfig{
|
|
Version: 1,
|
|
Accounts: map[string]Account{
|
|
"test": {Email: "test@example.com"},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateAccountsConfig(tt.config)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("validateAccountsConfig() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadAccountsConfigNotFound(t *testing.T) {
|
|
_, err := LoadAccountsConfig("/nonexistent/path.json")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent file")
|
|
}
|
|
}
|