Skip to content

Commit ba17256

Browse files
AnnatarHeclaude
andcommitted
feat(dotfiles): add pull command to sync dotfiles from server
- Extract push functionality into separate file - Add pull command to fetch and apply dotfiles from server - Implement GraphQL queries for fetching dotfiles - Add IsEqual, Backup, and Save methods to DotfileApp interface - Add path adjustment utility for cross-user dotfile synchronization - Skip files that are already identical to avoid unnecessary updates - Create automatic backups before overwriting local files 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 32e7cd2 commit ba17256

6 files changed

Lines changed: 597 additions & 98 deletions

File tree

commands/dotfiles.go

Lines changed: 12 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
package commands
22

33
import (
4-
"fmt"
5-
"os"
6-
7-
"github.com/malamtime/cli/model"
8-
"github.com/sirupsen/logrus"
94
"github.com/urfave/cli/v2"
10-
"go.opentelemetry.io/otel/attribute"
11-
"go.opentelemetry.io/otel/trace"
125
)
136

147
var DotfilesCommand *cli.Command = &cli.Command{
@@ -27,97 +20,20 @@ var DotfilesCommand *cli.Command = &cli.Command{
2720
},
2821
},
2922
},
23+
{
24+
Name: "pull",
25+
Usage: "pull dotfiles from server and save to local config",
26+
Action: pullDotfiles,
27+
Flags: []cli.Flag{
28+
&cli.StringSliceFlag{
29+
Name: "apps",
30+
Aliases: []string{"a"},
31+
Usage: "specify which apps to pull (nvim, fish, git, zsh, bash, ghostty). If empty, pulls all",
32+
},
33+
},
34+
},
3035
},
3136
OnUsageError: func(cCtx *cli.Context, err error, isSubcommand bool) error {
3237
return nil
3338
},
34-
}
35-
36-
func pushDotfiles(c *cli.Context) error {
37-
ctx, span := commandTracer.Start(c.Context, "dotfiles-push", trace.WithSpanKind(trace.SpanKindClient))
38-
defer span.End()
39-
SetupLogger(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER))
40-
logrus.SetLevel(logrus.TraceLevel)
41-
42-
apps := c.StringSlice("apps")
43-
span.SetAttributes(attribute.StringSlice("apps", apps))
44-
45-
config, err := configService.ReadConfigFile(ctx)
46-
if err != nil {
47-
logrus.Errorln(err)
48-
return err
49-
}
50-
51-
if config.Token == "" {
52-
return fmt.Errorf("no token found, please run 'shelltime auth login' first")
53-
}
54-
55-
mainEndpoint := model.Endpoint{
56-
APIEndpoint: config.APIEndpoint,
57-
Token: config.Token,
58-
}
59-
60-
// Initialize all available app handlers
61-
allApps := []model.DotfileApp{
62-
model.NewNvimApp(),
63-
model.NewFishApp(),
64-
model.NewGitApp(),
65-
model.NewZshApp(),
66-
model.NewBashApp(),
67-
model.NewGhosttyApp(),
68-
}
69-
70-
// Filter apps based on user input
71-
var selectedApps []model.DotfileApp
72-
if len(apps) == 0 {
73-
// If no apps specified, use all
74-
selectedApps = allApps
75-
} else {
76-
// Filter based on user selection
77-
appMap := make(map[string]model.DotfileApp)
78-
for _, app := range allApps {
79-
appMap[app.Name()] = app
80-
}
81-
82-
for _, appName := range apps {
83-
if app, ok := appMap[appName]; ok {
84-
selectedApps = append(selectedApps, app)
85-
} else {
86-
logrus.Warnf("Unknown app: %s", appName)
87-
}
88-
}
89-
}
90-
91-
// Collect all dotfiles
92-
var allDotfiles []model.DotfileItem
93-
for _, app := range selectedApps {
94-
logrus.Infof("Collecting dotfiles for %s", app.Name())
95-
dotfiles, err := app.CollectDotfiles(ctx)
96-
if err != nil {
97-
logrus.Errorf("Failed to collect dotfiles for %s: %v", app.Name(), err)
98-
continue
99-
}
100-
allDotfiles = append(allDotfiles, dotfiles...)
101-
}
102-
103-
if len(allDotfiles) == 0 {
104-
logrus.Infoln("No dotfiles found to push")
105-
return nil
106-
}
107-
108-
// Send to server
109-
logrus.Infof("Pushing %d dotfiles to server", len(allDotfiles))
110-
userID, err := model.SendDotfilesToServer(ctx, mainEndpoint, allDotfiles)
111-
if err != nil {
112-
logrus.Errorln("Failed to send dotfiles to server:", err)
113-
return err
114-
}
115-
116-
// Generate web link for managing dotfiles
117-
webLink := fmt.Sprintf("%s/users/%d/settings/dotfiles", config.WebEndpoint, userID)
118-
logrus.Infof("Successfully pushed dotfiles. Manage them at: %s", webLink)
119-
fmt.Printf("\n✅ Successfully pushed %d dotfiles to server\n", len(allDotfiles))
120-
fmt.Printf("📁 Manage your dotfiles at: %s\n", webLink)
121-
122-
return nil
12339
}

commands/dotfiles_pull.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/malamtime/cli/model"
8+
"github.com/sirupsen/logrus"
9+
"github.com/urfave/cli/v2"
10+
"go.opentelemetry.io/otel/attribute"
11+
"go.opentelemetry.io/otel/trace"
12+
)
13+
14+
func pullDotfiles(c *cli.Context) error {
15+
ctx, span := commandTracer.Start(c.Context, "dotfiles-pull", trace.WithSpanKind(trace.SpanKindClient))
16+
defer span.End()
17+
SetupLogger(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER))
18+
19+
apps := c.StringSlice("apps")
20+
span.SetAttributes(attribute.StringSlice("apps", apps))
21+
22+
config, err := configService.ReadConfigFile(ctx)
23+
if err != nil {
24+
logrus.Errorln(err)
25+
return err
26+
}
27+
28+
if config.Token == "" {
29+
return fmt.Errorf("no token found, please run 'shelltime auth login' first")
30+
}
31+
32+
mainEndpoint := model.Endpoint{
33+
APIEndpoint: config.APIEndpoint,
34+
Token: config.Token,
35+
}
36+
37+
// Prepare filter if apps are specified
38+
var filter *model.DotfileFilter
39+
if len(apps) > 0 {
40+
filter = &model.DotfileFilter{
41+
Apps: apps,
42+
}
43+
}
44+
45+
// Fetch dotfiles from server
46+
logrus.Infof("Fetching dotfiles from server...")
47+
resp, err := model.FetchDotfilesFromServer(ctx, mainEndpoint, filter)
48+
if err != nil {
49+
logrus.Errorln("Failed to fetch dotfiles from server:", err)
50+
return err
51+
}
52+
53+
if resp == nil || len(resp.Data.FetchUser.Dotfiles.Apps) == 0 {
54+
logrus.Infoln("No dotfiles found on server")
55+
fmt.Println("\n📭 No dotfiles found on server")
56+
return nil
57+
}
58+
59+
// Initialize all available app handlers
60+
allApps := map[string]model.DotfileApp{
61+
"nvim": model.NewNvimApp(),
62+
"fish": model.NewFishApp(),
63+
"git": model.NewGitApp(),
64+
"zsh": model.NewZshApp(),
65+
"bash": model.NewBashApp(),
66+
"ghostty": model.NewGhosttyApp(),
67+
}
68+
69+
// Process fetched dotfiles
70+
totalProcessed := 0
71+
totalSkipped := 0
72+
totalFailed := 0
73+
74+
for _, appData := range resp.Data.FetchUser.Dotfiles.Apps {
75+
app, exists := allApps[appData.App]
76+
if !exists {
77+
logrus.Warnf("Unknown app type: %s", appData.App)
78+
continue
79+
}
80+
81+
logrus.Infof("Processing %s dotfiles...", appData.App)
82+
83+
// Collect files to process for this app
84+
filesToProcess := make(map[string]string)
85+
var pathsToBackup []string
86+
87+
for _, file := range appData.Files {
88+
if len(file.Records) == 0 {
89+
logrus.Debugf("No records found for %s", file.Path)
90+
continue
91+
}
92+
93+
// Get the best record: prioritize records without host, fallback to latest
94+
var selectedRecord *model.DotfileRecord
95+
var latestRecord *model.DotfileRecord
96+
97+
for i := range file.Records {
98+
record := &file.Records[i]
99+
100+
// Track the latest record overall
101+
if latestRecord == nil || record.UpdatedAt.After(latestRecord.UpdatedAt) {
102+
latestRecord = record
103+
}
104+
105+
// If we find a record without a host (general config), use it
106+
if record.Host.ID == 0 || record.Host.Hostname == "" {
107+
if selectedRecord == nil || record.UpdatedAt.After(selectedRecord.UpdatedAt) {
108+
selectedRecord = record
109+
}
110+
}
111+
}
112+
113+
// If no host-less record found, use the latest record
114+
if selectedRecord == nil {
115+
selectedRecord = latestRecord
116+
}
117+
118+
if selectedRecord == nil {
119+
continue
120+
}
121+
122+
// Adjust path for current user
123+
adjustedPath := AdjustPathForCurrentUser(file.Path)
124+
filesToProcess[adjustedPath] = selectedRecord.Content
125+
pathsToBackup = append(pathsToBackup, adjustedPath)
126+
}
127+
128+
if len(filesToProcess) == 0 {
129+
continue
130+
}
131+
132+
// Check which files are different
133+
equalityMap, err := app.IsEqual(ctx, filesToProcess)
134+
if err != nil {
135+
logrus.Warnf("Failed to check file equality for %s: %v", appData.App, err)
136+
}
137+
138+
// Filter out files that are already equal
139+
filesToUpdate := make(map[string]string)
140+
var pathsToActuallyBackup []string
141+
142+
for path, content := range filesToProcess {
143+
if isEqual, exists := equalityMap[path]; exists && isEqual {
144+
logrus.Debugf("Skipping %s - content is identical", path)
145+
totalSkipped++
146+
} else {
147+
filesToUpdate[path] = content
148+
pathsToActuallyBackup = append(pathsToActuallyBackup, path)
149+
}
150+
}
151+
152+
if len(filesToUpdate) == 0 {
153+
logrus.Infof("All %s files are up to date", appData.App)
154+
continue
155+
}
156+
157+
// Backup files that will be modified
158+
if err := app.Backup(ctx, pathsToActuallyBackup); err != nil {
159+
logrus.Warnf("Failed to backup files for %s: %v", appData.App, err)
160+
}
161+
162+
// Save the updated files
163+
if err := app.Save(ctx, filesToUpdate); err != nil {
164+
logrus.Errorf("Failed to save files for %s: %v", appData.App, err)
165+
totalFailed += len(filesToUpdate)
166+
} else {
167+
totalProcessed += len(filesToUpdate)
168+
}
169+
}
170+
171+
if totalProcessed == 0 && totalFailed == 0 && totalSkipped == 0 {
172+
logrus.Infoln("No dotfiles found to process")
173+
fmt.Println("\n📭 No dotfiles to process")
174+
} else if totalProcessed == 0 && totalFailed == 0 {
175+
logrus.Infof("All dotfiles are up to date - Skipped: %d", totalSkipped)
176+
fmt.Println("\n✅ All dotfiles are up to date")
177+
fmt.Printf("🔄 Skipped: %d files (already identical)\n", totalSkipped)
178+
} else {
179+
logrus.Infof("Pull complete - Processed: %d, Skipped: %d, Failed: %d", totalProcessed, totalSkipped, totalFailed)
180+
fmt.Printf("\n✅ Pull complete\n")
181+
fmt.Printf("📥 Updated: %d files\n", totalProcessed)
182+
if totalSkipped > 0 {
183+
fmt.Printf("🔄 Skipped: %d files (already identical)\n", totalSkipped)
184+
}
185+
if totalFailed > 0 {
186+
fmt.Printf("⚠️ Failed: %d files\n", totalFailed)
187+
}
188+
}
189+
190+
return nil
191+
}

0 commit comments

Comments
 (0)