From d6a52c61d1c9e539bbf8c46530fa5562c467e4d3 Mon Sep 17 00:00:00 2001 From: Joseph Goksu Date: Mon, 9 Mar 2026 10:16:10 +0000 Subject: [PATCH 1/2] feat(migration): auto-migrate configs after CLI upgrade After `brew upgrade`, users have stale configs (old tw-* slash command files and legacy taskwing-mcp MCP entries). This adds a lightweight version check to PersistentPreRunE that detects version mismatches and silently regenerates local configs on the next command. - Add internal/migration package with CheckAndMigrate() - Hook into root command PersistentPreRunE (skips version/help/mcp) - Stamp .taskwing/version during bootstrap for change detection - Silently regenerate slash commands for managed AIs on version change - Warn (stderr) about legacy global MCP server names - Sub-millisecond happy path (1 stat + 1 read + string compare) - 7 tests covering skip cases, migration, idempotency, and warnings --- cmd/bootstrap.go | 1 + cmd/root.go | 34 ++++- internal/bootstrap/initializer.go | 30 +++++ internal/bootstrap/service.go | 6 + internal/migration/upgrade.go | 127 ++++++++++++++++++ internal/migration/upgrade_test.go | 204 +++++++++++++++++++++++++++++ 6 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 internal/migration/upgrade.go create mode 100644 internal/migration/upgrade_test.go diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index fce5ebb..984dfed 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -195,6 +195,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error { // Initialize Service svc := bootstrap.NewService(cwd, llmCfg) + svc.SetVersion(version) // Prompt for repo selection in multi-repo workspaces. // This must happen before the action loop because ActionInitProject may not diff --git a/cmd/root.go b/cmd/root.go index dddfedb..93859d9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,6 +12,7 @@ import ( "github.com/josephgoksu/TaskWing/internal/config" "github.com/josephgoksu/TaskWing/internal/logger" + "github.com/josephgoksu/TaskWing/internal/migration" "github.com/josephgoksu/TaskWing/internal/telemetry" "github.com/josephgoksu/TaskWing/internal/ui" "github.com/spf13/cobra" @@ -55,7 +56,13 @@ var rootCmd = &cobra.Command{ Create a plan, execute tasks with your AI tool, and keep architecture context persistent across sessions.`, - PersistentPreRunE: initTelemetry, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if err := initTelemetry(cmd, args); err != nil { + return err + } + maybeRunPostUpgradeMigration(cmd) + return nil + }, PersistentPostRunE: closeTelemetry, Run: func(cmd *cobra.Command, args []string) { if len(args) == 0 { @@ -173,6 +180,31 @@ func GetVersion() string { return version } +// maybeRunPostUpgradeMigration runs a one-time migration when the CLI version +// changes (e.g., after brew upgrade). Skips commands that don't need project context. +func maybeRunPostUpgradeMigration(cmd *cobra.Command) { + // Skip commands that don't need project context + name := cmd.Name() + if name == "version" || name == "help" || name == "mcp" { + return + } + + cwd, err := os.Getwd() + if err != nil { + return + } + + warnings, err := migration.CheckAndMigrate(cwd, version) + if err != nil { + // Migration errors are non-fatal + return + } + + for _, w := range warnings { + fmt.Fprintf(os.Stderr, "⚠️ %s\n", w) + } +} + // initTelemetry initializes the telemetry client. // It checks for: // 1. --no-telemetry flag (disables for this command) diff --git a/internal/bootstrap/initializer.go b/internal/bootstrap/initializer.go index 8f0a722..91b058f 100644 --- a/internal/bootstrap/initializer.go +++ b/internal/bootstrap/initializer.go @@ -16,6 +16,9 @@ import ( // Initializer handles the setup of TaskWing project structure and integrations. type Initializer struct { basePath string + // Version is the CLI version to stamp in .taskwing/version. + // If empty, no version file is written. + Version string } func NewInitializer(basePath string) *Initializer { @@ -216,6 +219,13 @@ func (i *Initializer) createStructure(verbose bool) error { fmt.Printf(" ✓ Created %s\n", dir) } } + + // Track CLI version for post-upgrade migration detection + if i.Version != "" { + versionPath := filepath.Join(i.basePath, ".taskwing", "version") + _ = os.WriteFile(versionPath, []byte(i.Version), 0644) + } + return nil } @@ -248,6 +258,26 @@ var aiHelpers = func() map[string]aiHelperConfig { return cfg }() +// AIHelperInfo exposes read-only AI config fields needed by external packages. +type AIHelperInfo struct { + CommandsDir string + SingleFile bool + SingleFileName string +} + +// AIHelperByName returns exported config info for the named AI, if it exists. +func AIHelperByName(name string) (AIHelperInfo, bool) { + cfg, ok := aiHelpers[name] + if !ok { + return AIHelperInfo{}, false + } + return AIHelperInfo{ + CommandsDir: cfg.commandsDir, + SingleFile: cfg.singleFile, + SingleFileName: cfg.singleFileName, + }, true +} + // TaskWingManagedFile is the marker file name written to directories managed by TaskWing. // This file indicates that TaskWing created and owns the directory, preventing false positives // when users have similarly named directories for other purposes. diff --git a/internal/bootstrap/service.go b/internal/bootstrap/service.go index ffa6938..a59ef9c 100644 --- a/internal/bootstrap/service.go +++ b/internal/bootstrap/service.go @@ -40,6 +40,12 @@ func NewService(basePath string, llmCfg llm.Config) *Service { } } +// SetVersion sets the CLI version on the underlying initializer so that +// createStructure() stamps .taskwing/version for post-upgrade migration detection. +func (s *Service) SetVersion(v string) { + s.initializer.Version = v +} + // InitializeProject sets up the .taskwing directory structure and integrations. func (s *Service) InitializeProject(verbose bool, selectedAIs []string) error { return s.initializer.Run(verbose, selectedAIs) diff --git a/internal/migration/upgrade.go b/internal/migration/upgrade.go new file mode 100644 index 0000000..4ee7a9d --- /dev/null +++ b/internal/migration/upgrade.go @@ -0,0 +1,127 @@ +package migration + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/josephgoksu/TaskWing/internal/bootstrap" + "github.com/josephgoksu/TaskWing/internal/mcpcfg" +) + +// CheckAndMigrate runs a post-upgrade migration if the CLI version has changed +// since the last run in this project. It silently regenerates local configs and +// returns warnings for issues that require manual intervention (e.g., global MCP). +// +// This is designed to be called from PersistentPreRunE and must be: +// - Sub-millisecond on the happy path (version matches) +// - Non-fatal on all error paths (never blocks user commands) +func CheckAndMigrate(projectDir, currentVersion string) (warnings []string, err error) { + taskwingDir := filepath.Join(projectDir, ".taskwing") + versionFile := filepath.Join(taskwingDir, "version") + + // Not bootstrapped or inaccessible — nothing to migrate + if _, err := os.Stat(taskwingDir); err != nil { + return nil, nil + } + + stored, err := os.ReadFile(versionFile) + if err != nil { + // Version file missing (pre-migration bootstrap). Write current and return. + _ = os.WriteFile(versionFile, []byte(currentVersion), 0644) + return nil, nil + } + + storedVersion := strings.TrimSpace(string(stored)) + + // Happy path: version matches — no-op + if storedVersion == currentVersion { + return nil, nil + } + + // Skip dev builds to avoid constant re-runs during development + if currentVersion == "dev" { + return nil, nil + } + + // --- Version mismatch: run migration --- + + // 1. Silent local migration: regenerate slash commands for managed AIs + migrateLocalConfigs(projectDir) + + // 2. Global MCP check: warn about legacy server names + warnings = checkGlobalMCPLegacy() + + // 3. Write current version + _ = os.WriteFile(versionFile, []byte(currentVersion), 0644) + + return warnings, nil +} + +// migrateLocalConfigs detects which AIs have managed markers and regenerates +// their slash commands (which internally prunes stale tw-* files). +func migrateLocalConfigs(projectDir string) { + for _, aiName := range bootstrap.ValidAINames() { + cfg, ok := bootstrap.AIHelperByName(aiName) + if !ok { + continue + } + + // Check if this AI has a managed marker + if cfg.SingleFile { + // Single-file AIs (e.g., Copilot) embed the marker in file content. + // Check for the embedded marker before regenerating. + filePath := filepath.Join(projectDir, cfg.CommandsDir, cfg.SingleFileName) + content, err := os.ReadFile(filePath) + if err != nil || !strings.Contains(string(content), "") { + continue + } + } else { + markerPath := filepath.Join(projectDir, cfg.CommandsDir, bootstrap.TaskWingManagedFile) + if _, err := os.Stat(markerPath); err != nil { + continue + } + } + + // Regenerate (this prunes stale files and creates new ones) + init := bootstrap.NewInitializer(projectDir) + _ = init.CreateSlashCommands(aiName, false) + } +} + +// checkGlobalMCPLegacy reads Claude's global MCP config and warns if legacy +// server names are present. +func checkGlobalMCPLegacy() []string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + configPath := filepath.Join(home, ".claude", "claude_desktop_config.json") + return checkGlobalMCPLegacyAt(configPath) +} + +// checkGlobalMCPLegacyAt checks a specific config file path for legacy server names. +func checkGlobalMCPLegacyAt(configPath string) []string { + content, err := os.ReadFile(configPath) + if err != nil { + return nil + } + + var config struct { + MCPServers map[string]json.RawMessage `json:"mcpServers"` + } + if err := json.Unmarshal(content, &config); err != nil { + return nil + } + + var warnings []string + for name := range config.MCPServers { + if mcpcfg.IsLegacyServerName(name) { + warnings = append(warnings, fmt.Sprintf("Global MCP config has legacy server name %q. Run: taskwing doctor --fix --yes", name)) + } + } + + return warnings +} diff --git a/internal/migration/upgrade_test.go b/internal/migration/upgrade_test.go new file mode 100644 index 0000000..dddc3fa --- /dev/null +++ b/internal/migration/upgrade_test.go @@ -0,0 +1,204 @@ +package migration + +import ( + "os" + "path/filepath" + "testing" + + "github.com/josephgoksu/TaskWing/internal/bootstrap" +) + +func TestNoMigrationWhenNotBootstrapped(t *testing.T) { + dir := t.TempDir() + warnings, err := CheckAndMigrate(dir, "1.22.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + // Version file should not be created + if _, err := os.Stat(filepath.Join(dir, ".taskwing", "version")); !os.IsNotExist(err) { + t.Fatal("version file should not exist when not bootstrapped") + } +} + +func TestNoMigrationWhenVersionMatches(t *testing.T) { + dir := t.TempDir() + twDir := filepath.Join(dir, ".taskwing") + if err := os.MkdirAll(twDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(twDir, "version"), []byte("1.22.0"), 0644); err != nil { + t.Fatal(err) + } + + warnings, err := CheckAndMigrate(dir, "1.22.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } +} + +func TestNoMigrationForDevVersion(t *testing.T) { + dir := t.TempDir() + twDir := filepath.Join(dir, ".taskwing") + if err := os.MkdirAll(twDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(twDir, "version"), []byte("1.21.4"), 0644); err != nil { + t.Fatal(err) + } + + warnings, err := CheckAndMigrate(dir, "dev") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + + // Version file should NOT be updated to "dev" + stored, _ := os.ReadFile(filepath.Join(twDir, "version")) + if string(stored) != "1.21.4" { + t.Fatalf("version should remain 1.21.4, got %q", string(stored)) + } +} + +func TestMigrationRunsOnVersionChange(t *testing.T) { + dir := t.TempDir() + twDir := filepath.Join(dir, ".taskwing") + if err := os.MkdirAll(twDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(twDir, "version"), []byte("1.21.4"), 0644); err != nil { + t.Fatal(err) + } + + // Create a managed Claude commands directory with a legacy tw-ask.md file + claudeDir := filepath.Join(dir, ".claude", "commands") + if err := os.MkdirAll(claudeDir, 0755); err != nil { + t.Fatal(err) + } + markerContent := "# This directory is managed by TaskWing\n# AI: claude\n# Version: old\n" + if err := os.WriteFile(filepath.Join(claudeDir, bootstrap.TaskWingManagedFile), []byte(markerContent), 0644); err != nil { + t.Fatal(err) + } + // Write a legacy tw-ask.md file that should get pruned + if err := os.WriteFile(filepath.Join(claudeDir, "tw-ask.md"), []byte("legacy"), 0644); err != nil { + t.Fatal(err) + } + + warnings, err := CheckAndMigrate(dir, "1.22.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // No global MCP warnings expected in test (no real home dir config) + _ = warnings + + // Version file should be updated + stored, _ := os.ReadFile(filepath.Join(twDir, "version")) + if string(stored) != "1.22.0" { + t.Fatalf("version should be 1.22.0, got %q", string(stored)) + } + + // Legacy tw-ask.md should be removed + if _, err := os.Stat(filepath.Join(claudeDir, "tw-ask.md")); !os.IsNotExist(err) { + t.Fatal("legacy tw-ask.md should have been pruned") + } + + // New namespace directory should exist with regenerated commands + nsDir := filepath.Join(claudeDir, "taskwing") + if _, err := os.Stat(nsDir); os.IsNotExist(err) { + t.Fatal("taskwing/ namespace directory should have been created") + } +} + +func TestMigrationIdempotent(t *testing.T) { + dir := t.TempDir() + twDir := filepath.Join(dir, ".taskwing") + if err := os.MkdirAll(twDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(twDir, "version"), []byte("1.21.4"), 0644); err != nil { + t.Fatal(err) + } + + // Create managed Claude config + claudeDir := filepath.Join(dir, ".claude", "commands") + if err := os.MkdirAll(claudeDir, 0755); err != nil { + t.Fatal(err) + } + markerContent := "# This directory is managed by TaskWing\n# AI: claude\n# Version: old\n" + if err := os.WriteFile(filepath.Join(claudeDir, bootstrap.TaskWingManagedFile), []byte(markerContent), 0644); err != nil { + t.Fatal(err) + } + + // Run migration twice + _, err := CheckAndMigrate(dir, "1.22.0") + if err != nil { + t.Fatalf("first run: %v", err) + } + + // Second run should be a no-op (version now matches) + warnings, err := CheckAndMigrate(dir, "1.22.0") + if err != nil { + t.Fatalf("second run: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("second run should produce no warnings, got %v", warnings) + } +} + +func TestMigrationWritesVersionOnFirstEncounter(t *testing.T) { + dir := t.TempDir() + twDir := filepath.Join(dir, ".taskwing") + if err := os.MkdirAll(twDir, 0755); err != nil { + t.Fatal(err) + } + // No version file exists (pre-migration bootstrap) + + warnings, err := CheckAndMigrate(dir, "1.22.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } + + // Version file should be stamped + stored, err := os.ReadFile(filepath.Join(twDir, "version")) + if err != nil { + t.Fatal("version file should exist after first encounter") + } + if string(stored) != "1.22.0" { + t.Fatalf("version should be 1.22.0, got %q", string(stored)) + } +} + +func TestGlobalMCPWarning(t *testing.T) { + // checkGlobalMCPLegacy reads from the real home dir, which we can't + // easily mock. Instead, test the function directly with a temp config. + dir := t.TempDir() + configDir := filepath.Join(dir, ".claude") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + config := `{"mcpServers":{"taskwing-mcp":{"command":"taskwing","args":["mcp"]}}}` + configPath := filepath.Join(configDir, "claude_desktop_config.json") + if err := os.WriteFile(configPath, []byte(config), 0644); err != nil { + t.Fatal(err) + } + + // Call the internal function with overridden home + warnings := checkGlobalMCPLegacyAt(configPath) + if len(warnings) == 0 { + t.Fatal("expected warning about legacy server name") + } + if len(warnings) != 1 { + t.Fatalf("expected 1 warning, got %d", len(warnings)) + } +} From 4a6cbeb6e28fc3a23b7582db9b0488ab2fb36982 Mon Sep 17 00:00:00 2001 From: Joseph Goksu Date: Mon, 9 Mar 2026 10:26:11 +0000 Subject: [PATCH 2/2] fix(migration): address review feedback - Rename `init` variable to `initializer` (reserved identifier) - Walk parent chain to skip entire mcp subtree, not just leaf command - Warn on stderr when version stamp write fails (prevents silent retry loop) - Warn on stderr when slash command regeneration fails (visibility) --- cmd/root.go | 10 ++++++---- internal/migration/upgrade.go | 14 ++++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 93859d9..49c183a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -183,10 +183,12 @@ func GetVersion() string { // maybeRunPostUpgradeMigration runs a one-time migration when the CLI version // changes (e.g., after brew upgrade). Skips commands that don't need project context. func maybeRunPostUpgradeMigration(cmd *cobra.Command) { - // Skip commands that don't need project context - name := cmd.Name() - if name == "version" || name == "help" || name == "mcp" { - return + // Skip the entire mcp subtree (and other commands that don't need migration) + for c := cmd; c != nil; c = c.Parent() { + n := c.Name() + if n == "version" || n == "help" || n == "mcp" { + return + } } cwd, err := os.Getwd() diff --git a/internal/migration/upgrade.go b/internal/migration/upgrade.go index 4ee7a9d..b86d29c 100644 --- a/internal/migration/upgrade.go +++ b/internal/migration/upgrade.go @@ -30,7 +30,9 @@ func CheckAndMigrate(projectDir, currentVersion string) (warnings []string, err stored, err := os.ReadFile(versionFile) if err != nil { // Version file missing (pre-migration bootstrap). Write current and return. - _ = os.WriteFile(versionFile, []byte(currentVersion), 0644) + if werr := os.WriteFile(versionFile, []byte(currentVersion), 0644); werr != nil { + fmt.Fprintf(os.Stderr, "⚠️ taskwing: could not write version stamp (%v); migration will re-run next time\n", werr) + } return nil, nil } @@ -55,7 +57,9 @@ func CheckAndMigrate(projectDir, currentVersion string) (warnings []string, err warnings = checkGlobalMCPLegacy() // 3. Write current version - _ = os.WriteFile(versionFile, []byte(currentVersion), 0644) + if werr := os.WriteFile(versionFile, []byte(currentVersion), 0644); werr != nil { + fmt.Fprintf(os.Stderr, "⚠️ taskwing: could not write version stamp (%v); migration will re-run next time\n", werr) + } return warnings, nil } @@ -86,8 +90,10 @@ func migrateLocalConfigs(projectDir string) { } // Regenerate (this prunes stale files and creates new ones) - init := bootstrap.NewInitializer(projectDir) - _ = init.CreateSlashCommands(aiName, false) + initializer := bootstrap.NewInitializer(projectDir) + if err := initializer.CreateSlashCommands(aiName, false); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ taskwing: could not regenerate %s commands: %v\n", aiName, err) + } } }