From 2dbf0918de377fbce9788daa52ea8f40a77cf339 Mon Sep 17 00:00:00 2001 From: simon Date: Sun, 1 Nov 2026 16:32:13 +0100 Subject: [PATCH] Add local JSON daemon and route TUI/CLI through it. A per-DataDir background process owns ticket JSON, history, and sync so multiple TUI instances stay consistent; MCP token APIs are ready for phase 2. Co-authored-by: Cursor --- README.md | 17 +- cmd/task-master/main.go | 200 +++++++++++++++------- internal/daemon/api/api_test.go | 115 +++++++++++++ internal/daemon/api/auth.go | 117 +++++++++++++ internal/daemon/api/history_handlers.go | 37 ++++ internal/daemon/api/hub.go | 81 +++++++++ internal/daemon/api/jsonutil.go | 23 +++ internal/daemon/api/project_handlers.go | 183 ++++++++++++++++++++ internal/daemon/api/server.go | 84 ++++++++++ internal/daemon/api/sync_handlers.go | 68 ++++++++ internal/daemon/api/ticket_handlers.go | 147 ++++++++++++++++ internal/daemon/api/time_handlers.go | 214 ++++++++++++++++++++++++ internal/daemon/client/client.go | 111 ++++++++++++ internal/daemon/client/events.go | 53 ++++++ internal/daemon/client/executor.go | 100 +++++++++++ internal/daemon/client/stores.go | 159 ++++++++++++++++++ internal/daemon/client/sync.go | 47 ++++++ internal/daemon/lifecycle.go | 159 ++++++++++++++++++ internal/daemon/paths.go | 91 ++++++++++ internal/daemon/serve.go | 79 +++++++++ internal/project/meta.go | 7 + internal/ui/app.go | 96 ++++++++--- internal/ui/keys.go | 12 +- 23 files changed, 2107 insertions(+), 93 deletions(-) create mode 100644 internal/daemon/api/api_test.go create mode 100644 internal/daemon/api/auth.go create mode 100644 internal/daemon/api/history_handlers.go create mode 100644 internal/daemon/api/hub.go create mode 100644 internal/daemon/api/jsonutil.go create mode 100644 internal/daemon/api/project_handlers.go create mode 100644 internal/daemon/api/server.go create mode 100644 internal/daemon/api/sync_handlers.go create mode 100644 internal/daemon/api/ticket_handlers.go create mode 100644 internal/daemon/api/time_handlers.go create mode 100644 internal/daemon/client/client.go create mode 100644 internal/daemon/client/events.go create mode 100644 internal/daemon/client/executor.go create mode 100644 internal/daemon/client/stores.go create mode 100644 internal/daemon/client/sync.go create mode 100644 internal/daemon/lifecycle.go create mode 100644 internal/daemon/paths.go create mode 100644 internal/daemon/serve.go diff --git a/README.md b/README.md index 97d41cd..bc2e12c 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,17 @@ Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Ch **TUI (`tm`)** -- Projekte & Tickets lokal als JSON (kein Server) +- Projekte & Tickets lokal als JSON hinter einem Hintergrund-Daemon (Unix-Socket) +- Mehrere TUI-Instanzen teilen denselben State; `tm stop` beendet den Daemon - Board-Filter: All / Backlog / In Progress / Done - Tickets in `$EDITOR` bearbeiten (Markdown + Checklisten) - Timer, manuelle Zeitbuchungen, Zeiteinträge inkl. Start/Ende editieren - Report mit Group-by (Ticket / Tag) -- Undo / Redo über eine Command-History +- Undo / Redo über eine Command-History (im Daemon) - Vim-Style Keybindings - YouTrack Pull/Push (manuell; Remote-URL, Token, Project-Key pro Projekt) - Sync gegen `tm_server` (Backend `taskmaster`, Projekt-Tokens) +- MCP-Token pro Projekt erzeugen (`g` in der Projektliste) für späteren KI-Zugriff **Web (`tm_server`)** @@ -49,14 +51,23 @@ make build-all # beides ## Nutzung ```bash -tm # TUI starten +tm # TUI starten (startet Daemon bei Bedarf) tm sync pull # Issues von YouTrack / tm_server holen tm sync push # lokale Änderungen pushen tm sync pull -p inbox # anderes Projekt +tm status # Daemon-Status + Socket-Pfad +tm stop # Hintergrund-Daemon beenden +tm daemon # Daemon im Vordergrund (Debug) tm version # Build-Version (Git-Hash) tm help ``` +### Lokaler JSON-Daemon + +`tm` speichert weiterhin JSON unter dem DataDir, schreibt aber nur noch über einen lokalen Daemon-Prozess (eine Instanz pro DataDir). Socket/PID liegen unter `$XDG_RUNTIME_DIR/task-master-/` (Fallback: Temp-Dir). Die TUI und `tm sync` verbinden sich als vertrauenswürdige Clients (`X-TM-Client: tui|cli`). + +MCP-Token: in der Projektliste `g` erzeugt ein Token (Klartext einmal in der Statuszeile); `G` widerruft. Speichere es als `.tm_token` im Repo für den MCP-Server (`tm mcp`, folgt / siehe Help). + ### Web-Server (`tm_server`) ```bash diff --git a/cmd/task-master/main.go b/cmd/task-master/main.go index f423e0c..32ed0d1 100644 --- a/cmd/task-master/main.go +++ b/cmd/task-master/main.go @@ -8,14 +8,12 @@ import ( tea "github.com/charmbracelet/bubbletea" - "task/internal/command" "task/internal/config" - "task/internal/project" - "task/internal/ticket" + "task/internal/daemon" + "task/internal/daemon/api" + "task/internal/daemon/client" "task/internal/ui" "task/internal/version" - "task/internal/tmsync" - "task/internal/ytsync" ) func main() { @@ -33,6 +31,30 @@ func main() { os.Exit(1) } return + case "daemon": + if err := runDaemon(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + return + case "stop": + if err := runStop(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + return + case "status": + if err := runStatus(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + return + case "mcp": + if err := runMCP(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + return default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) printHelp() @@ -46,35 +68,104 @@ func main() { } } -func run() error { +func loadConfig() (*config.Config, error) { cfg, err := config.LoadOrCreate() if err != nil { - return fmt.Errorf("config: %w", err) + return nil, fmt.Errorf("config: %w", err) } + abs, err := daemon.AbsoluteDataDir(cfg.DataDir) + if err != nil { + return nil, err + } + cfg.DataDir = abs + return cfg, nil +} - store := ticket.NewJSONStore(cfg.DataDir) - timeStore := ticket.NewJSONTimeStore(cfg.DataDir) - projects := project.NewDirStore(cfg.DataDir) - if err := projects.Ensure(cfg.ActiveProject); err != nil { +func ensureClient(kind string) (*config.Config, *client.Client, error) { + cfg, err := loadConfig() + if err != nil { + return nil, nil, err + } + paths, err := daemon.Ensure(cfg) + if err != nil { + return nil, nil, fmt.Errorf("daemon: %w", err) + } + cli := client.Dial(client.Options{Socket: paths.Socket, Kind: kind}) + return cfg, cli, nil +} + +func run() error { + cfg, cli, err := ensureClient(api.ClientTUI) + if err != nil { + return err + } + if err := cli.ProjectStore().Ensure(cfg.ActiveProject); err != nil { return fmt.Errorf("ensure project: %w", err) } - history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store, timeStore) - if err != nil { - return fmt.Errorf("history: %w", err) - } - model := ui.New(cfg, store, projects, timeStore, history) + model := ui.New(cfg, cli.TicketStore(), cli.ProjectStore(), cli.TimeStore(), cli.Executor(), cli) prog := tea.NewProgram(model, tea.WithAltScreen()) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + _ = cli.SubscribeEvents(ctx, func(ev api.Event) { + prog.Send(ui.DaemonChangedMsg{ProjectID: ev.ProjectID}) + }) + }() + if _, err := prog.Run(); err != nil { return fmt.Errorf("tui: %w", err) } return nil } -func runSync(args []string) error { - cfg, err := config.LoadOrCreate() +func runDaemon() error { + cfg, err := loadConfig() if err != nil { - return fmt.Errorf("config: %w", err) + return err + } + return daemon.Run(cfg) +} + +func runStop() error { + cfg, err := loadConfig() + if err != nil { + return err + } + if err := daemon.Stop(cfg); err != nil { + return err + } + fmt.Println("daemon stopped") + return nil +} + +func runStatus() error { + cfg, err := loadConfig() + if err != nil { + return err + } + st, err := daemon.StatusOf(cfg) + if err != nil { + return err + } + state := "stopped" + if st.Running { + state = "running" + } + fmt.Printf("status: %s\n", state) + fmt.Printf("socket: %s\n", st.Socket) + fmt.Printf("dataDir: %s\n", st.DataDir) + if st.PID > 0 { + fmt.Printf("pid: %d\n", st.PID) + } + return nil +} + +func runSync(args []string) error { + cfg, cli, err := ensureClient(api.ClientCLI) + if err != nil { + return err } projectID := cfg.ActiveProject action := "" @@ -105,56 +196,39 @@ func runSync(args []string) error { return fmt.Errorf("project id required") } - store := ticket.NewJSONStore(cfg.DataDir) - timeStore := ticket.NewJSONTimeStore(cfg.DataDir) - projects := project.NewDirStore(cfg.DataDir) - if err := projects.Ensure(projectID); err != nil { - return err - } - - meta, err := projects.LoadMeta(projectID) - if err != nil { - return err - } - var res ytsync.Result - switch meta.EffectiveBackend() { - case project.BackendTaskMaster: - if meta.TaskMaster == nil || !meta.TaskMaster.Configured() { - return fmt.Errorf("taskmaster not configured for %s", projectID) - } - syncer := &tmsync.Syncer{Store: store, TimeStore: timeStore} - if action == "push" { - res, err = syncer.Push(context.Background(), projectID, *meta.TaskMaster) - } else { - res, err = syncer.Pull(context.Background(), projectID, *meta.TaskMaster) - } - default: - if action == "push" { - res, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID) - } else { - res, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID) - } - } - if err != nil { - return err - } if action == "push" { + out, err := cli.SyncPush(projectID) + if err != nil { + return err + } fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n", - projectID, res.Created, res.Updated, res.TimePushed) - } else { - fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n", - projectID, res.Created, res.Updated, res.TimePulled, res.Total) + projectID, out.Created, out.Updated, out.TimePushed) + return nil } + out, err := cli.SyncPull(projectID) + if err != nil { + return err + } + fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n", + projectID, out.Created, out.Updated, out.TimePulled, out.Total) return nil } +func runMCP() error { + return fmt.Errorf("mcp server not implemented yet — coming in phase 2") +} + func printHelp() { fmt.Printf(`%s — offline-first terminal task manager Usage: - %s Start the TUI - %s sync pull [-p ID] Pull from configured backend (YouTrack or task-master) - %s sync push [-p ID] Push to configured backend + %s Start the TUI (starts local daemon if needed) + %s sync pull [-p ID] Pull via daemon from configured backend + %s sync push [-p ID] Push via daemon to configured backend + %s daemon Run the JSON daemon in the foreground + %s stop Stop the background daemon + %s status Show daemon status and socket path + %s mcp MCP stdio server (requires .tm_token) %s version Print build version %s help Show this help @@ -162,9 +236,9 @@ Env: TASK_MASTER_HOME Override home for config/data (dev/demo sandbox) TASK_MASTER_DATA_DIR Override data directory only -Sync backends: - In the TUI: p → project → e, choose youtrack or taskmaster, set URL/token/id. - Then Y → Pull or Push (manual only; edits stay local until you push). +The TUI talks to a per-DataDir background daemon over a Unix socket so multiple +instances stay consistent. Create an MCP token from the project list (key g); +place it in a repo as .tm_token for AI access. -`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName) +`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName) } diff --git a/internal/daemon/api/api_test.go b/internal/daemon/api/api_test.go new file mode 100644 index 0000000..67267a3 --- /dev/null +++ b/internal/daemon/api/api_test.go @@ -0,0 +1,115 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "task/internal/command" + "task/internal/daemon/api" + "task/internal/project" + "task/internal/ticket" +) + +func setup(t *testing.T) (*api.Server, string) { + t.Helper() + dir := t.TempDir() + store := ticket.NewJSONStore(dir) + timeStore := ticket.NewJSONTimeStore(dir) + projects := project.NewDirStore(dir) + _ = projects.Ensure("inbox") + hist, err := command.OpenHistory(filepath.Join(dir, "history.json"), store, timeStore) + if err != nil { + t.Fatal(err) + } + return api.New(store, timeStore, projects, hist), dir +} + +func TestHealthAndCRUD(t *testing.T) { + srv, _ := setup(t) + h := srv.Handler() + + req := httptest.NewRequest(http.MethodGet, "/v1/health", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("health: %d", rec.Code) + } + + body, _ := json.Marshal(ticket.Ticket{Title: "Hi", ProjectID: "inbox", Status: ticket.StatusTodo}) + req = httptest.NewRequest(http.MethodPost, "/v1/tickets", bytes.NewReader(body)) + req.Header.Set(api.HeaderClient, api.ClientTUI) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + var created ticket.Ticket + _ = json.Unmarshal(rec.Body.Bytes(), &created) + if created.ID == "" { + t.Fatal("missing id") + } + + req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil) + req.Header.Set(api.HeaderClient, api.ClientTUI) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("list: %d", rec.Code) + } +} + +func TestMCPAuthRequired(t *testing.T) { + srv, _ := setup(t) + h := srv.Handler() + + req := httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil) + req.Header.Set(api.HeaderClient, api.ClientMCP) + req.Header.Set("X-TM-Project", "inbox") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden { + t.Fatalf("expected auth failure, got %d %s", rec.Code, rec.Body.String()) + } + + // Create token via TUI client + req = httptest.NewRequest(http.MethodPost, "/v1/projects/inbox/mcp-token", bytes.NewReader([]byte("{}"))) + req.Header.Set(api.HeaderClient, api.ClientTUI) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("token create: %d %s", rec.Code, rec.Body.String()) + } + var tok struct { + Token string `json:"token"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &tok) + if tok.Token == "" { + t.Fatal("empty token") + } + + req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil) + req.Header.Set(api.HeaderClient, api.ClientMCP) + req.Header.Set("Authorization", "Bearer "+tok.Token) + req.Header.Set("X-TM-Project", "inbox") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("mcp list: %d %s", rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/tickets?projectId=inbox", nil) + req.Header.Set(api.HeaderClient, api.ClientMCP) + req.Header.Set("Authorization", "Bearer wrong") + req.Header.Set("X-TM-Project", "inbox") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code == 200 { + t.Fatal("wrong token should fail") + } +} diff --git a/internal/daemon/api/auth.go b/internal/daemon/api/auth.go new file mode 100644 index 0000000..0339e3a --- /dev/null +++ b/internal/daemon/api/auth.go @@ -0,0 +1,117 @@ +package api + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "net/http" + "strings" +) + +type ctxKey int + +const ( + ctxClientKind ctxKey = iota + ctxMCPProject +) + +const ( + HeaderClient = "X-TM-Client" + ClientTUI = "tui" + ClientCLI = "cli" + ClientMCP = "mcp" +) + +// ClientKind returns the authenticated client kind from context. +func ClientKind(ctx context.Context) string { + v, _ := ctx.Value(ctxClientKind).(string) + return v +} + +// MCPProjectID returns the project bound to a validated MCP token, if any. +func MCPProjectID(ctx context.Context) string { + v, _ := ctx.Value(ctxMCPProject).(string) + return v +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func (s *Server) withAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/health" { + next.ServeHTTP(w, r) + return + } + kind := strings.ToLower(strings.TrimSpace(r.Header.Get(HeaderClient))) + if kind == "" { + kind = ClientTUI + } + ctx := context.WithValue(r.Context(), ctxClientKind, kind) + + if kind == ClientMCP { + auth := r.Header.Get("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(auth, prefix) { + writeErr(w, http.StatusUnauthorized, "mcp requires Authorization: Bearer ") + return + } + token := strings.TrimSpace(strings.TrimPrefix(auth, prefix)) + if token == "" { + writeErr(w, http.StatusUnauthorized, "empty bearer token") + return + } + projectID := strings.TrimSpace(r.Header.Get("X-TM-Project")) + if projectID == "" { + projectID = extractProjectID(r) + } + if projectID == "" { + writeErr(w, http.StatusUnauthorized, "mcp requires project id (X-TM-Project or path/body)") + return + } + meta, err := s.projects.LoadMeta(projectID) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if meta.McpAuth == nil || meta.McpAuth.TokenHash == "" { + writeErr(w, http.StatusForbidden, "no mcp token configured for project") + return + } + got := hashToken(token) + if subtle.ConstantTimeCompare([]byte(got), []byte(meta.McpAuth.TokenHash)) != 1 { + writeErr(w, http.StatusForbidden, "invalid mcp token") + return + } + ctx = context.WithValue(ctx, ctxMCPProject, projectID) + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// extractProjectID best-effort for auth before body is consumed. +// Path patterns: /v1/projects/{id}/... +func extractProjectID(r *http.Request) string { + if q := strings.TrimSpace(r.URL.Query().Get("projectId")); q != "" { + return q + } + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + // v1 projects {id} ... + if len(parts) >= 3 && parts[0] == "v1" && parts[1] == "projects" { + return parts[2] + } + return "" +} + +// requireMCPProject ensures MCP calls stay on the token's project. +func requireMCPProject(r *http.Request, projectID string) bool { + if ClientKind(r.Context()) != ClientMCP { + return true + } + bound := MCPProjectID(r.Context()) + return bound != "" && bound == projectID +} diff --git a/internal/daemon/api/history_handlers.go b/internal/daemon/api/history_handlers.go new file mode 100644 index 0000000..a8d8f51 --- /dev/null +++ b/internal/daemon/api/history_handlers.go @@ -0,0 +1,37 @@ +package api + +import ( + "net/http" +) + +func (s *Server) handleHistoryStatus(w http.ResponseWriter, r *http.Request) { + _ = r + writeJSON(w, http.StatusOK, map[string]any{ + "canUndo": s.history.CanUndo(), + "canRedo": s.history.CanRedo(), + "lastUndoName": s.history.LastUndoName(), + "lastRedoName": s.history.LastRedoName(), + }) +} + +func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) { + _ = r + name := s.history.LastUndoName() + if err := s.history.Undo(); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + s.notify("") + writeJSON(w, http.StatusOK, map[string]string{"name": name}) +} + +func (s *Server) handleRedo(w http.ResponseWriter, r *http.Request) { + _ = r + name := s.history.LastRedoName() + if err := s.history.Redo(); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + s.notify("") + writeJSON(w, http.StatusOK, map[string]string{"name": name}) +} diff --git a/internal/daemon/api/hub.go b/internal/daemon/api/hub.go new file mode 100644 index 0000000..fb2c1a7 --- /dev/null +++ b/internal/daemon/api/hub.go @@ -0,0 +1,81 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" +) + +// Event is a change notification pushed to SSE clients. +type Event struct { + Type string `json:"type"` + ProjectID string `json:"projectId,omitempty"` +} + +type hub struct { + mu sync.Mutex + clients map[chan Event]struct{} +} + +func newHub() *hub { + return &hub{clients: map[chan Event]struct{}{}} +} + +func (h *hub) subscribe() chan Event { + ch := make(chan Event, 16) + h.mu.Lock() + h.clients[ch] = struct{}{} + h.mu.Unlock() + return ch +} + +func (h *hub) unsubscribe(ch chan Event) { + h.mu.Lock() + delete(h.clients, ch) + h.mu.Unlock() + close(ch) +} + +func (h *hub) publish(ev Event) { + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.clients { + select { + case ch <- ev: + default: + } + } +} + +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + ch := s.hub.subscribe() + defer s.hub.unsubscribe(ch) + + fmt.Fprintf(w, ": ok\n\n") + flusher.Flush() + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-ch: + if !ok { + return + } + data, _ := json.Marshal(ev) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + } +} diff --git a/internal/daemon/api/jsonutil.go b/internal/daemon/api/jsonutil.go new file mode 100644 index 0000000..79431ff --- /dev/null +++ b/internal/daemon/api/jsonutil.go @@ -0,0 +1,23 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func readJSON(r *http.Request, dst any) error { + defer r.Body.Close() + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + return dec.Decode(dst) +} diff --git a/internal/daemon/api/project_handlers.go b/internal/daemon/api/project_handlers.go new file mode 100644 index 0000000..1fa17f4 --- /dev/null +++ b/internal/daemon/api/project_handlers.go @@ -0,0 +1,183 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "time" + + "task/internal/project" +) + +func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { + list, err := s.projects.List() + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if ClientKind(r.Context()) == ClientMCP { + pid := MCPProjectID(r.Context()) + filtered := make([]project.Project, 0, 1) + for _, p := range list { + if p.ID == pid { + filtered = append(filtered, p) + } + } + list = filtered + } + writeJSON(w, http.StatusOK, list) +} + +func (s *Server) handleEnsureProject(w http.ResponseWriter, r *http.Request) { + var body struct { + ID string `json:"id"` + } + if err := readJSON(r, &body); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if !requireMCPProject(r, body.ID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + if err := s.projects.Ensure(body.ID); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + s.notify(body.ID) + writeJSON(w, http.StatusOK, map[string]string{"id": body.ID}) +} + +func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !requireMCPProject(r, id) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + if err := s.timeStore.DeleteByProject(id); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if t, err := s.timeStore.LoadActiveTimer(); err == nil && t != nil && t.ProjectID == id { + _ = s.timeStore.SaveActiveTimer(nil) + } + if err := s.projects.Delete(id); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + s.notify(id) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !requireMCPProject(r, id) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + meta, err := s.projects.LoadMeta(id) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, meta) +} + +func (s *Server) handleSaveMeta(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !requireMCPProject(r, id) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + var meta project.Meta + if err := readJSON(r, &meta); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + // Preserve existing mcpAuth if client omitted it (TUI sync edit). + prev, _ := s.projects.LoadMeta(id) + if meta.McpAuth == nil && prev.McpAuth != nil { + meta.McpAuth = prev.McpAuth + } + if err := s.projects.SaveMeta(id, meta); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + s.notify(id) + writeJSON(w, http.StatusOK, meta) +} + +func (s *Server) handleMCPTokenStatus(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if ClientKind(r.Context()) == ClientMCP { + writeErr(w, http.StatusForbidden, "mcp cannot inspect token status") + return + } + meta, err := s.projects.LoadMeta(id) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + configured := meta.McpAuth != nil && meta.McpAuth.TokenHash != "" + created := "" + if meta.McpAuth != nil { + created = meta.McpAuth.CreatedAt + } + writeJSON(w, http.StatusOK, map[string]any{ + "configured": configured, + "createdAt": created, + }) +} + +func (s *Server) handleMCPTokenCreate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if ClientKind(r.Context()) == ClientMCP { + writeErr(w, http.StatusForbidden, "mcp cannot create tokens") + return + } + if err := s.projects.Ensure(id); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + raw := make([]byte, 24) + if _, err := rand.Read(raw); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + token := "tm_mcp_" + hex.EncodeToString(raw) + meta, err := s.projects.LoadMeta(id) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + created := time.Now().UTC().Format(time.RFC3339) + meta.McpAuth = &project.McpAuth{TokenHash: hashToken(token), CreatedAt: created} + if err := s.projects.SaveMeta(id, meta); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{ + "token": token, + "project": id, + "createdAt": created, + }) +} + +func (s *Server) handleMCPTokenRevoke(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if ClientKind(r.Context()) == ClientMCP { + writeErr(w, http.StatusForbidden, "mcp cannot revoke tokens") + return + } + meta, err := s.projects.LoadMeta(id) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + meta.McpAuth = nil + if err := s.projects.SaveMeta(id, meta); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/daemon/api/server.go b/internal/daemon/api/server.go new file mode 100644 index 0000000..d6df213 --- /dev/null +++ b/internal/daemon/api/server.go @@ -0,0 +1,84 @@ +package api + +import ( + "net/http" + + "task/internal/command" + "task/internal/project" + "task/internal/ticket" +) + +// Server is the daemon HTTP API. +type Server struct { + store ticket.Store + timeStore ticket.TimeStore + projects project.Store + history command.Executor + hub *hub + mux *http.ServeMux +} + +// New constructs the API server. +func New(store ticket.Store, timeStore ticket.TimeStore, projects project.Store, history command.Executor) *Server { + s := &Server{ + store: store, + timeStore: timeStore, + projects: projects, + history: history, + hub: newHub(), + mux: http.NewServeMux(), + } + s.routes() + return s +} + +// Handler returns the root HTTP handler with auth middleware. +func (s *Server) Handler() http.Handler { + return s.withAuth(s.mux) +} + +func (s *Server) routes() { + s.mux.HandleFunc("GET /v1/health", s.handleHealth) + + s.mux.HandleFunc("GET /v1/projects", s.handleListProjects) + s.mux.HandleFunc("POST /v1/projects", s.handleEnsureProject) + s.mux.HandleFunc("DELETE /v1/projects/{id}", s.handleDeleteProject) + s.mux.HandleFunc("GET /v1/projects/{id}/meta", s.handleGetMeta) + s.mux.HandleFunc("PUT /v1/projects/{id}/meta", s.handleSaveMeta) + s.mux.HandleFunc("GET /v1/projects/{id}/mcp-token", s.handleMCPTokenStatus) + s.mux.HandleFunc("POST /v1/projects/{id}/mcp-token", s.handleMCPTokenCreate) + s.mux.HandleFunc("DELETE /v1/projects/{id}/mcp-token", s.handleMCPTokenRevoke) + + s.mux.HandleFunc("GET /v1/tickets", s.handleListTickets) + s.mux.HandleFunc("GET /v1/tickets/{id}", s.handleGetTicket) + s.mux.HandleFunc("POST /v1/tickets", s.handleCreateTicket) + s.mux.HandleFunc("PATCH /v1/tickets/{id}", s.handleUpdateTicket) + s.mux.HandleFunc("DELETE /v1/tickets/{id}", s.handleDeleteTicket) + s.mux.HandleFunc("POST /v1/tickets/{id}/move", s.handleMoveTicket) + + s.mux.HandleFunc("GET /v1/time", s.handleListTime) + s.mux.HandleFunc("GET /v1/time/by-ticket/{ticketId}", s.handleListTimeByTicket) + s.mux.HandleFunc("GET /v1/time/sum/{ticketId}", s.handleSumTime) + s.mux.HandleFunc("POST /v1/time", s.handleAddTime) + s.mux.HandleFunc("PATCH /v1/time/{id}", s.handleUpdateTime) + s.mux.HandleFunc("DELETE /v1/time/{id}", s.handleRemoveTime) + s.mux.HandleFunc("GET /v1/timer", s.handleGetTimer) + s.mux.HandleFunc("PUT /v1/timer", s.handleSetTimer) + + s.mux.HandleFunc("GET /v1/history", s.handleHistoryStatus) + s.mux.HandleFunc("POST /v1/undo", s.handleUndo) + s.mux.HandleFunc("POST /v1/redo", s.handleRedo) + + s.mux.HandleFunc("POST /v1/sync/pull", s.handleSyncPull) + s.mux.HandleFunc("POST /v1/sync/push", s.handleSyncPush) + + s.mux.HandleFunc("GET /v1/events", s.handleEvents) +} + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) notify(projectID string) { + s.hub.publish(Event{Type: "changed", ProjectID: projectID}) +} diff --git a/internal/daemon/api/sync_handlers.go b/internal/daemon/api/sync_handlers.go new file mode 100644 index 0000000..642720c --- /dev/null +++ b/internal/daemon/api/sync_handlers.go @@ -0,0 +1,68 @@ +package api + +import ( + "context" + "net/http" + + "task/internal/project" + "task/internal/tmsync" + "task/internal/ytsync" +) + +func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) { + s.runSync(w, r, false) +} + +func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) { + s.runSync(w, r, true) +} + +func (s *Server) runSync(w http.ResponseWriter, r *http.Request, push bool) { + var body struct { + ProjectID string `json:"projectId"` + } + if err := readJSON(r, &body); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if body.ProjectID == "" { + writeErr(w, http.StatusBadRequest, "projectId required") + return + } + if !requireMCPProject(r, body.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + meta, err := s.projects.LoadMeta(body.ProjectID) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + var res ytsync.Result + ctx := context.Background() + switch meta.EffectiveBackend() { + case project.BackendTaskMaster: + if meta.TaskMaster == nil || !meta.TaskMaster.Configured() { + writeErr(w, http.StatusBadRequest, "taskmaster not configured") + return + } + syncer := &tmsync.Syncer{Store: s.store, TimeStore: s.timeStore} + if push { + res, err = syncer.Push(ctx, body.ProjectID, *meta.TaskMaster) + } else { + res, err = syncer.Pull(ctx, body.ProjectID, *meta.TaskMaster) + } + default: + if push { + res, err = ytsync.PushProject(ctx, s.projects, s.store, s.timeStore, body.ProjectID) + } else { + res, err = ytsync.PullProject(ctx, s.projects, s.store, s.timeStore, body.ProjectID) + } + } + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + s.notify(body.ProjectID) + writeJSON(w, http.StatusOK, res) +} diff --git a/internal/daemon/api/ticket_handlers.go b/internal/daemon/api/ticket_handlers.go new file mode 100644 index 0000000..73e82f8 --- /dev/null +++ b/internal/daemon/api/ticket_handlers.go @@ -0,0 +1,147 @@ +package api + +import ( + "net/http" + + "task/internal/command" + "task/internal/ticket" +) + +func (s *Server) handleListTickets(w http.ResponseWriter, r *http.Request) { + projectID := r.URL.Query().Get("projectId") + if projectID == "" { + writeErr(w, http.StatusBadRequest, "projectId query required") + return + } + if !requireMCPProject(r, projectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + list, err := s.store.List(projectID) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, list) +} + +func (s *Server) handleGetTicket(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + t, err := s.store.Get(id) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + if !requireMCPProject(r, t.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + writeJSON(w, http.StatusOK, t) +} + +func (s *Server) handleCreateTicket(w http.ResponseWriter, r *http.Request) { + var body ticket.Ticket + if err := readJSON(r, &body); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if !requireMCPProject(r, body.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + cmd := &command.CreateTicket{Store: s.store, Ticket: body} + if err := s.history.Do(cmd); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + s.notify(cmd.Ticket.ProjectID) + writeJSON(w, http.StatusCreated, cmd.Ticket) +} + +func (s *Server) handleUpdateTicket(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + before, err := s.store.Get(id) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + if !requireMCPProject(r, before.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + var after ticket.Ticket + if err := readJSON(r, &after); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + after.ID = id + if after.ProjectID == "" { + after.ProjectID = before.ProjectID + } + if !requireMCPProject(r, after.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + cmd := &command.UpdateTicket{Store: s.store, Before: before, After: after} + if err := s.history.Do(cmd); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + s.notify(after.ProjectID) + writeJSON(w, http.StatusOK, after) +} + +func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + before, err := s.store.Get(id) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + if !requireMCPProject(r, before.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + cmd := &command.DeleteTicket{Store: s.store, Snapshot: before} + if err := s.history.Do(cmd); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + s.notify(before.ProjectID) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleMoveTicket(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + before, err := s.store.Get(id) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + if !requireMCPProject(r, before.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + var body struct { + ToProjectID string `json:"toProjectId"` + } + if err := readJSON(r, &body); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + // MCP may only move within its project (no cross-project). + if ClientKind(r.Context()) == ClientMCP && body.ToProjectID != MCPProjectID(r.Context()) { + writeErr(w, http.StatusForbidden, "mcp cannot move tickets to another project") + return + } + after := before + after.ProjectID = body.ToProjectID + cmd := &command.MoveTicket{Store: s.store, TimeStore: s.timeStore, Before: before, After: after} + if err := s.history.Do(cmd); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + s.notify(before.ProjectID) + s.notify(body.ToProjectID) + writeJSON(w, http.StatusOK, cmd.After) +} diff --git a/internal/daemon/api/time_handlers.go b/internal/daemon/api/time_handlers.go new file mode 100644 index 0000000..332dda6 --- /dev/null +++ b/internal/daemon/api/time_handlers.go @@ -0,0 +1,214 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "task/internal/command" + "task/internal/ticket" +) + +func (s *Server) handleListTime(w http.ResponseWriter, r *http.Request) { + list, err := s.timeStore.List() + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if ClientKind(r.Context()) == ClientMCP { + pid := MCPProjectID(r.Context()) + filtered := make([]ticket.TimeEntry, 0) + for _, e := range list { + if e.ProjectID == pid { + filtered = append(filtered, e) + } + } + list = filtered + } + writeJSON(w, http.StatusOK, list) +} + +func (s *Server) handleListTimeByTicket(w http.ResponseWriter, r *http.Request) { + ticketID := r.PathValue("ticketId") + t, err := s.store.Get(ticketID) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + if !requireMCPProject(r, t.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + list, err := s.timeStore.ListByTicket(ticketID) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, list) +} + +func (s *Server) handleSumTime(w http.ResponseWriter, r *http.Request) { + ticketID := r.PathValue("ticketId") + t, err := s.store.Get(ticketID) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + if !requireMCPProject(r, t.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + d, err := s.timeStore.SumByTicket(ticketID) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]int64{"duration": int64(d)}) +} + +func (s *Server) handleAddTime(w http.ResponseWriter, r *http.Request) { + var body ticket.TimeEntry + if err := readJSON(r, &body); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if !requireMCPProject(r, body.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + cmd := &command.AddTimeEntry{Store: s.timeStore, Entry: body} + if err := s.history.Do(cmd); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + s.notify(cmd.Entry.ProjectID) + writeJSON(w, http.StatusCreated, cmd.Entry) +} + +func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + entries, err := s.timeStore.List() + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + var before *ticket.TimeEntry + for i := range entries { + if entries[i].ID == id { + before = &entries[i] + break + } + } + if before == nil { + writeErr(w, http.StatusNotFound, "time entry not found") + return + } + if !requireMCPProject(r, before.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + var after ticket.TimeEntry + if err := readJSON(r, &after); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + after.ID = id + if after.ProjectID == "" { + after.ProjectID = before.ProjectID + } + cmd := &command.UpdateTimeEntry{Store: s.timeStore, Before: *before, After: after} + if err := s.history.Do(cmd); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + s.notify(after.ProjectID) + writeJSON(w, http.StatusOK, after) +} + +func (s *Server) handleRemoveTime(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + entries, err := s.timeStore.List() + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + var snap *ticket.TimeEntry + for i := range entries { + if entries[i].ID == id { + snap = &entries[i] + break + } + } + if snap == nil { + writeErr(w, http.StatusNotFound, "time entry not found") + return + } + if !requireMCPProject(r, snap.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + cmd := &command.RemoveTimeEntry{Store: s.timeStore, Snapshot: *snap} + if err := s.history.Do(cmd); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + s.notify(snap.ProjectID) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleGetTimer(w http.ResponseWriter, r *http.Request) { + t, err := s.timeStore.LoadActiveTimer() + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if t != nil && ClientKind(r.Context()) == ClientMCP && t.ProjectID != MCPProjectID(r.Context()) { + writeJSON(w, http.StatusOK, nil) + return + } + writeJSON(w, http.StatusOK, t) +} + +func (s *Server) handleSetTimer(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var raw map[string]any + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + var body *ticket.ActiveTimer + if raw != nil { + b, err := json.Marshal(raw) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + var t ticket.ActiveTimer + if err := json.Unmarshal(b, &t); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + if t.TicketID == "" && t.ProjectID == "" { + body = nil + } else { + if !requireMCPProject(r, t.ProjectID) { + writeErr(w, http.StatusForbidden, "mcp token project mismatch") + return + } + if t.StartedAt.IsZero() { + t.StartedAt = time.Now() + } + body = &t + } + } + if err := s.timeStore.SaveActiveTimer(body); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if body != nil { + s.notify(body.ProjectID) + } else { + s.notify("") + } + writeJSON(w, http.StatusOK, body) +} diff --git a/internal/daemon/client/client.go b/internal/daemon/client/client.go new file mode 100644 index 0000000..8df1a68 --- /dev/null +++ b/internal/daemon/client/client.go @@ -0,0 +1,111 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "task/internal/daemon/api" +) + +// Client talks to the local JSON daemon over a Unix socket. +type Client struct { + http *http.Client + kind string // tui | cli | mcp + token string // bearer for mcp + project string // mcp bound project +} + +// Options configures a daemon client. +type Options struct { + Socket string + Kind string + Token string + Project string +} + +// Dial creates a client for the given Unix socket. +func Dial(opts Options) *Client { + kind := opts.Kind + if kind == "" { + kind = api.ClientTUI + } + return &Client{ + kind: kind, + token: opts.Token, + project: opts.Project, + http: &http.Client{ + Timeout: 60 * time.Second, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", opts.Socket) + }, + }, + }, + } +} + +func (c *Client) do(method, path string, in any, out any) error { + var body io.Reader + if in != nil { + b, err := json.Marshal(in) + if err != nil { + return err + } + body = bytes.NewReader(b) + } + req, err := http.NewRequest(method, "http://daemon"+path, body) + if err != nil { + return err + } + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set(api.HeaderClient, c.kind) + if c.kind == api.ClientMCP { + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + if c.project != "" { + req.Header.Set("X-TM-Project", c.project) + } + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + var er struct { + Error string `json:"error"` + } + _ = json.Unmarshal(data, &er) + if er.Error == "" { + er.Error = strings.TrimSpace(string(data)) + } + if er.Error == "" { + er.Error = resp.Status + } + return fmt.Errorf("%s", er.Error) + } + if out == nil || resp.StatusCode == http.StatusNoContent || len(data) == 0 { + return nil + } + return json.Unmarshal(data, out) +} + +// Health checks /v1/health. +func (c *Client) Health() error { + return c.do(http.MethodGet, "/v1/health", nil, nil) +} diff --git a/internal/daemon/client/events.go b/internal/daemon/client/events.go new file mode 100644 index 0000000..f2ba3dc --- /dev/null +++ b/internal/daemon/client/events.go @@ -0,0 +1,53 @@ +package client + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "task/internal/daemon/api" +) + +// SubscribeEvents streams SSE change events until ctx is cancelled. +func (c *Client) SubscribeEvents(ctx context.Context, emit func(api.Event)) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://daemon/v1/events", nil) + if err != nil { + return err + } + req.Header.Set(api.HeaderClient, c.kind) + if c.kind == api.ClientMCP { + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + if c.project != "" { + req.Header.Set("X-TM-Project", c.project) + } + } + // No overall timeout for long-lived SSE. + httpClient := *c.http + httpClient.Timeout = 0 + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("events: %s", resp.Status) + } + sc := bufio.NewScanner(resp.Body) + for sc.Scan() { + line := sc.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + var ev api.Event + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev); err != nil { + continue + } + emit(ev) + } + return sc.Err() +} diff --git a/internal/daemon/client/executor.go b/internal/daemon/client/executor.go new file mode 100644 index 0000000..f3fee9e --- /dev/null +++ b/internal/daemon/client/executor.go @@ -0,0 +1,100 @@ +package client + +import ( + "fmt" + "net/http" + "net/url" + + "task/internal/command" + "task/internal/ticket" +) + +// Executor implements command.Executor via daemon mutation endpoints. +type Executor struct{ c *Client } + +func (c *Client) Executor() *Executor { return &Executor{c: c} } + +func (e *Executor) Do(cmd command.Command) error { + switch c := cmd.(type) { + case *command.CreateTicket: + var out ticket.Ticket + if err := e.c.do(http.MethodPost, "/v1/tickets", c.Ticket, &out); err != nil { + return err + } + c.Ticket = out + return nil + case *command.UpdateTicket: + return e.c.do(http.MethodPatch, "/v1/tickets/"+url.PathEscape(c.After.ID), c.After, nil) + case *command.DeleteTicket: + return e.c.do(http.MethodDelete, "/v1/tickets/"+url.PathEscape(c.Snapshot.ID), nil, nil) + case *command.MoveTicket: + var out ticket.Ticket + if err := e.c.do(http.MethodPost, "/v1/tickets/"+url.PathEscape(c.Before.ID)+"/move", map[string]string{ + "toProjectId": c.After.ProjectID, + }, &out); err != nil { + return err + } + c.After = out + return nil + case *command.AddTimeEntry: + var out ticket.TimeEntry + if err := e.c.do(http.MethodPost, "/v1/time", c.Entry, &out); err != nil { + return err + } + c.Entry = out + return nil + case *command.RemoveTimeEntry: + return e.c.do(http.MethodDelete, "/v1/time/"+url.PathEscape(c.Snapshot.ID), nil, nil) + case *command.UpdateTimeEntry: + return e.c.do(http.MethodPatch, "/v1/time/"+url.PathEscape(c.After.ID), c.After, nil) + default: + return fmt.Errorf("unsupported command type %T", cmd) + } +} + +func (e *Executor) Undo() error { + return e.c.do(http.MethodPost, "/v1/undo", map[string]any{}, nil) +} + +func (e *Executor) Redo() error { + return e.c.do(http.MethodPost, "/v1/redo", map[string]any{}, nil) +} + +func (e *Executor) CanUndo() bool { + st, err := e.status() + if err != nil { + return false + } + return st.CanUndo +} + +func (e *Executor) CanRedo() bool { + st, err := e.status() + if err != nil { + return false + } + return st.CanRedo +} + +func (e *Executor) LastUndoName() string { + st, _ := e.status() + return st.LastUndoName +} + +func (e *Executor) LastRedoName() string { + st, _ := e.status() + return st.LastRedoName +} + +type historyStatus struct { + CanUndo bool `json:"canUndo"` + CanRedo bool `json:"canRedo"` + LastUndoName string `json:"lastUndoName"` + LastRedoName string `json:"lastRedoName"` +} + +func (e *Executor) status() (historyStatus, error) { + var st historyStatus + err := e.c.do(http.MethodGet, "/v1/history", nil, &st) + return st, err +} diff --git a/internal/daemon/client/stores.go b/internal/daemon/client/stores.go new file mode 100644 index 0000000..a4d8b97 --- /dev/null +++ b/internal/daemon/client/stores.go @@ -0,0 +1,159 @@ +package client + +import ( + "fmt" + "net/http" + "net/url" + "time" + + "task/internal/project" + "task/internal/ticket" +) + +// TicketStore implements ticket.Store against the daemon (reads + MarkSynced). +// Mutations used by the TUI go through Executor; Create/Update/Delete/Move here +// call the daemon mutation endpoints (with undo) for completeness. +type TicketStore struct{ c *Client } + +func (c *Client) TicketStore() *TicketStore { return &TicketStore{c: c} } + +func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) { + var out []ticket.Ticket + q := url.Values{"projectId": {projectID}}.Encode() + err := s.c.do(http.MethodGet, "/v1/tickets?"+q, nil, &out) + if out == nil { + out = []ticket.Ticket{} + } + return out, err +} + +func (s *TicketStore) Get(id string) (ticket.Ticket, error) { + var out ticket.Ticket + err := s.c.do(http.MethodGet, "/v1/tickets/"+url.PathEscape(id), nil, &out) + return out, err +} + +func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) { + var out ticket.Ticket + err := s.c.do(http.MethodPost, "/v1/tickets", t, &out) + return out, err +} + +func (s *TicketStore) Update(t ticket.Ticket) error { + return s.c.do(http.MethodPatch, "/v1/tickets/"+url.PathEscape(t.ID), t, nil) +} + +func (s *TicketStore) MarkSynced(id string) error { + return fmt.Errorf("MarkSynced is daemon-internal (use sync endpoints)") +} + +func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) { + var out ticket.Ticket + err := s.c.do(http.MethodPost, "/v1/tickets/"+url.PathEscape(id)+"/move", map[string]string{ + "toProjectId": toProjectID, + }, &out) + return out, err +} + +func (s *TicketStore) Delete(id string) error { + return s.c.do(http.MethodDelete, "/v1/tickets/"+url.PathEscape(id), nil, nil) +} + +// TimeStore implements ticket.TimeStore. +type TimeStore struct{ c *Client } + +func (c *Client) TimeStore() *TimeStore { return &TimeStore{c: c} } + +func (s *TimeStore) List() ([]ticket.TimeEntry, error) { + var out []ticket.TimeEntry + err := s.c.do(http.MethodGet, "/v1/time", nil, &out) + if out == nil { + out = []ticket.TimeEntry{} + } + return out, err +} + +func (s *TimeStore) ListByTicket(ticketID string) ([]ticket.TimeEntry, error) { + var out []ticket.TimeEntry + err := s.c.do(http.MethodGet, "/v1/time/by-ticket/"+url.PathEscape(ticketID), nil, &out) + if out == nil { + out = []ticket.TimeEntry{} + } + return out, err +} + +func (s *TimeStore) Create(e ticket.TimeEntry) (ticket.TimeEntry, error) { + var out ticket.TimeEntry + err := s.c.do(http.MethodPost, "/v1/time", e, &out) + return out, err +} + +func (s *TimeStore) Update(e ticket.TimeEntry) error { + return s.c.do(http.MethodPatch, "/v1/time/"+url.PathEscape(e.ID), e, nil) +} + +func (s *TimeStore) Delete(id string) error { + return s.c.do(http.MethodDelete, "/v1/time/"+url.PathEscape(id), nil, nil) +} + +func (s *TimeStore) DeleteByProject(projectID string) error { + // Composite delete is done server-side on project delete; not exposed alone. + return fmt.Errorf("DeleteByProject is daemon-internal") +} + +func (s *TimeStore) RetargetTicket(ticketID, projectID string) error { + return fmt.Errorf("RetargetTicket is daemon-internal") +} + +func (s *TimeStore) SumByTicket(ticketID string) (time.Duration, error) { + var out struct { + Duration int64 `json:"duration"` + } + err := s.c.do(http.MethodGet, "/v1/time/sum/"+url.PathEscape(ticketID), nil, &out) + return time.Duration(out.Duration), err +} + +func (s *TimeStore) LoadActiveTimer() (*ticket.ActiveTimer, error) { + var out *ticket.ActiveTimer + err := s.c.do(http.MethodGet, "/v1/timer", nil, &out) + return out, err +} + +func (s *TimeStore) SaveActiveTimer(t *ticket.ActiveTimer) error { + if t == nil { + return s.c.do(http.MethodPut, "/v1/timer", map[string]any{}, nil) + } + return s.c.do(http.MethodPut, "/v1/timer", t, nil) +} + +// ProjectStore implements project.Store. +type ProjectStore struct{ c *Client } + +func (c *Client) ProjectStore() *ProjectStore { return &ProjectStore{c: c} } + +func (s *ProjectStore) List() ([]project.Project, error) { + var out []project.Project + err := s.c.do(http.MethodGet, "/v1/projects", nil, &out) + if out == nil { + out = []project.Project{} + } + return out, err +} + +func (s *ProjectStore) Ensure(id string) error { + return s.c.do(http.MethodPost, "/v1/projects", map[string]string{"id": id}, nil) +} + +func (s *ProjectStore) Delete(id string) error { + return s.c.do(http.MethodDelete, "/v1/projects/"+url.PathEscape(id), nil, nil) +} + +func (s *ProjectStore) LoadMeta(id string) (project.Meta, error) { + var out project.Meta + err := s.c.do(http.MethodGet, "/v1/projects/"+url.PathEscape(id)+"/meta", nil, &out) + return out, err +} + +func (s *ProjectStore) SaveMeta(id string, meta project.Meta) error { + return s.c.do(http.MethodPut, "/v1/projects/"+url.PathEscape(id)+"/meta", meta, nil) +} diff --git a/internal/daemon/client/sync.go b/internal/daemon/client/sync.go new file mode 100644 index 0000000..aec73e7 --- /dev/null +++ b/internal/daemon/client/sync.go @@ -0,0 +1,47 @@ +package client + +import ( + "net/http" + "net/url" + + "task/internal/ytsync" +) + +// SyncPull runs daemon sync pull for projectID. +func (c *Client) SyncPull(projectID string) (ytsync.Result, error) { + var out ytsync.Result + err := c.do(http.MethodPost, "/v1/sync/pull", map[string]string{"projectId": projectID}, &out) + return out, err +} + +// SyncPush runs daemon sync push for projectID. +func (c *Client) SyncPush(projectID string) (ytsync.Result, error) { + var out ytsync.Result + err := c.do(http.MethodPost, "/v1/sync/push", map[string]string{"projectId": projectID}, &out) + return out, err +} + +// CreateMCPToken creates a new MCP token for projectID (plaintext once). +func (c *Client) CreateMCPToken(projectID string) (token, createdAt string, err error) { + var out struct { + Token string `json:"token"` + CreatedAt string `json:"createdAt"` + } + err = c.do(http.MethodPost, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", map[string]any{}, &out) + return out.Token, out.CreatedAt, err +} + +// RevokeMCPToken removes the MCP token for projectID. +func (c *Client) RevokeMCPToken(projectID string) error { + return c.do(http.MethodDelete, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", nil, nil) +} + +// MCPTokenStatus returns whether a token is configured. +func (c *Client) MCPTokenStatus(projectID string) (configured bool, createdAt string, err error) { + var out struct { + Configured bool `json:"configured"` + CreatedAt string `json:"createdAt"` + } + err = c.do(http.MethodGet, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", nil, &out) + return out.Configured, out.CreatedAt, err +} diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go new file mode 100644 index 0000000..0378fbd --- /dev/null +++ b/internal/daemon/lifecycle.go @@ -0,0 +1,159 @@ +package daemon + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "syscall" + "time" + + "task/internal/config" +) + +// Status describes whether a daemon is reachable for cfg.DataDir. +type Status struct { + Running bool + PID int + Socket string + DataDir string +} + +// StatusOf returns daemon status for the given config. +func StatusOf(cfg *config.Config) (Status, error) { + p, err := PathsForDataDir(cfg.DataDir) + if err != nil { + return Status{}, err + } + st := Status{Socket: p.Socket, DataDir: cfg.DataDir} + pid, err := readPID(p.PIDFile) + if err == nil && processAlive(pid) { + st.PID = pid + } + if Healthy(p.Socket) { + st.Running = true + return st, nil + } + return st, nil +} + +// Healthy reports whether the daemon HTTP health endpoint responds on socket. +func Healthy(socket string) bool { + cli := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socket) + }, + }, + Timeout: 500 * time.Millisecond, + } + resp, err := cli.Get("http://daemon/v1/health") + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// Ensure starts the daemon if needed and waits until it is healthy. +func Ensure(cfg *config.Config) (Paths, error) { + p, err := PathsForDataDir(cfg.DataDir) + if err != nil { + return Paths{}, err + } + if Healthy(p.Socket) { + return p, nil + } + cleanupStale(p) + if err := Spawn(cfg, p); err != nil { + return Paths{}, err + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if Healthy(p.Socket) { + return p, nil + } + time.Sleep(50 * time.Millisecond) + } + return Paths{}, fmt.Errorf("daemon did not become healthy at %s", p.Socket) +} + +// Stop terminates the daemon for cfg.DataDir if running. +func Stop(cfg *config.Config) error { + p, err := PathsForDataDir(cfg.DataDir) + if err != nil { + return err + } + pid, err := readPID(p.PIDFile) + if err != nil { + if os.IsNotExist(err) { + cleanupStale(p) + return nil + } + return err + } + if processAlive(pid) { + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + return fmt.Errorf("signal daemon: %w", err) + } + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if !processAlive(pid) { + break + } + time.Sleep(50 * time.Millisecond) + } + if processAlive(pid) { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + } + cleanupStale(p) + return nil +} + +// Spawn starts a detached daemon process for cfg. +func Spawn(cfg *config.Config, p Paths) error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("executable: %w", err) + } + logFile, err := os.OpenFile(p.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return fmt.Errorf("open log: %w", err) + } + defer logFile.Close() + + cmd := exec.Command(exe, "daemon") + cmd.Env = os.Environ() + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.Stdin = nil + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return fmt.Errorf("spawn daemon: %w", err) + } + // Detach: do not wait; child writes its own PID file when listening. + _ = cmd.Process.Release() + return nil +} + +func cleanupStale(p Paths) { + _ = os.Remove(p.Socket) + _ = os.Remove(p.PIDFile) +} + +// AbsoluteDataDir ensures DataDir is absolute for path hashing. +func AbsoluteDataDir(dataDir string) (string, error) { + if filepath.IsAbs(dataDir) { + return filepath.Clean(dataDir), nil + } + abs, err := filepath.Abs(dataDir) + if err != nil { + return "", err + } + return abs, nil +} diff --git a/internal/daemon/paths.go b/internal/daemon/paths.go new file mode 100644 index 0000000..f301f55 --- /dev/null +++ b/internal/daemon/paths.go @@ -0,0 +1,91 @@ +package daemon + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +const ( + sockName = "daemon.sock" + pidName = "daemon.pid" + logName = "daemon.log" +) + +// Paths holds filesystem locations for one DataDir's daemon instance. +type Paths struct { + RuntimeDir string + Socket string + PIDFile string + LogFile string +} + +// PathsForDataDir returns runtime paths unique to dataDir. +func PathsForDataDir(dataDir string) (Paths, error) { + dataDir = filepath.Clean(dataDir) + sum := sha256.Sum256([]byte(dataDir)) + id := hex.EncodeToString(sum[:8]) + + dir, err := mkdirRuntime(id, dataDir) + if err != nil { + return Paths{}, err + } + return Paths{ + RuntimeDir: dir, + Socket: filepath.Join(dir, sockName), + PIDFile: filepath.Join(dir, pidName), + LogFile: filepath.Join(dir, logName), + }, nil +} + +func mkdirRuntime(id, dataDir string) (string, error) { + candidates := []string{} + if r := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); r != "" { + candidates = append(candidates, filepath.Join(r, "task-master-"+id)) + } + candidates = append(candidates, + filepath.Join(os.TempDir(), fmt.Sprintf("task-master-%d-%s", os.Getuid(), id)), + filepath.Join(dataDir, ".runtime"), + ) + var last error + for _, dir := range candidates { + if err := os.MkdirAll(dir, 0o700); err != nil { + last = err + continue + } + return dir, nil + } + if last == nil { + last = fmt.Errorf("no runtime dir candidate") + } + return "", fmt.Errorf("create runtime dir: %w", last) +} + +func writePID(path string, pid int) error { + return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o600) +} + +func readPID(path string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + s := strings.TrimSpace(string(data)) + if s == "" { + return 0, fmt.Errorf("empty pid file") + } + return strconv.Atoi(s) +} + +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + return err == nil +} diff --git a/internal/daemon/serve.go b/internal/daemon/serve.go new file mode 100644 index 0000000..83d774e --- /dev/null +++ b/internal/daemon/serve.go @@ -0,0 +1,79 @@ +package daemon + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "task/internal/command" + "task/internal/config" + "task/internal/daemon/api" + "task/internal/project" + "task/internal/ticket" +) + +// Run starts the foreground daemon for cfg until signal or context cancel. +func Run(cfg *config.Config) error { + abs, err := AbsoluteDataDir(cfg.DataDir) + if err != nil { + return err + } + cfg.DataDir = abs + + p, err := PathsForDataDir(cfg.DataDir) + if err != nil { + return err + } + cleanupStale(p) + + store := ticket.NewJSONStore(cfg.DataDir) + timeStore := ticket.NewJSONTimeStore(cfg.DataDir) + projects := project.NewDirStore(cfg.DataDir) + history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store, timeStore) + if err != nil { + return fmt.Errorf("history: %w", err) + } + + srvAPI := api.New(store, timeStore, projects, history) + server := &http.Server{Handler: srvAPI.Handler()} + + ln, err := net.Listen("unix", p.Socket) + if err != nil { + return fmt.Errorf("listen %s: %w", p.Socket, err) + } + if err := os.Chmod(p.Socket, 0o600); err != nil { + _ = ln.Close() + return fmt.Errorf("chmod socket: %w", err) + } + if err := writePID(p.PIDFile, os.Getpid()); err != nil { + _ = ln.Close() + return fmt.Errorf("pid file: %w", err) + } + defer cleanupStale(p) + + errCh := make(chan error, 1) + go func() { + errCh <- server.Serve(ln) + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + select { + case <-sigCh: + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + return nil + case err := <-errCh: + if err == http.ErrServerClosed { + return nil + } + return err + } +} diff --git a/internal/project/meta.go b/internal/project/meta.go index 4c2b28c..af515ce 100644 --- a/internal/project/meta.go +++ b/internal/project/meta.go @@ -54,11 +54,18 @@ func (t TaskMaster) Configured() bool { strings.TrimSpace(t.ProjectID) != "" } +// McpAuth holds the hashed local MCP token for a project (plaintext never stored). +type McpAuth struct { + TokenHash string `json:"tokenHash"` + CreatedAt string `json:"createdAt,omitempty"` +} + // Meta is per-project metadata stored beside tickets.json. type Meta struct { Backend string `json:"backend,omitempty"` // none|youtrack|taskmaster YouTrack *YouTrack `json:"youtrack,omitempty"` TaskMaster *TaskMaster `json:"taskmaster,omitempty"` + McpAuth *McpAuth `json:"mcpAuth,omitempty"` } // EffectiveBackend returns the sync backend kind, with legacy youtrack inference. diff --git a/internal/ui/app.go b/internal/ui/app.go index b2ed851..1ec8292 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -19,11 +19,23 @@ import ( "task/internal/ticket" "task/internal/ui/views" "task/internal/version" - "task/internal/tmsync" "task/internal/youtrack" "task/internal/ytsync" ) +// DaemonChangedMsg is sent when the daemon publishes a change event (SSE). +type DaemonChangedMsg struct { + ProjectID string +} + +// RemoteOps is the optional daemon client surface for sync and MCP tokens. +type RemoteOps interface { + SyncPull(projectID string) (ytsync.Result, error) + SyncPush(projectID string) (ytsync.Result, error) + CreateMCPToken(projectID string) (token, createdAt string, err error) + RevokeMCPToken(projectID string) error +} + // timerTickMsg refreshes the header while a timer is running. type timerTickMsg time.Time @@ -106,10 +118,11 @@ type Model struct { projectEdit views.ProjectEditForm projectEditMeta project.Meta // snapshot when opening the form syncing bool + remote RemoteOps } // New creates the root UI model. -func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor) Model { +func New(cfg *config.Config, store ticket.Store, projects project.Store, timeStore ticket.TimeStore, exec command.Executor, remote RemoteOps) Model { styles := DefaultStyles() if exec == nil { exec = command.NewHistory() @@ -121,6 +134,7 @@ func New(cfg *config.Config, store ticket.Store, projects project.Store, timeSto timeStore: timeStore, projects: projects, exec: exec, + remote: remote, mode: ModeList, boardFilter: ticket.ParseBoardLists(cfg.ActiveBoardLists(cfg.ActiveProject)), reportGroupBy: ticket.GroupByProject, @@ -167,6 +181,15 @@ func timerTickCmd() tea.Cmd { // Update implements tea.Model. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case DaemonChangedMsg: + _ = m.reloadTickets() + if m.activeTimer != nil && m.timeStore != nil { + if t, err := m.timeStore.LoadActiveTimer(); err == nil { + m.activeTimer = t + } + } + return m, nil + case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -920,6 +943,10 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M m.setStatus("sync already running…") return m, nil } + if m.remote == nil { + m.setError(fmt.Errorf("daemon client not available")) + return m, nil + } m.clearStatus() m.syncing = true switch action { @@ -928,33 +955,15 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M default: m.setStatus(fmt.Sprintf("pulling %s…", projectID)) } - store := m.store - timeStore := m.timeStore - projects := m.projects + remote := m.remote return m, func() tea.Msg { var pull, push ytsync.Result var err error - meta, merr := projects.LoadMeta(projectID) - if merr != nil { - err = merr - } else { - switch meta.EffectiveBackend() { - case project.BackendTaskMaster: - syncer := &tmsync.Syncer{Store: store, TimeStore: timeStore} - switch action { - case views.SyncActionPush: - push, err = syncer.Push(context.Background(), projectID, *meta.TaskMaster) - default: - pull, err = syncer.Pull(context.Background(), projectID, *meta.TaskMaster) - } - default: - switch action { - case views.SyncActionPush: - push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID) - default: - pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID) - } - } + switch action { + case views.SyncActionPush: + push, err = remote.SyncPush(projectID) + default: + pull, err = remote.SyncPull(projectID) } return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err} } @@ -1031,6 +1040,41 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.openSyncMenu(item.Project.ID) return m, nil + case key.Matches(msg, m.keys.MCPToken): + item, ok := m.projectList.SelectedItem().(views.ProjectItem) + if !ok { + m.setError(fmt.Errorf("no project selected")) + return m, nil + } + if m.remote == nil { + m.setError(fmt.Errorf("daemon client not available")) + return m, nil + } + token, _, err := m.remote.CreateMCPToken(item.Project.ID) + if err != nil { + m.setError(err) + return m, nil + } + m.setStatus(fmt.Sprintf("MCP token for %s (save as .tm_token): project=%s token=%s", item.Project.ID, item.Project.ID, token)) + return m, nil + + case key.Matches(msg, m.keys.MCPTokenRevoke): + item, ok := m.projectList.SelectedItem().(views.ProjectItem) + if !ok { + m.setError(fmt.Errorf("no project selected")) + return m, nil + } + if m.remote == nil { + m.setError(fmt.Errorf("daemon client not available")) + return m, nil + } + if err := m.remote.RevokeMCPToken(item.Project.ID); err != nil { + m.setError(err) + return m, nil + } + m.setStatus(fmt.Sprintf("revoked MCP token for %s", item.Project.ID)) + return m, nil + case key.Matches(msg, m.keys.DeleteForce): m.clearStatus() item, ok := m.projectList.SelectedItem().(views.ProjectItem) diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 407dc8b..9a9048b 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -25,6 +25,8 @@ type KeyMap struct { TimeEntries key.Binding // m — manage time entries Report key.Binding // r — time report Sync key.Binding // Y — pull YouTrack issues + MCPToken key.Binding // g — create MCP token for project + MCPTokenRevoke key.Binding // G — revoke MCP token Undo key.Binding Redo key.Binding Quit key.Binding @@ -121,6 +123,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys("Y"), key.WithHelp("Y", "YouTrack pull/push"), ), + MCPToken: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("g", "MCP token"), + ), + MCPTokenRevoke: key.NewBinding( + key.WithKeys("G"), + key.WithHelp("G", "revoke MCP token"), + ), Undo: key.NewBinding( key.WithKeys("u", "ctrl+z"), key.WithHelp("u", "undo"), @@ -154,7 +164,7 @@ func DefaultKeyMap() KeyMap { // ShortHelp implements help.KeyMap. func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit} + return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.MCPToken, k.MCPTokenRevoke, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit} } // FullHelp implements help.KeyMap.