Skip to content

Commit d199e62

Browse files
authored
feat(migration): auto-migrate configs after CLI upgrade (#25)
* 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 * 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)
1 parent 6e04dec commit d199e62

6 files changed

Lines changed: 409 additions & 1 deletion

File tree

cmd/bootstrap.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
195195

196196
// Initialize Service
197197
svc := bootstrap.NewService(cwd, llmCfg)
198+
svc.SetVersion(version)
198199

199200
// Prompt for repo selection in multi-repo workspaces.
200201
// This must happen before the action loop because ActionInitProject may not

cmd/root.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/josephgoksu/TaskWing/internal/config"
1414
"github.com/josephgoksu/TaskWing/internal/logger"
15+
"github.com/josephgoksu/TaskWing/internal/migration"
1516
"github.com/josephgoksu/TaskWing/internal/telemetry"
1617
"github.com/josephgoksu/TaskWing/internal/ui"
1718
"github.com/spf13/cobra"
@@ -55,7 +56,13 @@ var rootCmd = &cobra.Command{
5556
5657
Create a plan, execute tasks with your AI tool, and keep architecture context
5758
persistent across sessions.`,
58-
PersistentPreRunE: initTelemetry,
59+
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
60+
if err := initTelemetry(cmd, args); err != nil {
61+
return err
62+
}
63+
maybeRunPostUpgradeMigration(cmd)
64+
return nil
65+
},
5966
PersistentPostRunE: closeTelemetry,
6067
Run: func(cmd *cobra.Command, args []string) {
6168
if len(args) == 0 {
@@ -173,6 +180,33 @@ func GetVersion() string {
173180
return version
174181
}
175182

183+
// maybeRunPostUpgradeMigration runs a one-time migration when the CLI version
184+
// changes (e.g., after brew upgrade). Skips commands that don't need project context.
185+
func maybeRunPostUpgradeMigration(cmd *cobra.Command) {
186+
// Skip the entire mcp subtree (and other commands that don't need migration)
187+
for c := cmd; c != nil; c = c.Parent() {
188+
n := c.Name()
189+
if n == "version" || n == "help" || n == "mcp" {
190+
return
191+
}
192+
}
193+
194+
cwd, err := os.Getwd()
195+
if err != nil {
196+
return
197+
}
198+
199+
warnings, err := migration.CheckAndMigrate(cwd, version)
200+
if err != nil {
201+
// Migration errors are non-fatal
202+
return
203+
}
204+
205+
for _, w := range warnings {
206+
fmt.Fprintf(os.Stderr, "⚠️ %s\n", w)
207+
}
208+
}
209+
176210
// initTelemetry initializes the telemetry client.
177211
// It checks for:
178212
// 1. --no-telemetry flag (disables for this command)

internal/bootstrap/initializer.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import (
1616
// Initializer handles the setup of TaskWing project structure and integrations.
1717
type Initializer struct {
1818
basePath string
19+
// Version is the CLI version to stamp in .taskwing/version.
20+
// If empty, no version file is written.
21+
Version string
1922
}
2023

2124
func NewInitializer(basePath string) *Initializer {
@@ -216,6 +219,13 @@ func (i *Initializer) createStructure(verbose bool) error {
216219
fmt.Printf(" ✓ Created %s\n", dir)
217220
}
218221
}
222+
223+
// Track CLI version for post-upgrade migration detection
224+
if i.Version != "" {
225+
versionPath := filepath.Join(i.basePath, ".taskwing", "version")
226+
_ = os.WriteFile(versionPath, []byte(i.Version), 0644)
227+
}
228+
219229
return nil
220230
}
221231

@@ -248,6 +258,26 @@ var aiHelpers = func() map[string]aiHelperConfig {
248258
return cfg
249259
}()
250260

261+
// AIHelperInfo exposes read-only AI config fields needed by external packages.
262+
type AIHelperInfo struct {
263+
CommandsDir string
264+
SingleFile bool
265+
SingleFileName string
266+
}
267+
268+
// AIHelperByName returns exported config info for the named AI, if it exists.
269+
func AIHelperByName(name string) (AIHelperInfo, bool) {
270+
cfg, ok := aiHelpers[name]
271+
if !ok {
272+
return AIHelperInfo{}, false
273+
}
274+
return AIHelperInfo{
275+
CommandsDir: cfg.commandsDir,
276+
SingleFile: cfg.singleFile,
277+
SingleFileName: cfg.singleFileName,
278+
}, true
279+
}
280+
251281
// TaskWingManagedFile is the marker file name written to directories managed by TaskWing.
252282
// This file indicates that TaskWing created and owns the directory, preventing false positives
253283
// when users have similarly named directories for other purposes.

internal/bootstrap/service.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ func NewService(basePath string, llmCfg llm.Config) *Service {
4040
}
4141
}
4242

43+
// SetVersion sets the CLI version on the underlying initializer so that
44+
// createStructure() stamps .taskwing/version for post-upgrade migration detection.
45+
func (s *Service) SetVersion(v string) {
46+
s.initializer.Version = v
47+
}
48+
4349
// InitializeProject sets up the .taskwing directory structure and integrations.
4450
func (s *Service) InitializeProject(verbose bool, selectedAIs []string) error {
4551
return s.initializer.Run(verbose, selectedAIs)

internal/migration/upgrade.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package migration
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/josephgoksu/TaskWing/internal/bootstrap"
11+
"github.com/josephgoksu/TaskWing/internal/mcpcfg"
12+
)
13+
14+
// CheckAndMigrate runs a post-upgrade migration if the CLI version has changed
15+
// since the last run in this project. It silently regenerates local configs and
16+
// returns warnings for issues that require manual intervention (e.g., global MCP).
17+
//
18+
// This is designed to be called from PersistentPreRunE and must be:
19+
// - Sub-millisecond on the happy path (version matches)
20+
// - Non-fatal on all error paths (never blocks user commands)
21+
func CheckAndMigrate(projectDir, currentVersion string) (warnings []string, err error) {
22+
taskwingDir := filepath.Join(projectDir, ".taskwing")
23+
versionFile := filepath.Join(taskwingDir, "version")
24+
25+
// Not bootstrapped or inaccessible — nothing to migrate
26+
if _, err := os.Stat(taskwingDir); err != nil {
27+
return nil, nil
28+
}
29+
30+
stored, err := os.ReadFile(versionFile)
31+
if err != nil {
32+
// Version file missing (pre-migration bootstrap). Write current and return.
33+
if werr := os.WriteFile(versionFile, []byte(currentVersion), 0644); werr != nil {
34+
fmt.Fprintf(os.Stderr, "⚠️ taskwing: could not write version stamp (%v); migration will re-run next time\n", werr)
35+
}
36+
return nil, nil
37+
}
38+
39+
storedVersion := strings.TrimSpace(string(stored))
40+
41+
// Happy path: version matches — no-op
42+
if storedVersion == currentVersion {
43+
return nil, nil
44+
}
45+
46+
// Skip dev builds to avoid constant re-runs during development
47+
if currentVersion == "dev" {
48+
return nil, nil
49+
}
50+
51+
// --- Version mismatch: run migration ---
52+
53+
// 1. Silent local migration: regenerate slash commands for managed AIs
54+
migrateLocalConfigs(projectDir)
55+
56+
// 2. Global MCP check: warn about legacy server names
57+
warnings = checkGlobalMCPLegacy()
58+
59+
// 3. Write current version
60+
if werr := os.WriteFile(versionFile, []byte(currentVersion), 0644); werr != nil {
61+
fmt.Fprintf(os.Stderr, "⚠️ taskwing: could not write version stamp (%v); migration will re-run next time\n", werr)
62+
}
63+
64+
return warnings, nil
65+
}
66+
67+
// migrateLocalConfigs detects which AIs have managed markers and regenerates
68+
// their slash commands (which internally prunes stale tw-* files).
69+
func migrateLocalConfigs(projectDir string) {
70+
for _, aiName := range bootstrap.ValidAINames() {
71+
cfg, ok := bootstrap.AIHelperByName(aiName)
72+
if !ok {
73+
continue
74+
}
75+
76+
// Check if this AI has a managed marker
77+
if cfg.SingleFile {
78+
// Single-file AIs (e.g., Copilot) embed the marker in file content.
79+
// Check for the embedded marker before regenerating.
80+
filePath := filepath.Join(projectDir, cfg.CommandsDir, cfg.SingleFileName)
81+
content, err := os.ReadFile(filePath)
82+
if err != nil || !strings.Contains(string(content), "<!-- TASKWING_MANAGED -->") {
83+
continue
84+
}
85+
} else {
86+
markerPath := filepath.Join(projectDir, cfg.CommandsDir, bootstrap.TaskWingManagedFile)
87+
if _, err := os.Stat(markerPath); err != nil {
88+
continue
89+
}
90+
}
91+
92+
// Regenerate (this prunes stale files and creates new ones)
93+
initializer := bootstrap.NewInitializer(projectDir)
94+
if err := initializer.CreateSlashCommands(aiName, false); err != nil {
95+
fmt.Fprintf(os.Stderr, "⚠️ taskwing: could not regenerate %s commands: %v\n", aiName, err)
96+
}
97+
}
98+
}
99+
100+
// checkGlobalMCPLegacy reads Claude's global MCP config and warns if legacy
101+
// server names are present.
102+
func checkGlobalMCPLegacy() []string {
103+
home, err := os.UserHomeDir()
104+
if err != nil {
105+
return nil
106+
}
107+
configPath := filepath.Join(home, ".claude", "claude_desktop_config.json")
108+
return checkGlobalMCPLegacyAt(configPath)
109+
}
110+
111+
// checkGlobalMCPLegacyAt checks a specific config file path for legacy server names.
112+
func checkGlobalMCPLegacyAt(configPath string) []string {
113+
content, err := os.ReadFile(configPath)
114+
if err != nil {
115+
return nil
116+
}
117+
118+
var config struct {
119+
MCPServers map[string]json.RawMessage `json:"mcpServers"`
120+
}
121+
if err := json.Unmarshal(content, &config); err != nil {
122+
return nil
123+
}
124+
125+
var warnings []string
126+
for name := range config.MCPServers {
127+
if mcpcfg.IsLegacyServerName(name) {
128+
warnings = append(warnings, fmt.Sprintf("Global MCP config has legacy server name %q. Run: taskwing doctor --fix --yes", name))
129+
}
130+
}
131+
132+
return warnings
133+
}

0 commit comments

Comments
 (0)