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 <cursoragent@cursor.com>
This commit is contained in:
2026-11-01 16:32:13 +01:00
co-authored by Cursor
parent 98fcb57298
commit 2dbf0918de
23 changed files with 2107 additions and 93 deletions
+14 -3
View File
@@ -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-<hash>/` (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
+138 -64
View File
@@ -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 {
if action == "push" {
out, err := cli.SyncPush(projectID)
if err != nil {
return err
}
fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
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
}
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" {
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)
}
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)
}
+115
View File
@@ -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")
}
}
+117
View File
@@ -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 <token>")
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
}
+37
View File
@@ -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})
}
+81
View File
@@ -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()
}
}
}
+23
View File
@@ -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)
}
+183
View File
@@ -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)
}
+84
View File
@@ -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})
}
+68
View File
@@ -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)
}
+147
View File
@@ -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)
}
+214
View File
@@ -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)
}
+111
View File
@@ -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)
}
+53
View File
@@ -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()
}
+100
View File
@@ -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
}
+159
View File
@@ -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)
}
+47
View File
@@ -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
}
+159
View File
@@ -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
}
+91
View File
@@ -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
}
+79
View File
@@ -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
}
}
+7
View File
@@ -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.
+67 -23
View File
@@ -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)
push, err = remote.SyncPush(projectID)
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)
}
}
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)
+11 -1
View File
@@ -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.