diff --git a/internal/doctor/config_check.go b/internal/doctor/config_check.go index 76ff9b3ee3..51d591f126 100644 --- a/internal/doctor/config_check.go +++ b/internal/doctor/config_check.go @@ -10,6 +10,7 @@ import ( "sort" "strings" + "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/constants" ) @@ -605,8 +606,8 @@ func containsFlag(s, flag string) bool { // CustomTypesCheck verifies Gas Town custom types are registered with beads. type CustomTypesCheck struct { FixableCheck - missingTypes []string // Cached during Run for use in Fix - townRoot string // Cached during Run for use in Fix + missingTypes []string // Cached during Run for use in Fix + targetBeadsDir string // Cached during Run for use in Fix } // NewCustomTypesCheck creates a new custom types check. @@ -633,9 +634,8 @@ func (c *CustomTypesCheck) Run(ctx *CheckContext) *CheckResult { } } - // Check if .beads directory exists at town level - townBeadsDir := filepath.Join(ctx.TownRoot, ".beads") - if _, err := os.Stat(townBeadsDir); os.IsNotExist(err) { + beadsDir := doctorConfigBeadsDir(ctx) + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { return &CheckResult{ Name: c.Name(), Status: StatusOK, @@ -646,11 +646,12 @@ func (c *CustomTypesCheck) Run(ctx *CheckContext) *CheckResult { // Get current custom types configuration // Use Output() not CombinedOutput() to avoid capturing bd's stderr messages cmd := exec.Command("bd", "config", "get", "types.custom") - cmd.Dir = ctx.TownRoot + cmd.Dir = beadsDir + cmd.Env = doctorConfigEnv(beadsDir) output, err := cmd.Output() if err != nil { // If config key doesn't exist, types are not configured - c.townRoot = ctx.TownRoot + c.targetBeadsDir = beadsDir c.missingTypes = constants.BeadsCustomTypesList() return &CheckResult{ Name: c.Name(), @@ -690,7 +691,7 @@ func (c *CustomTypesCheck) Run(ctx *CheckContext) *CheckResult { } // Cache for Fix - c.townRoot = ctx.TownRoot + c.targetBeadsDir = beadsDir c.missingTypes = missing return &CheckResult{ @@ -720,8 +721,33 @@ func parseConfigOutput(output []byte) string { // Fix registers the missing custom types. func (c *CustomTypesCheck) Fix(ctx *CheckContext) error { - cmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes) - cmd.Dir = c.townRoot + getCmd := exec.Command("bd", "config", "get", "types.custom") + getCmd.Dir = c.targetBeadsDir + getCmd.Env = doctorConfigEnv(c.targetBeadsDir) + existingOutput, _ := getCmd.Output() + + typeSet := make(map[string]bool) + if existing := parseConfigOutput(existingOutput); existing != "" { + for _, typ := range strings.Split(existing, ",") { + typ = strings.TrimSpace(typ) + if typ != "" { + typeSet[typ] = true + } + } + } + for _, typ := range constants.BeadsCustomTypesList() { + typeSet[typ] = true + } + + var merged []string + for typ := range typeSet { + merged = append(merged, typ) + } + sort.Strings(merged) + + cmd := exec.Command("bd", "config", "set", "types.custom", strings.Join(merged, ",")) + cmd.Dir = c.targetBeadsDir + cmd.Env = doctorConfigEnv(c.targetBeadsDir) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("bd config set types.custom: %s", strings.TrimSpace(string(output))) @@ -733,7 +759,7 @@ func (c *CustomTypesCheck) Fix(ctx *CheckContext) error { type CustomStatusesCheck struct { FixableCheck missingStatuses []string // Cached during Run for use in Fix - townRoot string // Cached during Run for use in Fix + targetBeadsDir string // Cached during Run for use in Fix } // NewCustomStatusesCheck creates a new custom statuses check. @@ -759,8 +785,8 @@ func (c *CustomStatusesCheck) Run(ctx *CheckContext) *CheckResult { } } - townBeadsDir := filepath.Join(ctx.TownRoot, ".beads") - if _, err := os.Stat(townBeadsDir); os.IsNotExist(err) { + beadsDir := doctorConfigBeadsDir(ctx) + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { return &CheckResult{ Name: c.Name(), Status: StatusOK, @@ -770,10 +796,11 @@ func (c *CustomStatusesCheck) Run(ctx *CheckContext) *CheckResult { // Get current custom statuses configuration cmd := exec.Command("bd", "config", "get", "status.custom") - cmd.Dir = ctx.TownRoot + cmd.Dir = beadsDir + cmd.Env = doctorConfigEnv(beadsDir) output, err := cmd.Output() if err != nil { - c.townRoot = ctx.TownRoot + c.targetBeadsDir = beadsDir c.missingStatuses = constants.BeadsCustomStatusesList() return &CheckResult{ Name: c.Name(), @@ -810,7 +837,7 @@ func (c *CustomStatusesCheck) Run(ctx *CheckContext) *CheckResult { } } - c.townRoot = ctx.TownRoot + c.targetBeadsDir = beadsDir c.missingStatuses = missing return &CheckResult{ @@ -830,7 +857,8 @@ func (c *CustomStatusesCheck) Run(ctx *CheckContext) *CheckResult { func (c *CustomStatusesCheck) Fix(ctx *CheckContext) error { // Read existing statuses getCmd := exec.Command("bd", "config", "get", "status.custom") - getCmd.Dir = c.townRoot + getCmd.Dir = c.targetBeadsDir + getCmd.Env = doctorConfigEnv(c.targetBeadsDir) existingOutput, _ := getCmd.Output() // Build merged set @@ -854,10 +882,45 @@ func (c *CustomStatusesCheck) Fix(ctx *CheckContext) error { sort.Strings(merged) cmd := exec.Command("bd", "config", "set", "status.custom", strings.Join(merged, ",")) - cmd.Dir = c.townRoot + cmd.Dir = c.targetBeadsDir + cmd.Env = doctorConfigEnv(c.targetBeadsDir) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("bd config set status.custom: %s", strings.TrimSpace(string(output))) } return nil } + +func doctorConfigBeadsDir(ctx *CheckContext) string { + workDir := ctx.TownRoot + if ctx.RigName != "" { + workDir = ctx.RigPath() + } + return beads.ResolveBeadsDir(workDir) +} + +func doctorConfigEnv(beadsDir string) []string { + env := stripEnvPrefixes(os.Environ(), "BEADS_DIR=", "BEADS_DB=", "BEADS_DOLT_SERVER_DATABASE=") + env = append(env, "BEADS_DIR="+beadsDir) + if dbEnv := beads.DatabaseEnv(beadsDir); dbEnv != "" { + env = append(env, dbEnv) + } + return env +} + +func stripEnvPrefixes(env []string, prefixes ...string) []string { + filtered := make([]string, 0, len(env)) + for _, entry := range env { + skip := false + for _, prefix := range prefixes { + if strings.HasPrefix(entry, prefix) { + skip = true + break + } + } + if !skip { + filtered = append(filtered, entry) + } + } + return filtered +} diff --git a/internal/doctor/config_check_test.go b/internal/doctor/config_check_test.go index d5267d4418..c13141a712 100644 --- a/internal/doctor/config_check_test.go +++ b/internal/doctor/config_check_test.go @@ -1,6 +1,7 @@ package doctor import ( + "fmt" "os" "path/filepath" "strings" @@ -9,6 +10,94 @@ import ( "github.com/steveyegge/gastown/internal/constants" ) +func installFakeBdForConfigChecks(t *testing.T, townRoot string) { + t.Helper() + + binDir := filepath.Join(townRoot, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatalf("mkdir fake bin: %v", err) + } + + script := `#!/bin/sh +set -eu + +target="${BEADS_DIR:-$PWD}" +if [ -d "$target/.beads" ]; then + target="$target/.beads" +fi + +case "$1:$2:$3" in + config:get:types.custom) + if [ -f "$target/types.custom" ]; then + cat "$target/types.custom" + else + exit 1 + fi + ;; + config:set:types.custom) + printf '%s\n' "$4" > "$target/types.custom" + ;; + config:get:status.custom) + if [ -f "$target/status.custom" ]; then + cat "$target/status.custom" + else + exit 1 + fi + ;; + config:set:status.custom) + printf '%s\n' "$4" > "$target/status.custom" + ;; + *) + echo "unexpected bd invocation: $*" >&2 + exit 1 + ;; +esac +` + + bdPath := filepath.Join(binDir, "bd") + if err := os.WriteFile(bdPath, []byte(script), 0755); err != nil { + t.Fatalf("write fake bd: %v", err) + } + + oldPath := os.Getenv("PATH") + if err := os.Setenv("PATH", fmt.Sprintf("%s:%s", binDir, oldPath)); err != nil { + t.Fatalf("set PATH: %v", err) + } + t.Cleanup(func() { + _ = os.Setenv("PATH", oldPath) + }) + for _, key := range []string{"BEADS_DIR", "BEADS_DB", "BEADS_DOLT_SERVER_DATABASE"} { + oldVal, hadVal := os.LookupEnv(key) + _ = os.Unsetenv(key) + t.Cleanup(func() { + if hadVal { + _ = os.Setenv(key, oldVal) + } else { + _ = os.Unsetenv(key) + } + }) + } +} + +func writeConfigCheckFile(t *testing.T, beadsDir, name, value string) { + t.Helper() + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("mkdir beads dir: %v", err) + } + if err := os.WriteFile(filepath.Join(beadsDir, name), []byte(value+"\n"), 0644); err != nil { + t.Fatalf("write %s: %v", name, err) + } +} + +func readConfigCheckFile(t *testing.T, beadsDir, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(beadsDir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return strings.TrimSpace(string(data)) +} + func TestSessionHookCheck_UsesSessionStartScript(t *testing.T) { check := NewSessionHookCheck() @@ -482,3 +571,122 @@ func TestCustomStatusesCheck_ParsesOutputWithNotePrefix(t *testing.T) { t.Errorf("After parsing, missing statuses: %v", missing) } } + +func TestCustomTypesCheck_UsesRigScopedBeadsDir(t *testing.T) { + townRoot := t.TempDir() + rigDir := filepath.Join(townRoot, "gastown") + townBeadsDir := filepath.Join(townRoot, ".beads") + rigBeadsDir := filepath.Join(rigDir, ".beads") + + writeConfigCheckFile(t, townBeadsDir, "types.custom", constants.BeadsCustomTypes) + writeConfigCheckFile(t, rigBeadsDir, "types.custom", "agent,role") + installFakeBdForConfigChecks(t, townRoot) + + check := NewCustomTypesCheck() + ctx := &CheckContext{TownRoot: townRoot, RigName: "gastown"} + + result := check.Run(ctx) + if result.Status != StatusWarning { + t.Fatalf("expected StatusWarning, got %v (%v)", result.Status, result.Details) + } + if check.targetBeadsDir != rigBeadsDir { + t.Fatalf("Run cached %q, want %q", check.targetBeadsDir, rigBeadsDir) + } + + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix failed: %v", err) + } + + gotTypes := strings.Split(readConfigCheckFile(t, rigBeadsDir, "types.custom"), ",") + wantTypes := make(map[string]struct{}) + for _, item := range constants.BeadsCustomTypesList() { + wantTypes[item] = struct{}{} + } + for _, item := range gotTypes { + delete(wantTypes, item) + } + if len(wantTypes) != 0 { + t.Fatalf("rig types.custom missing expected entries after fix: %v (got %q)", wantTypes, strings.Join(gotTypes, ",")) + } + if got := readConfigCheckFile(t, townBeadsDir, "types.custom"); got != constants.BeadsCustomTypes { + t.Fatalf("town types.custom changed unexpectedly: %q", got) + } + + result = check.Run(ctx) + if result.Status != StatusOK { + t.Fatalf("expected StatusOK after fix, got %v (%v)", result.Status, result.Details) + } +} + +func TestCustomTypesCheck_FixPreservesExistingRigTypes(t *testing.T) { + townRoot := t.TempDir() + rigDir := filepath.Join(townRoot, "gastown") + townBeadsDir := filepath.Join(townRoot, ".beads") + rigBeadsDir := filepath.Join(rigDir, ".beads") + + writeConfigCheckFile(t, townBeadsDir, "types.custom", constants.BeadsCustomTypes) + writeConfigCheckFile(t, rigBeadsDir, "types.custom", "agent,role,external") + installFakeBdForConfigChecks(t, townRoot) + + check := NewCustomTypesCheck() + ctx := &CheckContext{TownRoot: townRoot, RigName: "gastown"} + + result := check.Run(ctx) + if result.Status != StatusWarning { + t.Fatalf("expected StatusWarning, got %v (%v)", result.Status, result.Details) + } + + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix failed: %v", err) + } + + got := strings.Split(readConfigCheckFile(t, rigBeadsDir, "types.custom"), ",") + wantSet := make(map[string]struct{}) + for _, item := range append(constants.BeadsCustomTypesList(), "external") { + wantSet[item] = struct{}{} + } + for _, item := range got { + delete(wantSet, item) + } + if len(wantSet) != 0 { + t.Fatalf("rig types.custom missing expected entries after fix: %v (got %q)", wantSet, strings.Join(got, ",")) + } +} + +func TestCustomStatusesCheck_UsesRigScopedBeadsDir(t *testing.T) { + townRoot := t.TempDir() + rigDir := filepath.Join(townRoot, "gastown") + townBeadsDir := filepath.Join(townRoot, ".beads") + rigBeadsDir := filepath.Join(rigDir, ".beads") + + writeConfigCheckFile(t, townBeadsDir, "status.custom", constants.BeadsCustomStatuses) + writeConfigCheckFile(t, rigBeadsDir, "status.custom", "queued") + installFakeBdForConfigChecks(t, townRoot) + + check := NewCustomStatusesCheck() + ctx := &CheckContext{TownRoot: townRoot, RigName: "gastown"} + + result := check.Run(ctx) + if result.Status != StatusWarning { + t.Fatalf("expected StatusWarning, got %v (%v)", result.Status, result.Details) + } + if check.targetBeadsDir != rigBeadsDir { + t.Fatalf("Run cached %q, want %q", check.targetBeadsDir, rigBeadsDir) + } + + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix failed: %v", err) + } + + if got := readConfigCheckFile(t, rigBeadsDir, "status.custom"); got != "queued,"+constants.BeadsCustomStatuses { + t.Fatalf("rig status.custom = %q", got) + } + if got := readConfigCheckFile(t, townBeadsDir, "status.custom"); got != constants.BeadsCustomStatuses { + t.Fatalf("town status.custom changed unexpectedly: %q", got) + } + + result = check.Run(ctx) + if result.Status != StatusOK { + t.Fatalf("expected StatusOK after fix, got %v (%v)", result.Status, result.Details) + } +}