verstak/cmd/verstak/main.go

836 lines
19 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"verstak/internal/core/actions"
"verstak/internal/core/config"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/vault"
"verstak/internal/core/worklog"
)
const version = "0.1.0-dev"
func main() {
if len(os.Args) < 2 {
fmt.Println("Verstak", version)
os.Exit(0)
}
switch os.Args[1] {
case "--version", "-v":
fmt.Println("Verstak", version)
case "--help", "-h":
usage()
case "init":
runInit(os.Args[2:])
case "node":
runNode(os.Args[2:])
case "action":
runAction(os.Args[2:])
case "log":
runLog(os.Args[2:])
case "index":
runIndex(os.Args[2:])
case "sync":
runSync(os.Args[2:])
case "plugin":
runPlugin(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
os.Exit(1)
}
}
func usage() {
fmt.Println("Verstak — local-first working vault")
fmt.Println()
fmt.Println("Usage: verstak <command> [flags]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" init Initialize a new vault")
fmt.Println(" node Manage nodes")
fmt.Println(" action Manage actions")
fmt.Println(" --version Show version")
fmt.Println(" sync Sync with server (push/pull/status)")
fmt.Println(" --help Show this help")
}
// --- init ---
func runInit(args []string) {
vaultPath := vaultPathFromFlags(args)
abs, err := filepath.Abs(vaultPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if err := vault.Init(abs); err != nil {
fmt.Fprintf(os.Stderr, "Init failed: %v\n", err)
os.Exit(1)
}
fmt.Println("Vault initialized at", abs)
fmt.Println(" .verstak/index.db")
fmt.Println(" .verstak/config.yml")
fmt.Println(" spaces/")
}
// --- node ---
func runNode(args []string) {
if len(args) == 0 {
nodeUsage()
os.Exit(1)
}
sub := args[0]
rest := args[1:]
switch sub {
case "create":
runNodeCreateCmd(rest)
case "list":
runNodeListCmd(rest)
case "get":
runNodeGetCmd(rest)
case "move":
runNodeMoveCmd(rest)
case "delete":
runNodeDeleteCmd(rest)
case "--help", "-h":
nodeUsage()
default:
fmt.Fprintf(os.Stderr, "Unknown node command: %s\n", sub)
os.Exit(1)
}
}
func runNodeCreateCmd(args []string) {
typ, _ := stringFlag(args, "--type")
title, _ := stringFlag(args, "--title")
parentID, _ := stringFlag(args, "--parent")
vaultPath, _ := stringFlag(args, "--vault")
if typ == "" || title == "" {
fmt.Fprintln(os.Stderr, "Error: --type and --title required")
nodeUsage()
os.Exit(1)
}
abs, _ := filepath.Abs(vaultPath)
if err := runNodeCreate(abs, parentID, typ, title); err != nil {
fmt.Fprintf(os.Stderr, "Create failed: %v\n", err)
os.Exit(1)
}
}
func runNodeListCmd(args []string) {
parentID, _ := stringFlag(args, "--parent")
vaultPath, _ := stringFlag(args, "--vault")
abs, _ := filepath.Abs(vaultPath)
if err := runNodeList(abs, parentID); err != nil {
fmt.Fprintf(os.Stderr, "List failed: %v\n", err)
os.Exit(1)
}
}
func runNodeGetCmd(args []string) {
id, _ := stringFlag(args, "--id")
vaultPath, _ := stringFlag(args, "--vault")
if id == "" {
fmt.Fprintln(os.Stderr, "Error: --id required")
os.Exit(1)
}
abs, _ := filepath.Abs(vaultPath)
if err := runNodeGet(abs, id); err != nil {
fmt.Fprintf(os.Stderr, "Get failed: %v\n", err)
os.Exit(1)
}
}
func runNodeMoveCmd(args []string) {
id, _ := stringFlag(args, "--id")
parentID, _ := stringFlag(args, "--parent")
sortStr, _ := stringFlag(args, "--sort")
vaultPath, _ := stringFlag(args, "--vault")
if id == "" {
fmt.Fprintln(os.Stderr, "Error: --id required")
os.Exit(1)
}
sort, _ := strconv.Atoi(sortStr)
abs, _ := filepath.Abs(vaultPath)
if err := runNodeMove(abs, id, parentID, sort); err != nil {
fmt.Fprintf(os.Stderr, "Move failed: %v\n", err)
os.Exit(1)
}
}
func runNodeDeleteCmd(args []string) {
id, _ := stringFlag(args, "--id")
vaultPath, _ := stringFlag(args, "--vault")
if id == "" {
fmt.Fprintln(os.Stderr, "Error: --id required")
os.Exit(1)
}
abs, _ := filepath.Abs(vaultPath)
if err := runNodeDelete(abs, id); err != nil {
fmt.Fprintf(os.Stderr, "Delete failed: %v\n", err)
os.Exit(1)
}
}
// --- flag parsing ---
func stringFlag(args []string, name string) (string, bool) {
for i := 0; i < len(args); i++ {
if args[i] == name && i+1 < len(args) {
return args[i+1], true
}
}
return "", false
}
func vaultPathFromFlags(args []string) string {
if v, ok := stringFlag(args, "--vault"); ok {
return v
}
return "."
}
// --- action ---
func runAction(args []string) {
if len(args) == 0 {
actionUsage()
os.Exit(1)
}
sub := args[0]
rest := args[1:]
switch sub {
case "add":
runActionAdd(rest)
case "list":
runActionList(rest)
case "run":
runActionRun(rest)
case "delete":
runActionDelete(rest)
case "--help", "-h":
actionUsage()
default:
fmt.Fprintf(os.Stderr, "Unknown action command: %s\n", sub)
os.Exit(1)
}
}
func actionUsage() {
fmt.Println("verstak action — manage actions")
fmt.Println()
fmt.Println("Usage: verstak action <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" add --node ID --kind KIND --title TITLE Add action")
fmt.Println(" list --node ID List actions for node")
fmt.Println(" run --id ID Run action")
fmt.Println(" delete --id ID Delete action")
fmt.Println()
fmt.Println("Kinds: open_url, open_file, open_folder, run_command, run_script, open_terminal, launch_app")
}
func openActionDB(args []string) (*actions.Service, string, func()) {
vaultPath, _ := stringFlag(args, "--vault")
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
svc := actions.NewService(db)
return svc, abs, func() { db.Close() }
}
func runActionAdd(args []string) {
nodeID, _ := stringFlag(args, "--node")
kind, _ := stringFlag(args, "--kind")
title, _ := stringFlag(args, "--title")
cmd, _ := stringFlag(args, "--command")
url, _ := stringFlag(args, "--url")
workingDir, _ := stringFlag(args, "--working-dir")
vaultPath, _ := stringFlag(args, "--vault")
if nodeID == "" || kind == "" || title == "" {
fmt.Fprintln(os.Stderr, "Error: --node, --kind and --title required")
actionUsage()
os.Exit(1)
}
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
defer db.Close()
svc := actions.NewService(db)
rec, err := svc.Create(nodeID, kind, title, cmd, workingDir, url, nil, false, false)
if err != nil {
fmt.Fprintf(os.Stderr, "Add failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("created\t%s\t%s\t%s\t%s\n", rec.ID, rec.Kind, rec.NodeID, rec.Title)
}
func runActionList(args []string) {
nodeID, _ := stringFlag(args, "--node")
vaultPath, _ := stringFlag(args, "--vault")
if nodeID == "" {
fmt.Fprintln(os.Stderr, "Error: --node required")
os.Exit(1)
}
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
defer db.Close()
svc := actions.NewService(db)
list, err := svc.ListByNode(nodeID)
if err != nil {
fmt.Fprintf(os.Stderr, "List failed: %v\n", err)
os.Exit(1)
}
for _, a := range list {
fmt.Printf("%s\t%s\t%v\t%s\n", a.ID, a.Kind, a.ConfirmRequired, a.Title)
}
}
func runActionRun(args []string) {
id, _ := stringFlag(args, "--id")
vaultPath, _ := stringFlag(args, "--vault")
if id == "" {
fmt.Fprintln(os.Stderr, "Error: --id required")
os.Exit(1)
}
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
defer db.Close()
svc := actions.NewService(db)
rec, err := svc.Get(id)
if err != nil {
fmt.Fprintf(os.Stderr, "Get failed: %v\n", err)
os.Exit(1)
}
if rec.ConfirmRequired {
fmt.Printf("Action: %s\n", rec.Title)
fmt.Printf("Kind: %s\n", actions.KindLabel(rec.Kind))
if rec.Command != "" {
fmt.Printf("Cmd: %s\n", rec.Command)
}
if rec.URL != "" {
fmt.Printf("URL: %s\n", rec.URL)
}
fmt.Print("Run? [y/N] ")
var input string
fmt.Scanln(&input)
if strings.ToLower(input) != "y" {
fmt.Println("Cancelled")
return
}
}
result, err := svc.Run(id)
if err != nil {
fmt.Fprintf(os.Stderr, "Run failed: %v\n", err)
os.Exit(1)
}
if result.ExitCode != 0 {
fmt.Printf("exit=%d\n", result.ExitCode)
}
if result.Output != "" {
fmt.Print(result.Output)
}
fmt.Println("done")
}
func runActionDelete(args []string) {
id, _ := stringFlag(args, "--id")
vaultPath, _ := stringFlag(args, "--vault")
if id == "" {
fmt.Fprintln(os.Stderr, "Error: --id required")
os.Exit(1)
}
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
defer db.Close()
svc := actions.NewService(db)
if err := svc.Delete(id); err != nil {
fmt.Fprintf(os.Stderr, "Delete failed: %v\n", err)
os.Exit(1)
}
fmt.Println("deleted")
}
// --- log ---
func runLog(args []string) {
if len(args) == 0 {
fmt.Println("verstak log — manage worklog entries")
fmt.Println()
fmt.Println("Usage: verstak log <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" add --node ID --time MIN --text TEXT Add worklog entry")
fmt.Println(" list --node ID List entries for node")
fmt.Println(" report --node ID Copy report to stdout")
os.Exit(1)
}
switch args[0] {
case "add":
runLogAdd(args[1:])
case "list":
runLogList(args[1:])
case "report":
runLogReport(args[1:])
case "--help", "-h":
runLog(nil)
default:
fmt.Fprintf(os.Stderr, "Unknown log command: %s\n", args[0])
os.Exit(1)
}
}
func runLogAdd(args []string) {
nodeID, _ := stringFlag(args, "--node")
minutesStr, _ := stringFlag(args, "--time")
text, _ := stringFlag(args, "--text")
vaultPath, _ := stringFlag(args, "--vault")
if nodeID == "" || text == "" {
fmt.Fprintln(os.Stderr, "Error: --node and --text required")
os.Exit(1)
}
minutes := 0
if minutesStr != "" {
fmt.Sscanf(minutesStr, "%d", &minutes)
}
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
defer db.Close()
svc := worklog.NewService(db)
e, err := svc.Add(nodeID, text, "", minutes, true, false)
if err != nil {
fmt.Fprintf(os.Stderr, "Add failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("logged\t%s\t%dm\t%s\n", e.ID, minutes, e.Summary)
}
func runLogList(args []string) {
nodeID, _ := stringFlag(args, "--node")
vaultPath, _ := stringFlag(args, "--vault")
if nodeID == "" {
fmt.Fprintln(os.Stderr, "Error: --node required")
os.Exit(1)
}
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
defer db.Close()
svc := worklog.NewService(db)
entries, err := svc.ListByNode(nodeID)
if err != nil {
fmt.Fprintf(os.Stderr, "List failed: %v\n", err)
os.Exit(1)
}
for _, e := range entries {
dur := "—"
if e.Minutes != nil {
dur = fmt.Sprintf("%dm", *e.Minutes)
}
approx := ""
if e.Approximate {
approx = " ~"
}
fmt.Printf("%s\t%s%s\t%s\n", e.Date, dur, approx, e.Summary)
}
}
func runLogReport(args []string) {
nodeID, _ := stringFlag(args, "--node")
vaultPath, _ := stringFlag(args, "--vault")
if nodeID == "" {
fmt.Fprintln(os.Stderr, "Error: --node required")
os.Exit(1)
}
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
defer db.Close()
svc := worklog.NewService(db)
report, err := svc.Report(nodeID)
if err != nil {
fmt.Fprintf(os.Stderr, "Report failed: %v\n", err)
os.Exit(1)
}
fmt.Print(report)
}
// --- index ---
func runIndex(args []string) {
if len(args) == 0 {
fmt.Println("verstak index — manage search index")
fmt.Println()
fmt.Println("Usage: verstak index <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" rebuild Rebuild FTS5 search index")
os.Exit(1)
}
switch args[0] {
case "rebuild":
runIndexRebuild(args[1:])
case "--help", "-h":
runIndex(nil)
default:
fmt.Fprintf(os.Stderr, "Unknown index command: %s\n", args[0])
os.Exit(1)
}
}
func runIndexRebuild(args []string) {
vaultPath, _ := stringFlag(args, "--vault")
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
defer db.Close()
svc := search.NewService(db)
if err := svc.Rebuild(); err != nil {
fmt.Fprintf(os.Stderr, "Rebuild failed: %v\n", err)
os.Exit(1)
}
// Index all node titles.
rows, err := db.Query(
`SELECT id, title, type, section FROM nodes WHERE deleted_at IS NULL`)
if err != nil {
fmt.Fprintf(os.Stderr, "Query nodes: %v\n", err)
os.Exit(1)
}
defer rows.Close()
count := 0
for rows.Next() {
var id, title, nodeType, section string
if err := rows.Scan(&id, &title, &nodeType, &section); err != nil {
continue
}
tags := section
if tags != "" {
tags = "section:" + tags
}
svc.Index(id, title, "", "", tags, nodeType)
count++
}
fmt.Printf("indexed %d nodes\n", count)
}
// --- sync ---
func runSync(args []string) {
if len(args) == 0 {
fmt.Println("verstak sync — synchronize with server")
fmt.Println()
fmt.Println("Usage: verstak sync <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" push Push local changes to server")
fmt.Println(" pull Pull remote changes from server")
fmt.Println(" status Show sync status")
os.Exit(0)
}
switch args[0] {
case "push":
runSyncPush(args[1:])
case "pull":
runSyncPull(args[1:])
case "status":
runSyncStatus(args[1:])
case "--help", "-h":
runSync(nil)
default:
fmt.Fprintf(os.Stderr, "Unknown sync command: %s\n", args[0])
os.Exit(1)
}
}
func openSyncDB(args []string) (*storage.DB, string) {
vaultPath, _ := stringFlag(args, "--vault")
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
return db, abs
}
func runSyncPush(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
fmt.Fprintln(os.Stderr, "Sync not configured. Use 'verstak sync configure' or GUI settings.")
os.Exit(1)
}
deviceID := cfg.Sync.DeviceID
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
unpushed, err := syncSvc.GetUnpushedOps()
if err != nil {
fmt.Fprintf(os.Stderr, "Get ops: %v\n", err)
os.Exit(1)
}
if len(unpushed) == 0 {
fmt.Println("Nothing to push.")
return
}
_, _, lastSeq, _, _ := syncSvc.GetState()
for i := range unpushed {
unpushed[i].LastSeenServerSeq = lastSeq
}
result, err := client.Push(unpushed)
if err != nil {
fmt.Fprintf(os.Stderr, "Push failed: %v\n", err)
os.Exit(1)
}
if err := syncSvc.MarkPushed(result.Accepted); err != nil {
fmt.Fprintf(os.Stderr, "Mark pushed: %v\n", err)
os.Exit(1)
}
fmt.Printf("Pushed %d ops, accepted %d\n", len(unpushed), len(result.Accepted))
if len(result.Conflicts) > 0 {
fmt.Printf("WARNING: %d conflict(s) detected\n", len(result.Conflicts))
}
}
func runSyncPull(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
fmt.Fprintln(os.Stderr, "Sync not configured.")
os.Exit(1)
}
deviceID := cfg.Sync.DeviceID
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
_, _, lastSeq, _, err := syncSvc.GetState()
if err != nil {
lastSeq = 0
}
result, err := client.Pull(lastSeq)
if err != nil {
fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err)
os.Exit(1)
}
var opIDs []string
for _, op := range result.Ops {
fmt.Printf(" %s\t%s\t%s\t%s\n", op.OpType, op.EntityType, op.EntityID, op.PayloadJSON)
opIDs = append(opIDs, op.OpID)
}
if len(opIDs) > 0 {
syncSvc.MarkApplied(opIDs)
}
fmt.Printf("Pulled %d ops (server seq: %d)\n", len(result.Ops), result.ServerSequence)
}
func runSyncStatus(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
configured := err == nil && cfg.Sync.ServerURL != "" && cfg.Sync.APIKey != ""
serverURL := ""
deviceID := ""
if cfg != nil {
serverURL = cfg.Sync.ServerURL
deviceID = cfg.Sync.DeviceID
}
unpushed := 0
if configured {
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
ops, _ := syncSvc.GetUnpushedOps()
unpushed = len(ops)
}
fmt.Println("Sync Status")
fmt.Println(" Configured:", configured)
fmt.Println(" Server:", serverURL)
fmt.Println(" Device:", deviceID)
fmt.Println(" Unpushed ops:", unpushed)
}
// --- plugin ---
func runPlugin(args []string) {
vaultPath, _ := stringFlag(args, "--vault")
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
fmt.Println("verstak plugin — manage plugins")
fmt.Println()
fmt.Println("Usage: verstak plugin <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" list List discovered plugins")
fmt.Println(" enable NAME Enable a plugin")
fmt.Println(" disable NAME Disable a plugin")
fmt.Println(" templates List available templates")
os.Exit(0)
}
abs, _ := filepath.Abs(vaultPath)
switch args[0] {
case "list":
mgr := plugins.NewManager(abs)
mgr.Discover()
pp := mgr.Plugins()
if len(pp) == 0 {
fmt.Println("No plugins found.")
fmt.Println("Put plugins in .verstak/plugins/<name>/plugin.json")
return
}
for _, p := range pp {
status := "on"
if !p.Active {
status = "off"
}
fmt.Printf("%-20s %-6s %s\n", p.Meta.Name, status, p.Meta.Description)
}
case "enable", "disable":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "Error: plugin name required")
os.Exit(1)
}
mgr := plugins.NewManager(abs)
mgr.Discover()
name := args[1]
if args[0] == "enable" {
mgr.Enable(name)
} else {
mgr.Disable(name)
}
fmt.Printf("%s %s\n", args[0]+"d", name)
case "templates":
mgr := plugins.NewManager(abs)
mgr.Discover()
tmpls := mgr.Templates()
if len(tmpls) == 0 {
fmt.Println("No templates found.")
return
}
for _, t := range tmpls {
fmt.Printf("%-20s %-10s %s\n", t.Name, fmt.Sprintf("[%s]", t.Plugin), t.Description)
}
default:
fmt.Fprintf(os.Stderr, "Unknown plugin command: %s\n", args[0])
os.Exit(1)
}
}