diff --git a/README.md b/README.md index f56b49e..7aa509e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - **Selective sync**: Choose `--scope sessions` to sync only conversation data (skip plugins/node_modules) - **Interactive wizard**: Arrow-key driven setup with validation - **Secure self-updating**: `claude-sync update` downloads and verifies SHA256 checksums +- **Desktop sidebar**: Restores the desktop app's conversation list, not just the CLI's session picker - **Simple CLI**: `push`, `pull`, `status`, `diff`, `conflicts` commands - **Compression**: Gzip compression before encryption for faster syncs - **Shell integration**: Optional shell hooks for automatic push/pull @@ -195,6 +196,9 @@ claude-sync pull | `~/.claude/settings.local.json` | Local settings | | `~/.claude/CLAUDE.md` | Global instructions | +Plus the desktop app's sidebar index, which lives **outside** `~/.claude` — see +[Desktop app sidebar](#desktop-app-sidebar). + ### Sync scope `init` asks whether to sync everything or just conversation data; you can also set it with `--scope`: @@ -249,6 +253,7 @@ claude-sync pull # Download remote changes from cloud storage claude-sync status # Show pending local changes claude-sync diff # Show differences between local and remote claude-sync conflicts # List and resolve conflicts +claude-sync desktop # Sync the desktop app's sidebar index claude-sync rebuild-history # Rebuild ~/.claude/history.jsonl from session files claude-sync reset # Reset configuration (forgot passphrase) claude-sync migrate # Convert legacy remote keys to portable path-mapped keys @@ -264,6 +269,7 @@ claude-sync pull # Normal pull (prompts if existing files) claude-sync pull --dry-run # Preview what would change claude-sync pull --force # Skip confirmation prompts claude-sync pull --rebuild-history # Also rebuild history.jsonl after pulling +claude-sync pull --desktop # Also restore desktop app sidebar entries ``` ### Rebuilding Prompt History @@ -281,6 +287,61 @@ claude-sync pull --rebuild-history # Rebuild automatically after a pull Every existing entry is preserved, recovered prompts are merged in and sorted by timestamp, and the previous file is kept as `history.jsonl.bak`. +### Desktop app sidebar + +The Claude desktop app does not render its sidebar from `~/.claude/projects`. It +keeps a separate index of small pointer records — one per conversation, each +naming a transcript — in its own support directory: + +| Platform | Location | Status | +|----------|----------|--------| +| macOS | `~/Library/Application Support/Claude/claude-code-sessions/` | Verified | +| Linux | `~/.config/Claude/claude-code-sessions/` | Untested | +| Windows | `%APPDATA%\Claude\claude-code-sessions\` | Untested | + +> The Linux and Windows locations follow Electron's `userData` convention, which +> the desktop app is built on, but have not been confirmed on those platforms. +> Reports welcome. On a machine where the index is not found, the desktop +> commands report that and change nothing. + +Syncing `~/.claude` alone therefore restores conversations for `claude --resume` +while leaving the desktop sidebar empty. `push` carries this index too, and +`pull --desktop` restores it: + +```bash +claude-sync pull --desktop # Pull conversations, then restore sidebar entries +claude-sync desktop pull # Restore sidebar entries only, no file sync +claude-sync desktop push # Upload this device's index only +``` + +Restart the desktop app afterwards — it reads the index at startup. + +Conversations that already have a sidebar entry are left untouched. Matching is +by transcript id rather than the app's per-device record id, so repeated pulls +never duplicate rows. + +A sidebar entry points at a transcript by id, so entries are only written for +conversations this machine actually has. Sessions whose transcript is absent are +skipped and reported — without that, the entry would appear in the sidebar and +open onto "Session not found on disk". Pull the conversations first, or use +`pull --desktop`, which pulls them before hydrating. + +**Archived conversations.** The archived flag exists only in this index; nothing +in a transcript records it. Archive state travels with every push, and the more +recent change wins when two devices disagree. To keep archived conversations' +transcripts out of the upload while still syncing the fact that they are +archived: + +```bash +claude-sync push --skip-archived +``` + +The label is always pushed even when the transcript is not, so a conversation +archived after its first sync still disappears from the other device's sidebar. + +A device that has never opened the desktop app has no index, contributes no +archive state, and is unaffected by any of this. + ### Init Options ```bash diff --git a/SECURITY-AUDIT.md b/SECURITY-AUDIT.md index 6fd993e..c6d7914 100644 --- a/SECURITY-AUDIT.md +++ b/SECURITY-AUDIT.md @@ -9,14 +9,14 @@ ## Executive Summary -claude-sync is a Go CLI tool (with npm wrapper) that syncs `~/.claude` directories across devices using cloud storage (R2/S3/GCS) with age encryption. **No critical or high vulnerabilities found.** Five medium findings relate to supply chain integrity and cryptographic design tradeoffs. Five low findings cover file permissions and path handling. +claude-sync is a Go CLI tool (with npm wrapper) that syncs `~/.claude` directories across devices using cloud storage (R2/S3/GCS) with age encryption. **No critical or high vulnerabilities found.** Five medium findings relate to supply chain integrity and cryptographic design tradeoffs. Six low findings cover file permissions, path handling, and write scope. | Severity | Count | |----------|-------| | Critical | 0 | | High | 0 | | Medium | 5 | -| Low | 5 | +| Low | 6 | | Info | 5 | --- @@ -139,6 +139,29 @@ CI builds and tests against Go 1.21 while the module requires Go 1.24. Tests may --- +### L6 — Desktop Index Writes Outside `~/.claude` + +**File:** `internal/desktop/apply.go`, `internal/desktop/record.go` + +`pull --desktop` and `desktop pull` write index records into the desktop app's +support directory. This is the tool's first write target outside `~/.claude`. +Record filenames derive from a `sessionId` field carried in remote data, which +makes them attacker-influenced input to a filesystem write. + +**Mitigating factors:** Filenames are refused rather than sanitized — any value +containing a path separator or `..`, or which differs from its own +`filepath.Base`, is rejected and the record skipped (covered by +`TestApplyRejectsTraversingSessionID`). The destination directory is discovered +on the local machine and never taken from remote data. Both entry points are +opt-in; an ordinary `pull` does not write here. + +**Residual risk:** An actor with bucket write access can still add sidebar +entries naming arbitrary transcript ids. These surface as rows in the app's +sidebar. No content is executed and no file outside the index directory is +written, so the effect is limited to sidebar clutter. + +--- + ## Clean Areas | Area | Status | @@ -149,6 +172,7 @@ CI builds and tests against Go 1.21 while the module requires Go 1.24. Tests may | eval/exec injection | No dynamic code execution | | Encryption | Sound — age v1.3.1, X25519, Argon2id | | Input validation | CLI inputs properly handled | +| Index record filenames | Validated — traversal refused rather than sanitized (L6) | --- diff --git a/cmd/claude-sync/desktop_test.go b/cmd/claude-sync/desktop_test.go new file mode 100644 index 0000000..e444a42 --- /dev/null +++ b/cmd/claude-sync/desktop_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "sort" + "testing" +) + +// Sidebar hydration is wired inside `pull`, so exercising it otherwise means +// running a full sync — downloading conversations just to fix a sidebar. MCP +// solved the same problem with standalone subcommands; desktop mirrors that so +// the index can be pushed or hydrated on its own. +func TestDesktopCmdExposesPushAndPull(t *testing.T) { + cmd := desktopCmd() + + if cmd.Use != "desktop" { + t.Errorf("Use = %q, want %q", cmd.Use, "desktop") + } + + found := map[string]bool{} + for _, sub := range cmd.Commands() { + found[sub.Name()] = true + } + for _, want := range []string{"push", "pull"} { + if !found[want] { + names := make([]string, 0, len(found)) + for n := range found { + names = append(names, n) + } + sort.Strings(names) + t.Errorf("desktop has no %q subcommand; got %v", want, names) + } + } +} + +// Every subcommand needs a one-line Short, or it renders blank in help output. +func TestDesktopSubcommandsAreDocumented(t *testing.T) { + for _, sub := range desktopCmd().Commands() { + if sub.Short == "" { + t.Errorf("subcommand %q has no Short description", sub.Name()) + } + } +} diff --git a/cmd/claude-sync/main.go b/cmd/claude-sync/main.go index 4f0d47f..7c2dafd 100644 --- a/cmd/claude-sync/main.go +++ b/cmd/claude-sync/main.go @@ -73,6 +73,7 @@ func main() { updateCmd(), changelogCmd(), mcpCmd(), + desktopCmd(), autoCmd(), pathsCmd(), ) @@ -1012,6 +1013,7 @@ func runWebDAVWizard(webdavURL, username, password, pathPrefix string) (*storage func pushCmd() *cobra.Command { var includeMCP bool + var skipArchived bool cmd := &cobra.Command{ Use: "push", @@ -1027,6 +1029,7 @@ func pushCmd() *cobra.Command { if err != nil { return err } + syncer.SetSkipArchived(skipArchived) if !quiet { syncer.SetProgressFunc(func(event sync.ProgressEvent) { @@ -1109,16 +1112,25 @@ func pushCmd() *cobra.Command { } } + // The sidebar index always travels with a push so archive state can + // reach other devices. Without it, an archive decision made here would + // never be visible anywhere else. + if err := runDesktopPush(ctx, syncer); err != nil { + return err + } + return nil }, } cmd.Flags().BoolVar(&includeMCP, "include-mcp", false, "Also sync MCP server configs from ~/.claude.json") + cmd.Flags().BoolVar(&skipArchived, "skip-archived", false, "Do not upload transcripts of archived conversations (their archived state is still synced)") return cmd } func pullCmd() *cobra.Command { var dryRun, force, includeMCP, rebuildHistory bool + var desktopSync bool cmd := &cobra.Command{ Use: "pull", @@ -1247,6 +1259,14 @@ Examples: } } + // Hydrate the desktop sidebar, which reads its own index rather than + // ~/.claude/projects. Skipped on a dry run: it writes to disk. + if desktopSync && !dryRun { + if err := runDesktopPull(ctx, syncer); err != nil { + return err + } + } + // Rebuild prompt history from the freshly-pulled session files. if rebuildHistory && !dryRun { if err := runHistoryRebuild(); err != nil { @@ -1262,6 +1282,7 @@ Examples: cmd.Flags().BoolVar(&force, "force", false, "Overwrite existing files without confirmation") cmd.Flags().BoolVar(&includeMCP, "include-mcp", false, "Also sync MCP server configs from ~/.claude.json") cmd.Flags().BoolVar(&rebuildHistory, "rebuild-history", false, "Rebuild ~/.claude/history.jsonl from session files after pulling") + cmd.Flags().BoolVar(&desktopSync, "desktop", false, "Also restore the Claude desktop app's sidebar entries for pulled conversations") return cmd } @@ -2734,6 +2755,57 @@ func printReleaseBody(body string) { // MCP sync commands +func desktopCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "desktop", + Short: "Manage Claude desktop app sidebar sync", + Long: `Sync the Claude desktop app's sidebar session index. + +The desktop app renders its sidebar from its own index, not from +~/.claude/projects. Conversations carried by push/pull are therefore resumable +with 'claude --resume' but stay invisible in the app until that index travels +too. These subcommands move the index on its own, without syncing conversations.`, + } + cmd.AddCommand(desktopPushCmd(), desktopPullCmd()) + return cmd +} + +func desktopPushCmd() *cobra.Command { + return &cobra.Command{ + Use: "push", + Short: "Upload this device's sidebar index", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + syncer, err := sync.NewSyncer(cfg, quiet) + if err != nil { + return err + } + return runDesktopPush(context.Background(), syncer) + }, + } +} + +func desktopPullCmd() *cobra.Command { + return &cobra.Command{ + Use: "pull", + Short: "Restore sidebar entries from another device (leaves conversations untouched)", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + syncer, err := sync.NewSyncer(cfg, quiet) + if err != nil { + return err + } + return runDesktopPull(context.Background(), syncer) + }, + } +} + func mcpCmd() *cobra.Command { cmd := &cobra.Command{ Use: "mcp", @@ -2932,6 +3004,67 @@ func mcpPullCmd() *cobra.Command { } } +// runDesktopPush uploads the desktop sidebar index alongside the file push. +// +// The sidebar renders from an index that lives outside ~/.claude, so a push +// carrying only transcripts leaves conversations resumable from the CLI but +// absent from the app on every other machine. +func runDesktopPush(ctx context.Context, syncer *sync.Syncer) error { + result, err := syncer.PushDesktop(ctx) + if err != nil { + return fmt.Errorf("desktop index push failed: %w", err) + } + if quiet { + return nil + } + switch { + case result.NoIndex: + // No desktop app on this machine; nothing worth reporting. + case result.Unchanged: + fmt.Printf("%s\u2713%s Desktop sidebar: no changes to push\n", colorGreen, colorReset) + default: + fmt.Printf("%s\u2713%s Desktop sidebar: %s%d records pushed%s\n", + colorGreen, colorReset, colorGreen, result.RecordsPushed, colorReset) + } + return nil +} + +// runDesktopPull applies the remote desktop index to this machine's sidebar. +func runDesktopPull(ctx context.Context, syncer *sync.Syncer) error { + result, err := syncer.PullDesktop(ctx) + if err != nil { + return fmt.Errorf("desktop index pull failed: %w", err) + } + if quiet { + return nil + } + switch { + case result.NoIndex: + fmt.Printf("%s!%s Desktop sidebar: no local index found (is the desktop app installed?)\n", colorYellow, colorReset) + case result.NoRemote: + fmt.Printf("%s!%s Desktop sidebar: nothing pushed from another device yet\n", colorYellow, colorReset) + default: + fmt.Printf("%s\u2713%s Desktop sidebar: %s%d added%s, %d archive %s updated\n", + colorGreen, colorReset, colorGreen, result.Written, colorReset, + result.Updated, pluralRecords(result.Updated)) + if result.MissingTranscript > 0 { + fmt.Printf(" %s%d skipped — no transcript on this machine; run 'claude-sync pull' first%s\n", + colorYellow, result.MissingTranscript, colorReset) + } + if result.Written > 0 || result.Updated > 0 { + fmt.Printf(" %sRestart Claude to see the changes.%s\n", colorDim, colorReset) + } + } + return nil +} + +func pluralRecords(n int) string { + if n == 1 { + return "state" + } + return "states" +} + func runMCPPush(ctx context.Context, syncer *sync.Syncer) error { result, err := syncer.PushMCP(ctx) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index bb0788e..6d1b6f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,10 +17,21 @@ const ( StateFile = "state.json" AgeKeyFile = "age-key.txt" + // ExternalKeyPrefix marks remote objects that are not files under ~/.claude + // (MCP servers, the desktop index). Both the pull mapper and the push + // change detector must treat these as out of band: they have no local + // path, so their absence from the local tree is never a deletion. + ExternalKeyPrefix = "_external/" + // MCPRemoteKey is the remote storage key for synced MCP server configs. // The _external/ prefix separates it from ~/.claude/-relative files. MCPRemoteKey = "_external/mcp-servers.json" + // DesktopRemoteKey is the remote storage key for the desktop app's sidebar + // session index. The index lives outside ~/.claude, so it travels as its own + // external object rather than as part of the synced path set. + DesktopRemoteKey = "_external/desktop-index.json" + // Sync scopes control which subset of ~/.claude is synced. // ScopeFull (default) syncs everything in SyncPaths; ScopeSessions limits // syncing to portable conversation data only. diff --git a/internal/desktop/apply.go b/internal/desktop/apply.go new file mode 100644 index 0000000..fac3de2 --- /dev/null +++ b/internal/desktop/apply.go @@ -0,0 +1,152 @@ +package desktop + +import ( + "os" + "path/filepath" + "time" +) + +// Incoming is an index record from the remote together with the moment the +// machine that pushed it last observed that record. ObservedAt is what makes +// archive reconciliation last-writer-wins; it is carried alongside the record +// rather than inside it because the record schema belongs to the desktop app +// and must not gain fields this tool invented. +type Incoming struct { + Record *Record + ObservedAt time.Time +} + +// Result summarises what Apply changed. +type Result struct { + Written int + Updated int + Skipped int +} + +// existingSessionIDs maps cliSessionId to the record file already representing +// it, so Apply can tell which sessions the local sidebar already knows about. +func existingSessionIDs(dir string) (map[string]string, error) { + matches, err := filepath.Glob(filepath.Join(dir, "local_*.json")) + if err != nil { + return nil, err + } + known := make(map[string]string, len(matches)) + for _, path := range matches { + rec, err := LoadRecord(path) + if err != nil { + // A record the app wrote in a format we cannot parse is not a + // reason to abort a pull; treat it as opaque and move on. + continue + } + if id := rec.CLISessionID(); id != "" { + known[id] = path + } + } + return known, nil +} + +// Apply writes index records into dir for sessions the local sidebar does not +// already know about. +// +// Dedupe keys on cliSessionId, never on the record's own sessionId: sessionId +// is minted per machine, so the same conversation carries a different one on +// every device and matching on it would append a duplicate sidebar row on each +// pull. +func Apply(dir string, incoming []Incoming) (Result, error) { + known, err := existingSessionIDs(dir) + if err != nil { + return Result{}, err + } + + var result Result + for _, in := range incoming { + if in.Record == nil { + result.Skipped++ + continue + } + id := in.Record.CLISessionID() + if id == "" { + result.Skipped++ + continue + } + + if path, exists := known[id]; exists { + updated, err := reconcileArchive(path, in) + if err != nil { + return result, err + } + if updated { + result.Updated++ + } else { + result.Skipped++ + } + continue + } + + name, err := in.Record.fileName() + if err != nil { + result.Skipped++ + continue + } + path := filepath.Join(dir, name) + if err := in.Record.Save(path); err != nil { + return result, err + } + stampObservedAt(path, in.ObservedAt) + known[id] = path + result.Written++ + } + return result, nil +} + +// reconcileArchive settles the archive state of a session the local sidebar +// already knows about, on a last-writer-wins basis. +// +// The local record's mtime stands in for "when this machine last decided": the +// desktop app rewrites the record when a session is archived or unarchived, so +// no field needs to be added to an app-owned schema to track it. +func reconcileArchive(path string, in Incoming) (bool, error) { + status := in.Record.ArchiveStatus() + if status == ArchiveUnknown { + // The pushing machine had no desktop index and so expressed no + // decision. Silence must never overwrite a known local state. + return false, nil + } + + info, err := os.Stat(path) + if err != nil { + return false, err + } + if !in.ObservedAt.After(info.ModTime()) { + return false, nil + } + + local, err := LoadRecord(path) + if err != nil { + return false, nil + } + if local.ArchiveStatus() == status { + return false, nil + } + local.setArchiveStatus(status) + if err := local.Save(path); err != nil { + return false, err + } + stampObservedAt(path, in.ObservedAt) + return true, nil +} + +// stampObservedAt sets the record's mtime to the moment the decision it +// carries was made. The mtime doubles as the observation time on the next +// push, so leaving a freshly written record at "now" would report another +// device's decision as though it were made here just now — an inflated time +// that could then beat a genuinely later change on the originating device. +// A zero ObservedAt carries no timing information and leaves the mtime alone. +func stampObservedAt(path string, observed time.Time) { + if observed.IsZero() { + return + } + // Best effort: a failure here degrades to the previous behaviour rather + // than failing a pull that has already written the record correctly. + _ = os.Chtimes(path, observed, observed) +} diff --git a/internal/desktop/apply_test.go b/internal/desktop/apply_test.go new file mode 100644 index 0000000..58ab26b --- /dev/null +++ b/internal/desktop/apply_test.go @@ -0,0 +1,253 @@ +package desktop + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func writeRecord(t *testing.T, dir, name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func countRecords(t *testing.T, dir string) int { + t.Helper() + matches, err := filepath.Glob(filepath.Join(dir, "local_*.json")) + if err != nil { + t.Fatal(err) + } + return len(matches) +} + +// A record's own sessionId is machine-local, so the same conversation carries a +// different sessionId on every device. Matching on it would add a duplicate +// sidebar row for every session on every pull. cliSessionId is the stable +// cross-machine identity and is what dedupe must key on. +func TestApplySkipsSessionsThatAlreadyHaveARecord(t *testing.T) { + dir := t.TempDir() + writeRecord(t, dir, "local_already-here.json", + `{"sessionId":"local_already-here","cliSessionId":"abc-123","isArchived":false}`) + + incoming := Incoming{Record: &Record{fields: map[string]any{ + "sessionId": "local_from-other-machine", + "cliSessionId": "abc-123", + "isArchived": false, + }}} + + result, err := Apply(dir, []Incoming{incoming}) + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if result.Written != 0 { + t.Errorf("Written = %d, want 0 (session already had a record)", result.Written) + } + if result.Updated != 0 { + t.Errorf("Updated = %d, want 0 (nothing newer to apply)", result.Updated) + } + if n := countRecords(t, dir); n != 1 { + t.Errorf("directory holds %d records, want 1 (duplicate created)", n) + } +} + +// A session with no local record is the case worth acting on: write it. +func TestApplyWritesRecordsForUnknownSessions(t *testing.T) { + dir := t.TempDir() + incoming := Incoming{Record: &Record{fields: map[string]any{ + "sessionId": "local_incoming", + "cliSessionId": "brand-new", + "isArchived": false, + }}} + + result, err := Apply(dir, []Incoming{incoming}) + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if result.Written != 1 { + t.Errorf("Written = %d, want 1", result.Written) + } + if n := countRecords(t, dir); n != 1 { + t.Errorf("directory holds %d records, want 1", n) + } +} + +// sessionId arrives from the remote and is used to name the file on disk, which +// makes it attacker-influenced input to a filesystem write. A traversing value +// must never escape the index directory. +func TestApplyRejectsTraversingSessionID(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "index") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + for _, hostile := range []string{"../escaped", "../../escaped", "nested/escaped"} { + incoming := Incoming{Record: &Record{fields: map[string]any{ + "sessionId": hostile, + "cliSessionId": "session-" + hostile, + }}} + + result, err := Apply(dir, []Incoming{incoming}) + if err == nil && result.Written != 0 { + t.Errorf("sessionId %q was written; want refusal", hostile) + } + } + + escaped, err := filepath.Glob(filepath.Join(parent, "*.json")) + if err != nil { + t.Fatal(err) + } + if len(escaped) != 0 { + t.Errorf("records escaped the index directory: %v", escaped) + } +} + +func archiveStatusOnDisk(t *testing.T, path string) ArchiveStatus { + t.Helper() + rec, err := LoadRecord(path) + if err != nil { + t.Fatal(err) + } + return rec.ArchiveStatus() +} + +// Archiving is a deliberate user action on whichever machine performed it, so +// the more recent decision wins. Here the remote archived a session the local +// sidebar still shows as active. +func TestApplyAdoptsRemoteArchiveStatusWhenRemoteIsNewer(t *testing.T) { + dir := t.TempDir() + path := writeRecord(t, dir, "local_a.json", + `{"sessionId":"local_a","cliSessionId":"abc","isArchived":false}`) + stale := time.Now().Add(-2 * time.Hour) + if err := os.Chtimes(path, stale, stale); err != nil { + t.Fatal(err) + } + + incoming := Incoming{ + Record: &Record{fields: map[string]any{ + "sessionId": "local_remote", "cliSessionId": "abc", "isArchived": true, + }}, + ObservedAt: time.Now(), + } + + result, err := Apply(dir, []Incoming{incoming}) + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if result.Updated != 1 { + t.Errorf("Updated = %d, want 1", result.Updated) + } + if got := archiveStatusOnDisk(t, path); got != ArchiveArchived { + t.Errorf("on-disk status = %v, want archived", got) + } + if n := countRecords(t, dir); n != 1 { + t.Errorf("directory holds %d records, want 1", n) + } +} + +// The mirror case: a local archive decision made after the remote observation +// must not be undone by an older remote label. +func TestApplyKeepsLocalArchiveStatusWhenLocalIsNewer(t *testing.T) { + dir := t.TempDir() + path := writeRecord(t, dir, "local_a.json", + `{"sessionId":"local_a","cliSessionId":"abc","isArchived":true}`) + + incoming := Incoming{ + Record: &Record{fields: map[string]any{ + "sessionId": "local_remote", "cliSessionId": "abc", "isArchived": false, + }}, + ObservedAt: time.Now().Add(-2 * time.Hour), + } + + result, err := Apply(dir, []Incoming{incoming}) + if err != nil { + t.Fatalf("Apply() error: %v", err) + } + if result.Updated != 0 { + t.Errorf("Updated = %d, want 0 (local decision is newer)", result.Updated) + } + if got := archiveStatusOnDisk(t, path); got != ArchiveArchived { + t.Errorf("on-disk status = %v, want archived (local state was overwritten)", got) + } +} + +// An incoming label of unknown provenance carries no decision at all. It must +// never overwrite a known local state, however recent it appears. +func TestApplyNeverLetsUnknownOverwriteKnownStatus(t *testing.T) { + dir := t.TempDir() + path := writeRecord(t, dir, "local_a.json", + `{"sessionId":"local_a","cliSessionId":"abc","isArchived":true}`) + + incoming := Incoming{ + Record: &Record{fields: map[string]any{"sessionId": "local_r", "cliSessionId": "abc"}}, + ObservedAt: time.Now().Add(time.Hour), + } + + if _, err := Apply(dir, []Incoming{incoming}); err != nil { + t.Fatalf("Apply() error: %v", err) + } + if got := archiveStatusOnDisk(t, path); got != ArchiveArchived { + t.Errorf("on-disk status = %v, want archived (unknown label clobbered it)", got) + } +} + +// A record's mtime is its observation time on the next push. If Apply leaves +// a newly written record stamped "now", this machine later reports the other +// device's decision as though it were made here, just now — and that inflated +// time can beat a genuinely later change back on the originating device. +func TestApplyStampsNewRecordWithObservedAt(t *testing.T) { + dir := t.TempDir() + observed := time.Now().Add(-48 * time.Hour).Truncate(time.Second) + in := Incoming{ + Record: &Record{fields: map[string]any{"sessionId": "local_a", "cliSessionId": "abc", "isArchived": true}}, + ObservedAt: observed, + } + + if _, err := Apply(dir, []Incoming{in}); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dir, "local_a.json")) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(observed) { + t.Errorf("record mtime = %v, want the incoming ObservedAt %v", info.ModTime(), observed) + } +} + +// The same holds when an existing record adopts a remote archive decision: +// the record should carry the time of that decision, not the time of the pull. +func TestApplyStampsReconciledRecordWithObservedAt(t *testing.T) { + dir := t.TempDir() + path := writeRecord(t, dir, "local_a.json", + `{"sessionId":"local_a","cliSessionId":"abc","isArchived":false}`) + stale := time.Now().Add(-72 * time.Hour) + if err := os.Chtimes(path, stale, stale); err != nil { + t.Fatal(err) + } + observed := time.Now().Add(-24 * time.Hour).Truncate(time.Second) + in := Incoming{ + Record: &Record{fields: map[string]any{"sessionId": "local_r", "cliSessionId": "abc", "isArchived": true}}, + ObservedAt: observed, + } + + res, err := Apply(dir, []Incoming{in}) + if err != nil { + t.Fatal(err) + } + if res.Updated != 1 { + t.Fatalf("Updated = %d, want 1", res.Updated) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(observed) { + t.Errorf("reconciled record mtime = %v, want ObservedAt %v", info.ModTime(), observed) + } +} diff --git a/internal/desktop/index.go b/internal/desktop/index.go new file mode 100644 index 0000000..92d278c --- /dev/null +++ b/internal/desktop/index.go @@ -0,0 +1,64 @@ +// Package desktop reads and writes the Claude desktop app's session index. +// +// The desktop app does not render its sidebar from ~/.claude/projects. It keeps +// a separate index of small pointer records at +// claude-code-sessions///local_.json, each naming a +// transcript through its cliSessionId field. Syncing ~/.claude alone therefore +// restores conversations for `claude --resume` while leaving the sidebar empty. +package desktop + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// ErrNoIndex reports that no desktop session index exists. A machine that has +// never opened the desktop app is an ordinary skip, not a sync failure. +var ErrNoIndex = errors.New("no desktop session index found") + +// ErrAmbiguousIndex reports that several / leaves exist and +// none can be chosen safely. +var ErrAmbiguousIndex = errors.New("multiple desktop session index directories") + +// IndexDir returns the / leaf directory holding the index +// records under root. +func IndexDir(root string) (string, error) { + var found []string + + accounts, err := os.ReadDir(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("%w under %s", ErrNoIndex, root) + } + return "", err + } + for _, account := range accounts { + if !account.IsDir() { + continue + } + workspaces, err := os.ReadDir(filepath.Join(root, account.Name())) + if err != nil { + continue + } + for _, workspace := range workspaces { + if !workspace.IsDir() { + continue + } + found = append(found, filepath.Join(root, account.Name(), workspace.Name())) + } + } + + sort.Strings(found) + switch len(found) { + case 0: + return "", fmt.Errorf("%w under %s", ErrNoIndex, root) + case 1: + return found[0], nil + default: + return "", fmt.Errorf("%w under %s: %s", ErrAmbiguousIndex, root, strings.Join(found, ", ")) + } +} diff --git a/internal/desktop/index_test.go b/internal/desktop/index_test.go new file mode 100644 index 0000000..3893fdb --- /dev/null +++ b/internal/desktop/index_test.go @@ -0,0 +1,70 @@ +package desktop + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// The desktop index lives at claude-code-sessions///, and +// those two UUID segments differ between machines. Pull must discover the local +// pair rather than reusing the ones baked into synced records, or it writes +// into a directory the local app never reads. +func TestIndexDirFindsSingleLeaf(t *testing.T) { + root := t.TempDir() + want := filepath.Join(root, "6dfc58e6-account", "580d1a7a-workspace") + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + + got, err := IndexDir(root) + if err != nil { + t.Fatalf("IndexDir() unexpected error: %v", err) + } + if got != want { + t.Errorf("IndexDir() = %q, want %q", got, want) + } +} + +// Silently picking one of several candidates would write records into a +// directory the running app may not read, producing a pull that reports success +// and changes nothing the user can see. Fail loudly and name the candidates. +func TestIndexDirRejectsAmbiguousLeaves(t *testing.T) { + root := t.TempDir() + for _, leaf := range []string{"acct-a/ws-one", "acct-b/ws-two"} { + if err := os.MkdirAll(filepath.Join(root, leaf), 0o755); err != nil { + t.Fatal(err) + } + } + + _, err := IndexDir(root) + if !errors.Is(err, ErrAmbiguousIndex) { + t.Fatalf("IndexDir() error = %v, want ErrAmbiguousIndex", err) + } + for _, want := range []string{"ws-one", "ws-two"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not name candidate %q", err, want) + } + } +} + +// A machine that has never opened the desktop app has no index at all. That is +// an ordinary skip, not a failure, so callers need to distinguish it. +func TestIndexDirReportsMissingIndex(t *testing.T) { + if _, err := IndexDir(t.TempDir()); !errors.Is(err, ErrNoIndex) { + t.Fatalf("IndexDir() error = %v, want ErrNoIndex", err) + } +} + +// A CLI-only machine has no claude-code-sessions directory whatsoever. That is +// the same "nothing to do" condition as an empty index and must not surface as +// an opaque filesystem error that aborts an otherwise healthy pull. +func TestIndexDirTreatsMissingRootAsNoIndex(t *testing.T) { + missing := filepath.Join(t.TempDir(), "claude-code-sessions") + + if _, err := IndexDir(missing); !errors.Is(err, ErrNoIndex) { + t.Fatalf("IndexDir() error = %v, want ErrNoIndex", err) + } +} diff --git a/internal/desktop/record.go b/internal/desktop/record.go new file mode 100644 index 0000000..dc7537a --- /dev/null +++ b/internal/desktop/record.go @@ -0,0 +1,154 @@ +package desktop + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Record is one desktop sidebar index record. +// +// It is deliberately backed by a generic field map rather than a typed struct. +// The schema is owned by the desktop app and gains fields across versions; a +// struct would drop everything it has not been taught about each time a record +// is rewritten. Only the fields this tool reasons about are accessed by name. +type Record struct { + fields map[string]any +} + +// LoadRecord reads an index record from disk, preserving every field verbatim. +func LoadRecord(path string) (*Record, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var fields map[string]any + if err := json.Unmarshal(data, &fields); err != nil { + return nil, fmt.Errorf("parse index record %s: %w", path, err) + } + return &Record{fields: fields}, nil +} + +// Save writes the record, carrying unknown fields through unchanged. Records +// hold conversation titles and working directories, so they are written with +// the same owner-only permissions the desktop app uses. +func (r *Record) Save(path string) error { + data, err := json.Marshal(r.fields) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +// ArchiveStatus is the tri-state archive state of a session. +// +// Archive state lives only in the desktop index: no record type in a CLI +// transcript encodes it. A machine without the desktop app therefore has no +// information about a session, which is distinct from knowing it is active. +// ArchiveUnknown is the zero value so a status that was never populated can +// never be mistaken for "active" and un-archive the session elsewhere. +type ArchiveStatus int + +const ( + ArchiveUnknown ArchiveStatus = iota + ArchiveActive + ArchiveArchived +) + +func (s ArchiveStatus) String() string { + switch s { + case ArchiveActive: + return "active" + case ArchiveArchived: + return "archived" + default: + return "unknown" + } +} + +// ArchiveStatus reports whether the session is archived. A missing or +// non-boolean isArchived field yields ArchiveUnknown rather than a guess. +func (r *Record) ArchiveStatus() ArchiveStatus { + value, ok := r.fields["isArchived"] + if !ok { + return ArchiveUnknown + } + archived, ok := value.(bool) + if !ok { + return ArchiveUnknown + } + if archived { + return ArchiveArchived + } + return ArchiveActive +} + +// CLISessionID returns the transcript this record points at. It is the stable +// identity across machines; the record's own sessionId is machine-local and +// must never be used to match records between devices. +func (r *Record) CLISessionID() string { + id, _ := r.fields["cliSessionId"].(string) + return id +} + +// fileName returns the on-disk name for this record. +// +// sessionId reaches this tool from remote storage and is used to name a file, +// which makes it untrusted input to a filesystem write. Anything that is not a +// plain file name is refused rather than cleaned, so a malformed or hostile +// record can never place a file outside the index directory. +func (r *Record) fileName() (string, error) { + id, _ := r.fields["sessionId"].(string) + if id == "" { + id = "local_" + r.CLISessionID() + } + if id == "" || id == "." || id == ".." { + return "", fmt.Errorf("record has no usable session id") + } + if strings.ContainsAny(id, `/\`) || strings.Contains(id, "..") { + return "", fmt.Errorf("record session id %q is not a plain file name", id) + } + name := id + ".json" + if name != filepath.Base(name) { + return "", fmt.Errorf("record session id %q is not a plain file name", id) + } + return name, nil +} + +// setArchiveStatus records a known archive decision. ArchiveUnknown is ignored: +// it carries no decision and must not write a value into the record. +func (r *Record) setArchiveStatus(status ArchiveStatus) { + switch status { + case ArchiveArchived: + r.fields["isArchived"] = true + case ArchiveActive: + r.fields["isArchived"] = false + } +} + +// UnmarshalJSON stores every field verbatim so a record can be carried through +// a sync payload without losing anything this tool does not model. +func (r *Record) UnmarshalJSON(data []byte) error { + var fields map[string]any + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + r.fields = fields + return nil +} + +// MarshalJSON writes the record back out exactly as it came in. +func (r *Record) MarshalJSON() ([]byte, error) { + if r.fields == nil { + return []byte("null"), nil + } + return json.Marshal(r.fields) +} + +// NewRecord builds a record from an already-decoded field map. It exists for +// callers outside this package that assemble records from a sync payload. +func NewRecord(fields map[string]any) *Record { + return &Record{fields: fields} +} diff --git a/internal/desktop/record_test.go b/internal/desktop/record_test.go new file mode 100644 index 0000000..665ad0a --- /dev/null +++ b/internal/desktop/record_test.go @@ -0,0 +1,117 @@ +package desktop + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The record schema belongs to the desktop app, is undocumented, and gains +// fields across app versions. Modelling it as a fixed struct would silently drop +// anything this tool has not been taught about, corrupting records on every +// round-trip. Carry unknown fields through untouched. +func TestRecordRoundTripPreservesUnknownFields(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "local_source.json") + original := `{"sessionId":"local_a","cliSessionId":"abc-123","isArchived":false,` + + `"fieldFromANewerApp":{"nested":[1,2]}}` + if err := os.WriteFile(src, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + + rec, err := LoadRecord(src) + if err != nil { + t.Fatalf("LoadRecord() error: %v", err) + } + + dst := filepath.Join(dir, "local_dest.json") + if err := rec.Save(dst); err != nil { + t.Fatalf("Save() error: %v", err) + } + + var got map[string]any + data, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("saved record is not valid JSON: %v", err) + } + if _, ok := got["fieldFromANewerApp"]; !ok { + t.Errorf("unknown field dropped on round-trip; got keys %v", keysOf(got)) + } + if got["cliSessionId"] != "abc-123" { + t.Errorf("cliSessionId = %v, want abc-123", got["cliSessionId"]) + } +} + +func keysOf(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// Archive state exists only in the desktop index; nothing in a CLI transcript +// encodes it. A machine that never opened the desktop app therefore knows +// nothing about a session's archive state, which is NOT the same as knowing it +// is active. Collapsing the two into a bool would let a CLI-only push report +// every session as active and un-archive them on every other machine. +func TestArchiveStatusDistinguishesUnknownFromActive(t *testing.T) { + cases := []struct { + name string + body string + want ArchiveStatus + }{ + {"explicitly archived", `{"isArchived":true}`, ArchiveArchived}, + {"explicitly active", `{"isArchived":false}`, ArchiveActive}, + {"field absent entirely", `{"cliSessionId":"x"}`, ArchiveUnknown}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "local_r.json") + if err := os.WriteFile(path, []byte(tc.body), 0o600); err != nil { + t.Fatal(err) + } + rec, err := LoadRecord(path) + if err != nil { + t.Fatal(err) + } + if got := rec.ArchiveStatus(); got != tc.want { + t.Errorf("ArchiveStatus() = %v, want %v", got, tc.want) + } + }) + } +} + +// Records travel to and from remote storage inside a sync payload, so they must +// survive encoding/json in both directions with unknown fields intact. +func TestRecordSurvivesEncodingJSONRoundTrip(t *testing.T) { + src := []byte(`{"cliSessionId":"abc","isArchived":true,"unknownField":42}`) + + var rec Record + if err := json.Unmarshal(src, &rec); err != nil { + t.Fatalf("Unmarshal() error: %v", err) + } + if rec.CLISessionID() != "abc" { + t.Errorf("CLISessionID() = %q, want abc", rec.CLISessionID()) + } + if rec.ArchiveStatus() != ArchiveArchived { + t.Errorf("ArchiveStatus() = %v, want archived", rec.ArchiveStatus()) + } + + out, err := json.Marshal(&rec) + if err != nil { + t.Fatalf("Marshal() error: %v", err) + } + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatal(err) + } + if got["unknownField"] != float64(42) { + t.Errorf("unknownField = %v, want 42 (dropped in transit)", got["unknownField"]) + } +} diff --git a/internal/desktop/root.go b/internal/desktop/root.go new file mode 100644 index 0000000..0999825 --- /dev/null +++ b/internal/desktop/root.go @@ -0,0 +1,47 @@ +package desktop + +import ( + "errors" + "os" + "path/filepath" + "runtime" +) + +// sessionsDirName is the directory the desktop app keeps its sidebar index in, +// below the app's Electron userData directory. +const sessionsDirName = "claude-code-sessions" + +// ErrNoHomeDir is returned when the user's home directory cannot be determined. +var ErrNoHomeDir = errors.New("could not determine home directory") + +// sessionsRoot resolves the index root for a platform. +// +// The darwin layout is confirmed against a real installation. The windows and +// linux layouts follow Electron's userData convention, which this app is built +// on, but have not been verified on those platforms. +func sessionsRoot(goos, home, appData string) (string, error) { + if home == "" && appData == "" { + return "", ErrNoHomeDir + } + switch goos { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Claude", sessionsDirName), nil + case "windows": + base := appData + if base == "" { + base = filepath.Join(home, "AppData", "Roaming") + } + return filepath.Join(base, "Claude", sessionsDirName), nil + default: + return filepath.Join(home, ".config", "Claude", sessionsDirName), nil + } +} + +// SessionsRoot returns the desktop session index root for this machine. +func SessionsRoot() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", ErrNoHomeDir + } + return sessionsRoot(runtime.GOOS, home, os.Getenv("APPDATA")) +} diff --git a/internal/desktop/root_test.go b/internal/desktop/root_test.go new file mode 100644 index 0000000..09e4619 --- /dev/null +++ b/internal/desktop/root_test.go @@ -0,0 +1,47 @@ +package desktop + +import ( + "path/filepath" + "testing" +) + +// Only the darwin layout has been observed on a real machine. The windows and +// linux layouts follow Electron's userData convention, which the desktop app +// uses, but they are inferred rather than verified. +func TestSessionsRootPerPlatform(t *testing.T) { + const home = "/home/u" + cases := []struct { + goos string + appData string + want string + }{ + {"darwin", "", filepath.Join(home, "Library", "Application Support", "Claude", "claude-code-sessions")}, + {"linux", "", filepath.Join(home, ".config", "Claude", "claude-code-sessions")}, + {"windows", `C:\Users\u\AppData\Roaming`, filepath.Join(`C:\Users\u\AppData\Roaming`, "Claude", "claude-code-sessions")}, + } + + for _, tc := range cases { + t.Run(tc.goos, func(t *testing.T) { + got, err := sessionsRoot(tc.goos, home, tc.appData) + if err != nil { + t.Fatalf("sessionsRoot() error: %v", err) + } + if got != tc.want { + t.Errorf("sessionsRoot(%q) = %q, want %q", tc.goos, got, tc.want) + } + }) + } +} + +// APPDATA is the authoritative location on Windows; falling back to a guessed +// AppData\Roaming path is only correct when the variable is unset. +func TestSessionsRootFallsBackWhenAppDataUnset(t *testing.T) { + got, err := sessionsRoot("windows", "/home/u", "") + if err != nil { + t.Fatal(err) + } + want := filepath.Join("/home/u", "AppData", "Roaming", "Claude", "claude-code-sessions") + if got != want { + t.Errorf("sessionsRoot() = %q, want %q", got, want) + } +} diff --git a/internal/desktop/scan.go b/internal/desktop/scan.go new file mode 100644 index 0000000..cbe2151 --- /dev/null +++ b/internal/desktop/scan.go @@ -0,0 +1,80 @@ +package desktop + +import ( + "os" + "path/filepath" + "time" +) + +// Label is the archive state of one session together with when that state was +// last observed. ObservedAt comes from the record's modification time: the +// desktop app rewrites a record when the session is archived or unarchived, so +// the mtime tracks the decision without adding a field to an app-owned schema. +type Label struct { + Status ArchiveStatus + ObservedAt time.Time +} + +// Scan reads the local index and returns each session's archive label, keyed by +// cliSessionId — the identity that is stable across machines. +// +// A missing index is not an error: a machine that has never opened the desktop +// app simply contributes no labels, leaving push otherwise unchanged. +func Scan(dir string) (map[string]Label, error) { + matches, err := filepath.Glob(filepath.Join(dir, "local_*.json")) + if err != nil { + return nil, err + } + + labels := make(map[string]Label, len(matches)) + for _, path := range matches { + rec, err := LoadRecord(path) + if err != nil { + // An unparseable record is opaque, not fatal; it simply + // contributes no opinion about its session. + continue + } + id := rec.CLISessionID() + if id == "" { + continue + } + var observed time.Time + if info, err := os.Stat(path); err == nil { + observed = info.ModTime() + } + labels[id] = Label{Status: rec.ArchiveStatus(), ObservedAt: observed} + } + return labels, nil +} + +// Snapshot is one index record together with when it was last written. +type Snapshot struct { + Record *Record + ObservedAt time.Time +} + +// Snapshots loads every index record in dir. A missing index yields no +// snapshots rather than an error, so a CLI-only machine stays a normal machine. +func Snapshots(dir string) ([]Snapshot, error) { + matches, err := filepath.Glob(filepath.Join(dir, "local_*.json")) + if err != nil { + return nil, err + } + + out := make([]Snapshot, 0, len(matches)) + for _, path := range matches { + rec, err := LoadRecord(path) + if err != nil { + continue + } + if rec.CLISessionID() == "" { + continue + } + var observed time.Time + if info, err := os.Stat(path); err == nil { + observed = info.ModTime() + } + out = append(out, Snapshot{Record: rec, ObservedAt: observed}) + } + return out, nil +} diff --git a/internal/desktop/scan_test.go b/internal/desktop/scan_test.go new file mode 100644 index 0000000..5ecd322 --- /dev/null +++ b/internal/desktop/scan_test.go @@ -0,0 +1,67 @@ +package desktop + +import ( + "os" + "testing" + "time" +) + +// Push needs the archive state of every session the local sidebar knows about, +// keyed by the cross-machine identity, together with when that state was last +// touched so the other end can settle conflicts. +func TestScanLabelsSessionsByCLISessionID(t *testing.T) { + dir := t.TempDir() + writeRecord(t, dir, "local_one.json", + `{"sessionId":"local_one","cliSessionId":"aaa","isArchived":true}`) + writeRecord(t, dir, "local_two.json", + `{"sessionId":"local_two","cliSessionId":"bbb","isArchived":false}`) + + labels, err := Scan(dir) + if err != nil { + t.Fatalf("Scan() error: %v", err) + } + if len(labels) != 2 { + t.Fatalf("Scan() returned %d labels, want 2", len(labels)) + } + if got := labels["aaa"].Status; got != ArchiveArchived { + t.Errorf("aaa status = %v, want archived", got) + } + if got := labels["bbb"].Status; got != ArchiveActive { + t.Errorf("bbb status = %v, want active", got) + } + if labels["aaa"].ObservedAt.IsZero() { + t.Error("label carries no observation time; conflicts cannot be settled") + } +} + +// A machine with no desktop index must report no labels rather than an error, +// so a CLI-only push stays a normal push. +func TestScanOnMissingDirReportsNoLabels(t *testing.T) { + labels, err := Scan(t.TempDir() + "/absent") + if err != nil { + t.Fatalf("Scan() error: %v", err) + } + if len(labels) != 0 { + t.Errorf("Scan() returned %d labels, want 0", len(labels)) + } +} + +// The observation time must track the record, not the moment of the scan, or +// every push would look like the freshest decision and always win. +func TestScanUsesRecordModificationTime(t *testing.T) { + dir := t.TempDir() + path := writeRecord(t, dir, "local_one.json", + `{"sessionId":"local_one","cliSessionId":"aaa","isArchived":true}`) + stale := time.Now().Add(-72 * time.Hour) + if err := os.Chtimes(path, stale, stale); err != nil { + t.Fatal(err) + } + + labels, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + if got := labels["aaa"].ObservedAt; got.After(time.Now().Add(-time.Hour)) { + t.Errorf("ObservedAt = %v, want the record's stale mtime", got) + } +} diff --git a/internal/sync/desktop.go b/internal/sync/desktop.go new file mode 100644 index 0000000..94930bb --- /dev/null +++ b/internal/sync/desktop.go @@ -0,0 +1,348 @@ +package sync + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/tawanorg/claude-sync/internal/config" + "github.com/tawanorg/claude-sync/internal/desktop" +) + +// desktopPayloadVersion versions the wire format so an older client can +// recognise a payload it does not understand instead of misreading it. +const desktopPayloadVersion = 1 + +// desktopEntry carries one index record plus when the pushing machine last +// observed it. The observation time travels beside the record rather than +// inside it: the record schema belongs to the desktop app and must not gain +// fields invented here. +type desktopEntry struct { + ObservedAt time.Time `json:"observed_at"` + Record *desktop.Record `json:"record"` +} + +type desktopPayload struct { + Version int `json:"version"` + Entries []desktopEntry `json:"entries"` +} + +// DesktopPushResult summarises a desktop index push. +type DesktopPushResult struct { + RecordsPushed int + Unchanged bool + NoIndex bool +} + +// DesktopPullResult summarises a desktop index pull. +type DesktopPullResult struct { + Written int + Updated int + Skipped int + + // MissingTranscript counts sessions skipped because this machine has no + // transcript for them. Writing those records would produce sidebar rows + // that open onto "Session not found on disk". + MissingTranscript int + + NoIndex bool + NoRemote bool +} + +// desktopIndexDir resolves the index directory, preferring an explicit override. +func (s *Syncer) desktopIndexDir() (string, error) { + if s.desktopDir != "" { + return s.desktopDir, nil + } + root, err := desktop.SessionsRoot() + if err != nil { + return "", err + } + return desktop.IndexDir(root) +} + +// PushDesktop uploads the desktop sidebar index. +// +// The sidebar renders from this index, not from ~/.claude/projects, so syncing +// conversations without it leaves them resumable from the CLI but invisible in +// the app. A machine with no desktop app contributes nothing and is not an +// error. +func (s *Syncer) PushDesktop(ctx context.Context) (*DesktopPushResult, error) { + result := &DesktopPushResult{} + + dir, err := s.desktopIndexDir() + if err != nil { + if errors.Is(err, desktop.ErrNoIndex) { + result.NoIndex = true + return result, nil + } + return nil, err + } + + snapshots, err := desktop.Snapshots(dir) + if err != nil { + return nil, fmt.Errorf("failed to read desktop index: %w", err) + } + if len(snapshots) == 0 { + result.Unchanged = true + return result, nil + } + + payload := desktopPayload{Version: desktopPayloadVersion} + for _, snap := range snapshots { + payload.Entries = append(payload.Entries, desktopEntry{ + ObservedAt: snap.ObservedAt, + Record: snap.Record, + }) + } + // Stable ordering keeps the hash meaningful, so an unchanged index does not + // re-upload merely because the filesystem returned a different order. + sort.Slice(payload.Entries, func(i, j int) bool { + return payload.Entries[i].Record.CLISessionID() < payload.Entries[j].Record.CLISessionID() + }) + + data, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to serialize desktop index: %w", err) + } + + sum := sha256.Sum256(data) + hash := hex.EncodeToString(sum[:]) + if existing := s.state.GetFile(config.DesktopRemoteKey); existing != nil && existing.Hash == hash { + result.Unchanged = true + return result, nil + } + + compressed, err := gzipCompress(data) + if err != nil { + return nil, fmt.Errorf("failed to compress desktop index: %w", err) + } + encrypted, err := s.encryptor.Encrypt(compressed) + if err != nil { + return nil, fmt.Errorf("failed to encrypt desktop index: %w", err) + } + if err := s.storage.Upload(ctx, config.DesktopRemoteKey+".age", encrypted); err != nil { + return nil, fmt.Errorf("failed to upload desktop index: %w", err) + } + + s.state.mu.Lock() + s.state.Files[config.DesktopRemoteKey] = &FileState{ + Path: config.DesktopRemoteKey, + Hash: hash, + Size: int64(len(data)), + ModTime: time.Now(), + Uploaded: time.Now(), + } + s.state.mu.Unlock() + + if err := s.state.Save(); err != nil { + return nil, fmt.Errorf("failed to save state: %w", err) + } + + result.RecordsPushed = len(payload.Entries) + return result, nil +} + +// PullDesktop applies the remote desktop index to this machine's sidebar. +// +// Records for sessions the sidebar has never seen are created; sessions it +// already knows about keep their local record and only have their archive state +// reconciled, last-writer-wins. +func (s *Syncer) PullDesktop(ctx context.Context) (*DesktopPullResult, error) { + result := &DesktopPullResult{} + + dir, err := s.desktopIndexDir() + if err != nil { + if errors.Is(err, desktop.ErrNoIndex) { + result.NoIndex = true + return result, nil + } + return nil, err + } + info, err := os.Stat(dir) + switch { + case errors.Is(err, os.ErrNotExist): + // No desktop app on this machine: nothing to hydrate. + result.NoIndex = true + return result, nil + case err != nil: + // Anything else — a permission problem, a file where a directory + // should be — is a real failure on a machine that may well have the + // app installed. Do not disguise it as "no desktop app". + return nil, fmt.Errorf("desktop index directory: %w", err) + case !info.IsDir(): + return nil, fmt.Errorf("desktop index path is not a directory: %s", dir) + } + + // The storage layer has no not-found sentinel across providers, so a bare + // Download error cannot tell "never pushed" apart from "unreachable" or + // "forbidden". Listing the exact key first can: an empty listing is a + // genuine absence, a listing error is a storage failure, and a download + // that fails after the key was listed is likewise a real failure. + remoteKey := config.DesktopRemoteKey + ".age" + present, err := s.remoteKeyExists(ctx, remoteKey) + if err != nil { + return nil, fmt.Errorf("failed to check remote desktop index: %w", err) + } + if !present { + result.NoRemote = true + return result, nil + } + encrypted, err := s.storage.Download(ctx, remoteKey) + if err != nil { + return nil, fmt.Errorf("failed to download desktop index: %w", err) + } + + compressed, err := s.encryptor.Decrypt(encrypted) + if err != nil { + return nil, fmt.Errorf("failed to decrypt desktop index: %w", err) + } + data, err := gzipDecompress(compressed) + if err != nil { + return nil, fmt.Errorf("failed to decompress desktop index: %w", err) + } + + var payload desktopPayload + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("failed to parse desktop index: %w", err) + } + if payload.Version > desktopPayloadVersion { + return nil, fmt.Errorf("desktop index was written by a newer claude-sync (format v%d); upgrade to pull it", payload.Version) + } + + // A sidebar record points at a transcript by id. If this machine does not + // have that transcript, the row it creates opens onto "Session not found on + // disk" — the index is intact but the conversation behind it is elsewhere. + // Skip those and report them, so the remedy (pull the conversations first) + // is visible instead of appearing as a broken sidebar. + local := localTranscriptIDs(s.claudeDir) + + incoming := make([]desktop.Incoming, 0, len(payload.Entries)) + for _, entry := range payload.Entries { + if entry.Record == nil { + continue + } + if id := entry.Record.CLISessionID(); id != "" && !local[id] { + result.MissingTranscript++ + continue + } + incoming = append(incoming, desktop.Incoming{ + Record: entry.Record, + ObservedAt: entry.ObservedAt, + }) + } + + applied, err := desktop.Apply(dir, incoming) + if err != nil { + return nil, fmt.Errorf("failed to apply desktop index: %w", err) + } + result.Written = applied.Written + result.Updated = applied.Updated + result.Skipped = applied.Skipped + return result, nil +} + +// SetSkipArchived controls whether push omits archived sessions' transcript +// bodies. The archive labels themselves are always pushed, so the other machine +// still learns that a session was archived; only the bulk transcript is held +// back. Omitting the label too would let an archive decision made after the +// first sync never reach the other device. +func (s *Syncer) SetSkipArchived(skip bool) { + s.skipArchived = skip +} + +// sessionIDFromPath returns the session a transcript path belongs to. +// +// Transcripts live at projects//.jsonl exactly one +// level below a project directory. Anything else — history.jsonl, task files, +// nested artefacts — belongs to no single session and is never filtered. +func sessionIDFromPath(relPath string) (string, bool) { + parts := strings.Split(filepath.ToSlash(relPath), "/") + if len(parts) != 3 || parts[0] != "projects" { + return "", false + } + name := parts[2] + if !strings.HasSuffix(name, ".jsonl") { + return "", false + } + return strings.TrimSuffix(name, ".jsonl"), true +} + +// archivedSessionIDs reports which sessions the local desktop index marks +// archived. A machine without a desktop index knows of none. +func (s *Syncer) archivedSessionIDs() map[string]bool { + dir, err := s.desktopIndexDir() + if err != nil { + return nil + } + labels, err := desktop.Scan(dir) + if err != nil { + return nil + } + archived := make(map[string]bool) + for id, label := range labels { + if label.Status == desktop.ArchiveArchived { + archived[id] = true + } + } + return archived +} + +// pushExcluder composes the configured excludes with --skip-archived. It is +// used only by push: pull and status keep the plain exclude set so the flag +// cannot silently change what a download or a status report covers. +func (s *Syncer) pushExcluder() func(string) bool { + if !s.skipArchived { + return s.isExcluded + } + archived := s.archivedSessionIDs() + if len(archived) == 0 { + return s.isExcluded + } + return func(relPath string) bool { + if s.isExcluded(relPath) { + return true + } + id, ok := sessionIDFromPath(relPath) + return ok && archived[id] + } +} + +// localTranscriptIDs returns the session ids this machine holds transcripts for. +// Transcripts live at projects//.jsonl. +func localTranscriptIDs(claudeDir string) map[string]bool { + ids := make(map[string]bool) + matches, err := filepath.Glob(filepath.Join(claudeDir, "projects", "*", "*.jsonl")) + if err != nil { + return ids + } + for _, path := range matches { + ids[strings.TrimSuffix(filepath.Base(path), ".jsonl")] = true + } + return ids +} + +// remoteKeyExists reports whether key is present on the remote, using a +// prefix listing on the exact key. Unlike Download, a listing distinguishes +// "not there" (empty result) from "cannot reach storage" (error) without +// relying on a provider-specific not-found error. +func (s *Syncer) remoteKeyExists(ctx context.Context, key string) (bool, error) { + objects, err := s.storage.List(ctx, key) + if err != nil { + return false, err + } + for _, obj := range objects { + if obj.Key == key { + return true, nil + } + } + return false, nil +} diff --git a/internal/sync/desktop_test.go b/internal/sync/desktop_test.go new file mode 100644 index 0000000..7832723 --- /dev/null +++ b/internal/sync/desktop_test.go @@ -0,0 +1,286 @@ +package sync + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/tawanorg/claude-sync/internal/desktop" + "github.com/tawanorg/claude-sync/internal/storage" +) + +func makeIndex(t *testing.T, records map[string]string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "acct", "workspace") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for name, body := range records { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + return dir +} + +// The desktop sidebar renders from an index this tool did not previously sync. +// A conversation that reaches a second machine through ~/.claude alone is +// resumable from the CLI but invisible in the sidebar; carrying the index +// records is what closes that gap. +func TestDesktopPushPullRoundTrip(t *testing.T) { + ctx := context.Background() + + envA := setupTestEnv(t) + envA.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_one.json": `{"sessionId":"local_one","cliSessionId":"aaa","isArchived":false,"quirk":7}`, + }) + + pushed, err := envA.syncer.PushDesktop(ctx) + if err != nil { + t.Fatalf("PushDesktop() error: %v", err) + } + if pushed.RecordsPushed != 1 { + t.Fatalf("RecordsPushed = %d, want 1", pushed.RecordsPushed) + } + + // A second machine sharing the same bucket and key, with an empty index. + stateB, err := LoadStateFromDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + // Machine B must hold the transcript, or the record is correctly skipped as + // one that would produce a dead sidebar row. + claudeB := t.TempDir() + projB := filepath.Join(claudeB, "projects", "-Users-x-proj") + if err := os.MkdirAll(projB, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projB, "aaa.jsonl"), []byte(`{"type":"user"}`), 0o600); err != nil { + t.Fatal(err) + } + + syncerB := NewSyncerWith(envA.syncer.cfg, envA.store, envA.syncer.encryptor, stateB, claudeB, true) + indexB := makeIndex(t, nil) + syncerB.desktopDir = indexB + + pulled, err := syncerB.PullDesktop(ctx) + if err != nil { + t.Fatalf("PullDesktop() error: %v", err) + } + if pulled.Written != 1 { + t.Errorf("Written = %d, want 1", pulled.Written) + } + + labels, err := desktop.Scan(indexB) + if err != nil { + t.Fatal(err) + } + if _, ok := labels["aaa"]; !ok { + t.Fatalf("session aaa did not reach the second machine; got %v", labels) + } + + // The app owns this schema: fields we do not model must survive the trip. + got, err := os.ReadFile(filepath.Join(indexB, "local_one.json")) + if err != nil { + t.Fatal(err) + } + if !containsKey(got, "quirk") { + t.Errorf("unknown field dropped in transit: %s", got) + } +} + +// Pushing an unchanged index must not re-upload, matching how MCP push behaves. +func TestDesktopPushSkipsUnchangedIndex(t *testing.T) { + ctx := context.Background() + env := setupTestEnv(t) + env.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_one.json": `{"sessionId":"local_one","cliSessionId":"aaa","isArchived":false}`, + }) + + if _, err := env.syncer.PushDesktop(ctx); err != nil { + t.Fatal(err) + } + again, err := env.syncer.PushDesktop(ctx) + if err != nil { + t.Fatal(err) + } + if !again.Unchanged { + t.Errorf("second PushDesktop() Unchanged = false, want true") + } +} + +// A machine with no desktop app must push and pull without error. +func TestDesktopOpsAreNoOpWithoutIndex(t *testing.T) { + ctx := context.Background() + env := setupTestEnv(t) + env.syncer.desktopDir = filepath.Join(t.TempDir(), "absent") + + pushed, err := env.syncer.PushDesktop(ctx) + if err != nil { + t.Fatalf("PushDesktop() on missing index: %v", err) + } + if pushed.RecordsPushed != 0 { + t.Errorf("RecordsPushed = %d, want 0", pushed.RecordsPushed) + } + + if _, err := env.syncer.PullDesktop(ctx); err != nil { + t.Fatalf("PullDesktop() on missing index: %v", err) + } +} + +func containsKey(data []byte, key string) bool { + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return false + } + _, ok := m[key] + return ok +} + +// A sidebar entry points at a transcript by id. Writing one for a transcript +// this machine does not have produces a row that opens onto "Session not found +// on disk" — the index is fine, the conversation behind it simply is not here. +// Skip those and say so, rather than manufacturing dead rows. +func TestPullDesktopSkipsSessionsWithoutLocalTranscripts(t *testing.T) { + ctx := context.Background() + + envA := setupTestEnv(t) + envA.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_here.json": `{"sessionId":"local_here","cliSessionId":"present-one","isArchived":false}`, + "local_gone.json": `{"sessionId":"local_gone","cliSessionId":"absent-one","isArchived":false}`, + }) + if _, err := envA.syncer.PushDesktop(ctx); err != nil { + t.Fatal(err) + } + + // Machine B has the transcript for only one of the two sessions. + stateB, err := LoadStateFromDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + claudeB := t.TempDir() + projB := filepath.Join(claudeB, "projects", "-Users-x-proj") + if err := os.MkdirAll(projB, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projB, "present-one.jsonl"), []byte(`{"type":"user"}`), 0o600); err != nil { + t.Fatal(err) + } + + syncerB := NewSyncerWith(envA.syncer.cfg, envA.store, envA.syncer.encryptor, stateB, claudeB, true) + indexB := makeIndex(t, nil) + syncerB.desktopDir = indexB + + result, err := syncerB.PullDesktop(ctx) + if err != nil { + t.Fatalf("PullDesktop() error: %v", err) + } + if result.Written != 1 { + t.Errorf("Written = %d, want 1 (only the session whose transcript is present)", result.Written) + } + if result.MissingTranscript != 1 { + t.Errorf("MissingTranscript = %d, want 1", result.MissingTranscript) + } + + labels, err := desktop.Scan(indexB) + if err != nil { + t.Fatal(err) + } + if _, ok := labels["absent-one"]; ok { + t.Error("wrote a sidebar entry for a transcript this machine does not have") + } + if _, ok := labels["present-one"]; !ok { + t.Error("failed to write the entry whose transcript is present") + } +} + +// failingStorage wraps the mock so a test can inject the kinds of failure a +// real bucket produces — an unreachable endpoint, a rejected credential — as +// distinct from the object simply not existing. +type failingStorage struct { + *mockStorage + listErr error + downloadErr error +} + +func (f *failingStorage) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) { + if f.listErr != nil { + return nil, f.listErr + } + return f.mockStorage.List(ctx, prefix) +} + +func (f *failingStorage) Download(ctx context.Context, key string) ([]byte, error) { + if f.downloadErr != nil { + return nil, f.downloadErr + } + return f.mockStorage.Download(ctx, key) +} + +// "Nothing pushed yet" is a specific, benign condition. A storage that cannot +// be reached, or that rejects the credentials, is neither — reporting it as +// "nothing pushed yet" sends the user to check the other machine when the +// problem is in front of them. +func TestPullDesktopDistinguishesStorageFailureFromAbsence(t *testing.T) { + ctx := context.Background() + + t.Run("key genuinely absent is NoRemote, not an error", func(t *testing.T) { + env := setupTestEnv(t) + env.syncer.desktopDir = makeIndex(t, nil) + res, err := env.syncer.PullDesktop(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.NoRemote { + t.Error("NoRemote = false, want true when nothing has been pushed") + } + }) + + t.Run("listing failure surfaces as an error", func(t *testing.T) { + env := setupTestEnv(t) + env.syncer.desktopDir = makeIndex(t, nil) + env.syncer.storage = &failingStorage{mockStorage: env.store, listErr: errors.New("dial tcp: connection refused")} + res, err := env.syncer.PullDesktop(ctx) + if err == nil { + t.Fatalf("got nil error and NoRemote=%v; want the storage failure surfaced", res.NoRemote) + } + }) + + t.Run("download failure of an existing key surfaces as an error", func(t *testing.T) { + env := setupTestEnv(t) + env.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_a.json": `{"sessionId":"local_a","cliSessionId":"aaa","isArchived":false}`, + }) + if _, err := env.syncer.PushDesktop(ctx); err != nil { + t.Fatal(err) + } + env.syncer.storage = &failingStorage{mockStorage: env.store, downloadErr: errors.New("403 Forbidden")} + res, err := env.syncer.PullDesktop(ctx) + if err == nil { + t.Fatalf("got nil error and NoRemote=%v; want the download failure surfaced", res.NoRemote) + } + }) +} + +// A missing index directory is the ordinary "no desktop app here" case. Any +// other stat failure — a permission problem, a file where a directory should +// be — is a real error on a machine that may well have the app installed. +func TestPullDesktopOnlyTreatsNotExistAsNoIndex(t *testing.T) { + ctx := context.Background() + env := setupTestEnv(t) + + // A path routed *through* a regular file stats with ENOTDIR, not ENOENT. + blocker := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + env.syncer.desktopDir = filepath.Join(blocker, "acct", "ws") + + res, err := env.syncer.PullDesktop(ctx) + if err == nil { + t.Fatalf("got nil error and NoIndex=%v; want the stat failure surfaced", res.NoIndex) + } +} diff --git a/internal/sync/external_keys_test.go b/internal/sync/external_keys_test.go new file mode 100644 index 0000000..cca6916 --- /dev/null +++ b/internal/sync/external_keys_test.go @@ -0,0 +1,66 @@ +package sync + +import ( + "context" + "strings" + "testing" + + "github.com/tawanorg/claude-sync/internal/config" +) + +// PushDesktop and PushMCP record their remote objects in state.Files so an +// unchanged payload is not re-uploaded. Those keys never correspond to a file +// under ~/.claude, so DetectChanges must not read their absence from the local +// tree as a deletion. Otherwise every push deletes the object and re-uploads +// it — and if the desktop app is absent when push runs, nothing re-uploads it +// and the remote index is simply gone. +func TestPushDoesNotDeleteExternalIndexOnSubsequentPush(t *testing.T) { + ctx := context.Background() + env := setupTestEnv(t) + env.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_one.json": `{"sessionId":"local_one","cliSessionId":"aaa","isArchived":false}`, + }) + + if _, err := env.syncer.PushDesktop(ctx); err != nil { + t.Fatal(err) + } + key := config.DesktopRemoteKey + ".age" + if _, err := env.store.Download(ctx, key); err != nil { + t.Fatalf("precondition: desktop index not on remote after PushDesktop: %v", err) + } + + result, err := env.syncer.Push(ctx) + if err != nil { + t.Fatalf("Push() error: %v", err) + } + for _, d := range result.Deleted { + if strings.HasPrefix(d, "_external/") { + t.Errorf("Push scheduled a delete of external key %q", d) + } + } + if _, err := env.store.Download(ctx, key); err != nil { + t.Errorf("desktop index was removed from the remote by an ordinary push: %v", err) + } + if env.syncer.state.GetFile(config.DesktopRemoteKey) == nil { + t.Error("desktop index entry was dropped from sync state by an ordinary push") + } +} + +// Same invariant at the unit level, and it covers the pre-existing MCP key too. +func TestDetectChangesIgnoresExternalKeys(t *testing.T) { + env := setupTestEnv(t) + env.syncer.state.mu.Lock() + env.syncer.state.Files[config.DesktopRemoteKey] = &FileState{Path: config.DesktopRemoteKey, Hash: "x"} + env.syncer.state.Files[config.MCPRemoteKey] = &FileState{Path: config.MCPRemoteKey, Hash: "y"} + env.syncer.state.mu.Unlock() + + changes, err := env.syncer.state.DetectChanges(env.claudeDir, env.syncer.syncPaths(), env.syncer.isExcluded) + if err != nil { + t.Fatal(err) + } + for _, c := range changes { + if strings.HasPrefix(c.Path, "_external/") { + t.Errorf("DetectChanges reported %s for external key %q", c.Action, c.Path) + } + } +} diff --git a/internal/sync/skiparchived_test.go b/internal/sync/skiparchived_test.go new file mode 100644 index 0000000..bf04b3d --- /dev/null +++ b/internal/sync/skiparchived_test.go @@ -0,0 +1,90 @@ +package sync + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSessionIDFromPath(t *testing.T) { + cases := []struct { + path string + want string + wantOK bool + }{ + {"projects/-Users-x-proj/abc-123.jsonl", "abc-123", true}, + {"projects/-Users-x-proj/nested/abc-123.jsonl", "", false}, + {"history.jsonl", "", false}, + {"projects/-Users-x-proj/notes.txt", "", false}, + {"tasks/abc.json", "", false}, + } + for _, tc := range cases { + got, ok := sessionIDFromPath(tc.path) + if ok != tc.wantOK || got != tc.want { + t.Errorf("sessionIDFromPath(%q) = (%q,%v), want (%q,%v)", tc.path, got, ok, tc.want, tc.wantOK) + } + } +} + +// --skip-archived exists to keep finished conversations out of the upload. The +// archived transcript body must stay local while an active one still syncs. +func TestPushSkipArchivedExcludesArchivedTranscripts(t *testing.T) { + ctx := context.Background() + env := setupTestEnv(t) + + projDir := filepath.Join(env.claudeDir, "projects", "-Users-x-proj") + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatal(err) + } + for _, id := range []string{"archived-one", "active-one"} { + if err := os.WriteFile(filepath.Join(projDir, id+".jsonl"), []byte(`{"type":"user"}`), 0o600); err != nil { + t.Fatal(err) + } + } + + env.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_a.json": `{"sessionId":"local_a","cliSessionId":"archived-one","isArchived":true}`, + "local_b.json": `{"sessionId":"local_b","cliSessionId":"active-one","isArchived":false}`, + }) + env.syncer.skipArchived = true + + result, err := env.syncer.Push(ctx) + if err != nil { + t.Fatalf("Push() error: %v", err) + } + + uploaded := strings.Join(result.Uploaded, "\n") + if strings.Contains(uploaded, "archived-one") { + t.Errorf("archived transcript was uploaded despite --skip-archived:\n%s", uploaded) + } + if !strings.Contains(uploaded, "active-one") { + t.Errorf("active transcript was not uploaded:\n%s", uploaded) + } +} + +// Without the flag, nothing changes: archived transcripts still sync. +func TestPushWithoutSkipArchivedUploadsEverything(t *testing.T) { + ctx := context.Background() + env := setupTestEnv(t) + + projDir := filepath.Join(env.claudeDir, "projects", "-Users-x-proj") + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projDir, "archived-one.jsonl"), []byte(`{"type":"user"}`), 0o600); err != nil { + t.Fatal(err) + } + env.syncer.desktopDir = makeIndex(t, map[string]string{ + "local_a.json": `{"sessionId":"local_a","cliSessionId":"archived-one","isArchived":true}`, + }) + + result, err := env.syncer.Push(ctx) + if err != nil { + t.Fatalf("Push() error: %v", err) + } + if !strings.Contains(strings.Join(result.Uploaded, "\n"), "archived-one") { + t.Errorf("archived transcript was skipped without the flag: %v", result.Uploaded) + } +} diff --git a/internal/sync/state.go b/internal/sync/state.go index f0ae2c8..1e9bafc 100644 --- a/internal/sync/state.go +++ b/internal/sync/state.go @@ -356,6 +356,14 @@ func (s *SyncState) DetectChanges(claudeDir string, syncPaths []string, excludeF s.mu.Unlock() for _, relPath := range knownPaths { + // External objects (MCP servers, the desktop index) are tracked in + // state so an unchanged payload is not re-uploaded, but they have no + // local file. Reading their absence as a deletion would delete the + // remote object on every push — and if the subsystem that owns it + // then has nothing to re-upload, the object is simply lost. + if strings.HasPrefix(relPath, config.ExternalKeyPrefix) { + continue + } if _, exists := localFiles[relPath]; !exists { changes = append(changes, FileChange{ Path: relPath, diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 0a45bec..a58a46a 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -52,6 +52,13 @@ type Syncer struct { onProgress ProgressFunc cfg *config.Config paths *PathMapper + + // desktopDir overrides the resolved desktop index directory. It is set in + // tests; in normal use it stays empty and the directory is discovered. + desktopDir string + + // skipArchived drops archived sessions' transcript bodies from push. + skipArchived bool } type SyncResult struct { @@ -179,7 +186,7 @@ func (s *Syncer) Push(ctx context.Context) (*SyncResult, error) { s.progress(ProgressEvent{Action: "scan", Path: "Detecting changes..."}) - changes, err := s.state.DetectChanges(s.claudeDir, s.syncPaths(), s.isExcluded) + changes, err := s.state.DetectChanges(s.claudeDir, s.syncPaths(), s.pushExcluder()) if err != nil { return nil, fmt.Errorf("failed to detect changes: %w", err) } @@ -641,7 +648,7 @@ func (s *Syncer) buildRemoteMap(remoteObjects []storage.ObjectInfo) (remoteFiles continue } // Skip external files (handled by MCP sync) - if strings.HasPrefix(localPath, "_external/") { + if strings.HasPrefix(localPath, config.ExternalKeyPrefix) { continue } // Skip metadata files (manifest, etc.)