step 3: nodes repository + CRUD + CLI node commands

- types.go: TypeSpace/Case/Folder/Note/... + Slugify()
- node.go: Node struct, Meta, IsDeleted/IsRoot helpers
- repository.go: full CRUD (Create, Get, GetActive, ListChildren,
  ListRoots, UpdateTitle, Move, SoftDelete) + Meta KV (MetaSet,
  MetaGet, MetaList)
- node_cmd.go: thin wrappers around repository
- main.go: verstak node create/list/get/move/delete subcommands
- repository_test.go: 12 tests covering all CRUD paths

Acceptance: go build pass, go test pass (12 tests),
  CLI create+list+get+move+delete all working.
This commit is contained in:
2026-05-30 19:09:25 +08:00
parent b8d8427c46
commit 69eb909d48
7 changed files with 993 additions and 16 deletions
+140 -15
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"verstak/internal/core/vault"
)
@@ -22,7 +23,9 @@ func main() {
case "--help", "-h":
usage()
case "init":
runInit()
runInit(os.Args[2:])
case "node":
runNode(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
os.Exit(1)
@@ -35,34 +38,156 @@ func usage() {
fmt.Println("Usage: verstak <command> [flags]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" init --vault PATH Initialize a new vault at PATH")
fmt.Println(" --version Show version")
fmt.Println(" --help Show this help")
fmt.Println(" init Initialize a new vault")
fmt.Println(" node Manage nodes")
fmt.Println(" --version Show version")
fmt.Println(" --help Show this help")
}
func runInit() {
vaultPath := "."
// Parse --vault flag
for i := 2; i < len(os.Args); i++ {
if os.Args[i] == "--vault" && i+1 < len(os.Args) {
vaultPath = os.Args[i+1]
break
}
}
// --- 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 "."
}
+133
View File
@@ -0,0 +1,133 @@
// Command helpers for the node subcommand.
package main
import (
"fmt"
"path/filepath"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
)
func runNodeCreate(vault, parentID, typ, title string) error {
dbPath := filepath.Join(vault, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
return fmt.Errorf("open db: %v", err)
}
defer db.Close()
repo := nodes.NewRepository(db)
n, err := repo.Create(parentID, typ, title)
if err != nil {
return err
}
fmt.Printf("created\t%s\t%s\t%s\n", n.ID, n.Type, n.Title)
return nil
}
func runNodeGet(vault, id string) error {
dbPath := filepath.Join(vault, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
return fmt.Errorf("open db: %v", err)
}
defer db.Close()
repo := nodes.NewRepository(db)
n, err := repo.GetActive(id)
if err != nil {
return err
}
fmt.Printf("id=%s\n", n.ID)
fmt.Printf("parent=%v\n", ptrStr(n.ParentID))
fmt.Printf("type=%s\n", n.Type)
fmt.Printf("title=%s\n", n.Title)
fmt.Printf("slug=%s\n", n.Slug)
fmt.Printf("revision=%d\n", n.Revision)
fmt.Printf("created=%s\n", n.CreatedAt)
fmt.Printf("updated=%s\n", n.UpdatedAt)
meta, _ := repo.MetaList(n.ID)
for _, m := range meta {
fmt.Printf("meta:%s=%s\n", m.Key, m.Value)
}
return nil
}
func runNodeList(vault, parentID string) error {
dbPath := filepath.Join(vault, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
return fmt.Errorf("open db: %v", err)
}
defer db.Close()
repo := nodes.NewRepository(db)
var list []nodes.Node
if parentID == "" {
list, err = repo.ListRoots(false)
} else {
list, err = repo.ListChildren(parentID, false)
}
if err != nil {
return err
}
for _, n := range list {
fmt.Printf("%s\t%s\t%s\n", n.ID, n.Type, n.Title)
}
return nil
}
func runNodeMove(vault, id, parentID string, sortOrder int) error {
dbPath := filepath.Join(vault, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
return fmt.Errorf("open db: %v", err)
}
defer db.Close()
repo := nodes.NewRepository(db)
if err := repo.Move(id, parentID, sortOrder); err != nil {
return err
}
fmt.Println("moved")
return nil
}
func runNodeDelete(vault, id string) error {
dbPath := filepath.Join(vault, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
return fmt.Errorf("open db: %v", err)
}
defer db.Close()
repo := nodes.NewRepository(db)
if err := repo.SoftDelete(id); err != nil {
return err
}
fmt.Println("deleted")
return nil
}
func nodeUsage() {
fmt.Println("verstak node — manage nodes in the vault")
fmt.Println()
fmt.Println("Usage: verstak node <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" create --type TYPE --title TITLE [--parent ID] Create a node")
fmt.Println(" list [--parent ID] List root or children")
fmt.Println(" get --id ID Show node details")
fmt.Println(" move --id ID [--parent ID] [--sort N] Move node")
fmt.Println(" delete --id ID Soft-delete node")
fmt.Println()
fmt.Println("Types: space, case, folder, note, document, file, action, recipe, secret, worklog, link")
}
func ptrStr(p *string) string {
if p == nil {
return ""
}
return *p
}