Add tm_server with SQLite, Svelte UI, and task-master TUI sync.

Introduces the web backend (auth, RBAC, project tokens, revisions),
YouTrack-or-taskmaster backend choice in the TUI, and a first-pass
embedded Svelte UI. The frontend is functional but needs a major rework.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 22:23:11 +01:00
co-authored by Cursor
parent 98d2253f44
commit 4adfcdb95a
67 changed files with 7188 additions and 129 deletions
+32 -14
View File
@@ -14,6 +14,7 @@ import (
"task/internal/ticket"
"task/internal/ui"
"task/internal/version"
"task/internal/tmsync"
"task/internal/ytsync"
)
@@ -111,19 +112,36 @@ func runSync(args []string) error {
return err
}
switch action {
case "push":
res, err := ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
if 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" {
fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
projectID, res.Created, res.Updated, res.TimePushed)
default:
res, err := ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
if err != nil {
return err
}
} else {
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
projectID, res.Created, res.Updated, res.TimePulled, res.Total)
}
@@ -135,8 +153,8 @@ func printHelp() {
Usage:
%s Start the TUI
%s sync pull [-p ID] Pull issues from YouTrack
%s sync push [-p ID] Push local changes to YouTrack
%s sync pull [-p ID] Pull from configured backend (YouTrack or task-master)
%s sync push [-p ID] Push to configured backend
%s version Print build version
%s help Show this help
@@ -144,8 +162,8 @@ Env:
TASK_MASTER_HOME Override home for config/data (dev/demo sandbox)
TASK_MASTER_DATA_DIR Override data directory only
YouTrack:
In the TUI: p → project → e to set remote URL, token, project key.
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).
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
+188
View File
@@ -0,0 +1,188 @@
package main
import (
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"task/internal/config"
"task/internal/server"
"task/internal/sqlite"
"task/internal/version"
)
func main() {
listen := flag.String("listen", ":8080", "HTTP listen address")
dbPath := flag.String("db", "", "SQLite database path (default: <dataDir>/task-master.db)")
secure := flag.Bool("secure", false, "Set Secure flag on session cookies")
flag.Parse()
args := flag.Args()
if len(args) > 0 {
switch args[0] {
case "version", "--version", "-v":
fmt.Printf("tm_server %s\n", version.BuildVersion)
return
case "help", "--help", "-h":
printHelp()
return
case "import-json":
if err := runImportJSON(args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
printHelp()
os.Exit(1)
}
}
if err := runServer(*listen, *dbPath, *secure); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func openDB(dbPath string) (*sqlite.DB, *config.Config, error) {
cfg, err := config.LoadOrCreate()
if err != nil {
return nil, nil, fmt.Errorf("config: %w", err)
}
path := dbPath
if path == "" {
path = filepath.Join(cfg.DataDir, "task-master.db")
}
secret := []byte(os.Getenv("TASK_MASTER_SECRET"))
db, err := sqlite.Open(path, secret)
if err != nil {
return nil, nil, err
}
return db, cfg, nil
}
func bootstrapAdmin(db *sqlite.DB) error {
n, err := db.CountUsers()
if err != nil {
return err
}
if n > 0 {
return nil
}
user := strings.TrimSpace(os.Getenv("TM_ADMIN_USER"))
pass := os.Getenv("TM_ADMIN_PASSWORD")
if user == "" || pass == "" {
fmt.Fprintln(os.Stderr, "no users in database; set TM_ADMIN_USER and TM_ADMIN_PASSWORD to create the first admin")
return nil
}
u, err := db.CreateUser(user, pass, true)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "created bootstrap admin %q (%s)\n", u.Username, u.ID)
return nil
}
func runServer(listen, dbPath string, secure bool) error {
db, _, err := openDB(dbPath)
if err != nil {
return err
}
defer db.Close()
if err := bootstrapAdmin(db); err != nil {
return err
}
srv := server.New(db)
srv.Secure = secure
if strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "1") ||
strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "true") {
if err := srv.SeedDemo(); err != nil {
return fmt.Errorf("seed: %w", err)
}
projects, _ := srv.Projects.List()
fmt.Fprintf(os.Stderr, "demo seed ready (%d projects; login admin/admin, alice/alice)\n", len(projects))
}
fmt.Fprintf(os.Stderr, "tm_server %s listening on %s\n", version.BuildVersion, listen)
return http.ListenAndServe(listen, srv.Handler())
}
func runImportJSON(args []string) error {
fs := flag.NewFlagSet("import-json", flag.ExitOnError)
dataDir := fs.String("data", "", "JSON DataDir to import (default from config)")
dbPath := fs.String("db", "", "SQLite database path")
owner := fs.String("owner", "", "username to own imported projects")
_ = fs.Parse(args)
db, cfg, err := openDB(*dbPath)
if err != nil {
return err
}
defer db.Close()
if err := bootstrapAdmin(db); err != nil {
return err
}
dir := *dataDir
if dir == "" {
dir = cfg.DataDir
}
var ownerID string
if *owner != "" {
u, err := db.GetUserByUsername(*owner)
if err != nil {
return fmt.Errorf("owner: %w", err)
}
ownerID = u.ID
} else {
users, err := db.ListUsers()
if err != nil {
return err
}
if len(users) == 0 {
return fmt.Errorf("no users; create admin first (TM_ADMIN_USER/TM_ADMIN_PASSWORD)")
}
for _, u := range users {
if u.IsAdmin {
ownerID = u.ID
break
}
}
if ownerID == "" {
ownerID = users[0].ID
}
}
srv := server.New(db)
fmt.Fprintf(os.Stderr, "importing from %s …\n", dir)
if err := srv.ImportJSONDataDir(dir, ownerID); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "import done")
return nil
}
func printHelp() {
fmt.Print(`tm_server — task-master web server
Usage:
tm_server [flags]
tm_server import-json [-data DIR] [-db PATH] [-owner USER]
tm_server version
tm_server help
Flags:
-listen ADDR HTTP listen address (default :8080)
-db PATH SQLite path (default <dataDir>/task-master.db)
-secure Set Secure cookie flag
Env:
TASK_MASTER_HOME / TASK_MASTER_DATA_DIR same as tm
TASK_MASTER_SECRET encrypts remote tokens
TM_ADMIN_USER / TM_ADMIN_PASSWORD bootstrap first admin
TM_SEED_DEMO=1 seed local Test projects when DB empty (no YouTrack)
`)
}