Files
task-master/internal/project/meta.go
T
skrueckenandCursor 2dbf0918de 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>
2026-11-01 16:32:13 +01:00

180 lines
4.7 KiB
Go

package project
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
const metaFileName = "meta.json"
const (
BackendNone = "none"
BackendYouTrack = "youtrack"
BackendTaskMaster = "taskmaster"
)
// YouTrack holds remote sync settings for a project.
type YouTrack struct {
BaseURL string `json:"baseUrl"`
Token string `json:"token"`
ProjectKey string `json:"projectKey"`
// Query overrides the default "project: <ProjectKey>" issue filter.
Query string `json:"query,omitempty"`
}
// Configured reports whether enough fields are set to sync.
func (y YouTrack) Configured() bool {
return strings.TrimSpace(y.BaseURL) != "" &&
strings.TrimSpace(y.Token) != "" &&
strings.TrimSpace(y.ProjectKey) != ""
}
// IssueQuery returns the YouTrack search query used for sync.
func (y YouTrack) IssueQuery() string {
if q := strings.TrimSpace(y.Query); q != "" {
return q
}
return "project: " + strings.TrimSpace(y.ProjectKey)
}
// TaskMaster holds sync settings for a remote tm_server project.
type TaskMaster struct {
BaseURL string `json:"baseUrl"`
Token string `json:"token"`
ProjectID string `json:"projectId"`
}
// Configured reports whether task-master sync can run.
func (t TaskMaster) Configured() bool {
return strings.TrimSpace(t.BaseURL) != "" &&
strings.TrimSpace(t.Token) != "" &&
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.
func (m Meta) EffectiveBackend() string {
b := strings.TrimSpace(strings.ToLower(m.Backend))
switch b {
case BackendYouTrack, BackendTaskMaster, BackendNone:
return b
}
if m.YouTrack != nil && m.YouTrack.Configured() {
return BackendYouTrack
}
if m.TaskMaster != nil && m.TaskMaster.Configured() {
return BackendTaskMaster
}
if m.YouTrack != nil {
return BackendYouTrack
}
if m.TaskMaster != nil {
return BackendTaskMaster
}
return BackendNone
}
// SyncConfigured reports whether the active backend is fully set up.
func (m Meta) SyncConfigured() bool {
switch m.EffectiveBackend() {
case BackendYouTrack:
return m.YouTrack != nil && m.YouTrack.Configured()
case BackendTaskMaster:
return m.TaskMaster != nil && m.TaskMaster.Configured()
default:
return false
}
}
func (s *DirStore) metaPath(id string) string {
return filepath.Join(s.projectsRoot(), id, metaFileName)
}
// LoadMeta reads projects/<id>/meta.json. Missing file yields empty Meta.
func (s *DirStore) LoadMeta(id string) (Meta, error) {
if id == "" {
return Meta{}, fmt.Errorf("project id is required")
}
path := s.metaPath(id)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return Meta{}, nil
}
return Meta{}, fmt.Errorf("read meta %s: %w", id, err)
}
if len(data) == 0 {
return Meta{}, nil
}
var meta Meta
if err := json.Unmarshal(data, &meta); err != nil {
return Meta{}, fmt.Errorf("parse meta %s: %w", id, err)
}
if meta.Backend == "" && meta.YouTrack != nil {
meta.Backend = BackendYouTrack
}
if meta.Backend == "" && meta.TaskMaster != nil {
meta.Backend = BackendTaskMaster
}
return meta, nil
}
// SaveMeta writes projects/<id>/meta.json with mode 0600 (may contain tokens).
func (s *DirStore) SaveMeta(id string, meta Meta) error {
if id == "" {
return fmt.Errorf("project id is required")
}
if err := s.Ensure(id); err != nil {
return err
}
if meta.Backend == "" {
meta.Backend = meta.EffectiveBackend()
}
path := s.metaPath(id)
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return fmt.Errorf("marshal meta: %w", err)
}
data = append(data, '\n')
tmp, err := os.CreateTemp(filepath.Dir(path), "meta-*.tmp")
if err != nil {
return fmt.Errorf("create temp meta: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return fmt.Errorf("chmod temp meta: %w", err)
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp meta: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp meta: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename meta: %w", err)
}
_ = os.Chmod(path, 0o600)
return nil
}