step 7: actions — table, service, CLI, GUI tab + confirm dialog
- Migration 005: actions table (node_id, title, kind, command, args_json, working_dir, url, confirm_required, capture_output) - ActionService: Create, Get, ListByNode, Delete, Run Run dispatches: open_url/file/folder (xdg-open), run_command/script (exec.Command), open_terminal, launch_app - CLI: verstak action add/list/run/delete 'run' shows confirm prompt for confirm_required actions - GUI 'Действия' tab: button list with kind label, confirm_required opens editor overlay with action info + confirm, delete button - 7 unit tests for ActionService Acceptance: go build ./... pass, go test ./... pass.
This commit is contained in:
parent
9ee6df0d3f
commit
dae53fcbba
|
|
@ -5,7 +5,10 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"verstak/internal/core/actions"
|
||||||
|
"verstak/internal/core/storage"
|
||||||
"verstak/internal/core/vault"
|
"verstak/internal/core/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -26,6 +29,8 @@ func main() {
|
||||||
runInit(os.Args[2:])
|
runInit(os.Args[2:])
|
||||||
case "node":
|
case "node":
|
||||||
runNode(os.Args[2:])
|
runNode(os.Args[2:])
|
||||||
|
case "action":
|
||||||
|
runAction(os.Args[2:])
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
|
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1])
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
@ -40,6 +45,7 @@ func usage() {
|
||||||
fmt.Println("Commands:")
|
fmt.Println("Commands:")
|
||||||
fmt.Println(" init Initialize a new vault")
|
fmt.Println(" init Initialize a new vault")
|
||||||
fmt.Println(" node Manage nodes")
|
fmt.Println(" node Manage nodes")
|
||||||
|
fmt.Println(" action Manage actions")
|
||||||
fmt.Println(" --version Show version")
|
fmt.Println(" --version Show version")
|
||||||
fmt.Println(" --help Show this help")
|
fmt.Println(" --help Show this help")
|
||||||
}
|
}
|
||||||
|
|
@ -191,3 +197,201 @@ func vaultPathFromFlags(args []string) string {
|
||||||
}
|
}
|
||||||
return "."
|
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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"verstak/internal/core/storage"
|
||||||
|
"verstak/internal/core/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind constants.
|
||||||
|
const (
|
||||||
|
KindOpenURL = "open_url"
|
||||||
|
KindOpenFile = "open_file"
|
||||||
|
KindOpenFolder = "open_folder"
|
||||||
|
KindRunCommand = "run_command"
|
||||||
|
KindRunScript = "run_script"
|
||||||
|
KindOpenTerminal = "open_terminal"
|
||||||
|
KindLaunchApp = "launch_app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record represents an action attached to a node.
|
||||||
|
type Record struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
Args []string `json:"args,omitempty"`
|
||||||
|
WorkingDir string `json:"working_dir,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
ConfirmRequired bool `json:"confirm_required"`
|
||||||
|
CaptureOutput bool `json:"capture_output"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunResult is returned after executing an action.
|
||||||
|
type RunResult struct {
|
||||||
|
ExitCode int `json:"exit_code"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service manages actions.
|
||||||
|
type Service struct {
|
||||||
|
db *storage.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates an action service.
|
||||||
|
func NewService(db *storage.DB) *Service {
|
||||||
|
return &Service{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new action.
|
||||||
|
func (s *Service) Create(nodeID, kind, title, command, workingDir, url string, args []string, confirmRequired, captureOutput bool) (*Record, error) {
|
||||||
|
if nodeID == "" {
|
||||||
|
return nil, fmt.Errorf("node_id required")
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
return nil, fmt.Errorf("title required")
|
||||||
|
}
|
||||||
|
if !isValidKind(kind) {
|
||||||
|
return nil, fmt.Errorf("invalid kind: %s", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
argsJSON, _ := json.Marshal(args)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
rec := &Record{
|
||||||
|
ID: util.UUID7(),
|
||||||
|
NodeID: nodeID,
|
||||||
|
Title: title,
|
||||||
|
Kind: kind,
|
||||||
|
Command: command,
|
||||||
|
Args: args,
|
||||||
|
WorkingDir: workingDir,
|
||||||
|
URL: url,
|
||||||
|
ConfirmRequired: confirmRequired,
|
||||||
|
CaptureOutput: captureOutput,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO actions (id,node_id,title,kind,command,args_json,working_dir,url,
|
||||||
|
confirm_required,capture_output,created_at,updated_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
rec.ID, rec.NodeID, rec.Title, rec.Kind, rec.Command, string(argsJSON),
|
||||||
|
rec.WorkingDir, rec.URL, boolInt(rec.ConfirmRequired), boolInt(rec.CaptureOutput),
|
||||||
|
rec.CreatedAt.Format(time.RFC3339), rec.UpdatedAt.Format(time.RFC3339),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns an action by ID.
|
||||||
|
func (s *Service) Get(id string) (*Record, error) {
|
||||||
|
row := s.db.QueryRow(
|
||||||
|
`SELECT id,node_id,title,kind,command,args_json,working_dir,url,
|
||||||
|
confirm_required,capture_output,created_at,updated_at
|
||||||
|
FROM actions WHERE id = ?`, id)
|
||||||
|
return scanRecord(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByNode returns all actions for a node.
|
||||||
|
func (s *Service) ListByNode(nodeID string) ([]Record, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id,node_id,title,kind,command,args_json,working_dir,url,
|
||||||
|
confirm_required,capture_output,created_at,updated_at
|
||||||
|
FROM actions WHERE node_id = ? ORDER BY created_at`, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanRecords(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes an action.
|
||||||
|
func (s *Service) Delete(id string) error {
|
||||||
|
res, err := s.db.Exec("DELETE FROM actions WHERE id=?", id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("action not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the action. For dangerous actions (confirm_required),
|
||||||
|
// the caller should check ConfirmRequired before calling Run.
|
||||||
|
func (s *Service) Run(id string) (*RunResult, error) {
|
||||||
|
rec, err := s.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rec.Kind {
|
||||||
|
case KindOpenURL:
|
||||||
|
return &RunResult{}, openWithSystem(rec.URL)
|
||||||
|
case KindOpenFile:
|
||||||
|
return &RunResult{}, openWithSystem(rec.Command)
|
||||||
|
case KindOpenFolder:
|
||||||
|
return &RunResult{}, openWithSystem(rec.Command)
|
||||||
|
case KindRunCommand, KindRunScript:
|
||||||
|
return runCommand(rec.Command, rec.Args, rec.WorkingDir, rec.CaptureOutput)
|
||||||
|
case KindOpenTerminal:
|
||||||
|
return runCommand("xdg-open", []string{"terminal"}, rec.WorkingDir, false)
|
||||||
|
case KindLaunchApp:
|
||||||
|
return runCommand(rec.Command, rec.Args, rec.WorkingDir, false)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown action kind: %s", rec.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func isValidKind(k string) bool {
|
||||||
|
switch k {
|
||||||
|
case KindOpenURL, KindOpenFile, KindOpenFolder, KindRunCommand,
|
||||||
|
KindRunScript, KindOpenTerminal, KindLaunchApp:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func openWithSystem(path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
cmd = exec.Command("xdg-open", path)
|
||||||
|
case "darwin":
|
||||||
|
cmd = exec.Command("open", path)
|
||||||
|
case "windows":
|
||||||
|
cmd = exec.Command("cmd", "/c", "start", "", path)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported platform")
|
||||||
|
}
|
||||||
|
return cmd.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCommand(command string, args []string, workingDir string, capture bool) (*RunResult, error) {
|
||||||
|
if command == "" {
|
||||||
|
return nil, fmt.Errorf("empty command")
|
||||||
|
}
|
||||||
|
cmd := exec.Command(command, args...)
|
||||||
|
if workingDir != "" {
|
||||||
|
cmd.Dir = workingDir
|
||||||
|
}
|
||||||
|
if capture {
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
res := &RunResult{Output: string(out)}
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
res.ExitCode = exitErr.ExitCode()
|
||||||
|
}
|
||||||
|
res.Error = err.Error()
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
err := cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return &RunResult{Error: err.Error()}, nil
|
||||||
|
}
|
||||||
|
return &RunResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRecord(s scanner) (*Record, error) {
|
||||||
|
var r Record
|
||||||
|
var argsJSON sql.NullString
|
||||||
|
var command, workingDir, url sql.NullString
|
||||||
|
var createdStr, updatedStr string
|
||||||
|
var confirmInt, captureInt int
|
||||||
|
|
||||||
|
err := s.Scan(
|
||||||
|
&r.ID, &r.NodeID, &r.Title, &r.Kind, &command, &argsJSON,
|
||||||
|
&workingDir, &url, &confirmInt, &captureInt,
|
||||||
|
&createdStr, &updatedStr,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("action not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if command.Valid {
|
||||||
|
r.Command = command.String
|
||||||
|
}
|
||||||
|
if workingDir.Valid {
|
||||||
|
r.WorkingDir = workingDir.String
|
||||||
|
}
|
||||||
|
if url.Valid {
|
||||||
|
r.URL = url.String
|
||||||
|
}
|
||||||
|
if argsJSON.Valid && argsJSON.String != "" {
|
||||||
|
_ = json.Unmarshal([]byte(argsJSON.String), &r.Args)
|
||||||
|
}
|
||||||
|
r.ConfirmRequired = confirmInt == 1
|
||||||
|
r.CaptureOutput = captureInt == 1
|
||||||
|
r.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||||
|
r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRecords(rows *sql.Rows) ([]Record, error) {
|
||||||
|
var out []Record
|
||||||
|
for rows.Next() {
|
||||||
|
r, err := scanRecord(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// KindLabel returns a human-readable label for a kind.
|
||||||
|
func KindLabel(kind string) string {
|
||||||
|
m := map[string]string{
|
||||||
|
KindOpenURL: "Открыть URL",
|
||||||
|
KindOpenFile: "Открыть файл",
|
||||||
|
KindOpenFolder: "Открыть папку",
|
||||||
|
KindRunCommand: "Запустить команду",
|
||||||
|
KindRunScript: "Запустить скрипт",
|
||||||
|
KindOpenTerminal: "Открыть терминал",
|
||||||
|
KindLaunchApp: "Запустить приложение",
|
||||||
|
}
|
||||||
|
if l, ok := m[kind]; ok {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidKinds returns all valid kind constants.
|
||||||
|
func ValidKinds() []string {
|
||||||
|
return []string{KindOpenURL, KindOpenFile, KindOpenFolder, KindRunCommand, KindRunScript, KindOpenTerminal, KindLaunchApp}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silence unused import.
|
||||||
|
var _ = strings.ToLower
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"verstak/internal/core/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openTestDB(t *testing.T) *storage.DB {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAndGet(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
svc := NewService(db)
|
||||||
|
|
||||||
|
rec, err := svc.Create("node-1", KindOpenURL, "Открыть сайт", "", "", "https://example.com", nil, false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
if rec.ID == "" {
|
||||||
|
t.Fatal("empty id")
|
||||||
|
}
|
||||||
|
if rec.Kind != KindOpenURL {
|
||||||
|
t.Errorf("kind = %q", rec.Kind)
|
||||||
|
}
|
||||||
|
if rec.URL != "https://example.com" {
|
||||||
|
t.Errorf("url = %q", rec.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := svc.Get(rec.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Title != "Открыть сайт" {
|
||||||
|
t.Errorf("title = %q", got.Title)
|
||||||
|
}
|
||||||
|
if !got.ConfirmRequired {
|
||||||
|
// default is false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateWithConfirm(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
svc := NewService(db)
|
||||||
|
|
||||||
|
rec, err := svc.Create("node-1", KindRunCommand, "Backup", "./backup.sh", "/tmp", "", nil, true, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !rec.ConfirmRequired {
|
||||||
|
t.Error("expected confirm_required")
|
||||||
|
}
|
||||||
|
if !rec.CaptureOutput {
|
||||||
|
t.Error("expected capture_output")
|
||||||
|
}
|
||||||
|
if rec.Command != "./backup.sh" {
|
||||||
|
t.Errorf("command = %q", rec.Command)
|
||||||
|
}
|
||||||
|
if rec.WorkingDir != "/tmp" {
|
||||||
|
t.Errorf("working_dir = %q", rec.WorkingDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListByNode(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
svc := NewService(db)
|
||||||
|
|
||||||
|
svc.Create("node-1", KindOpenURL, "A1", "", "", "https://a.com", nil, false, false)
|
||||||
|
svc.Create("node-1", KindOpenURL, "A2", "", "", "https://b.com", nil, false, false)
|
||||||
|
svc.Create("node-2", KindOpenURL, "B1", "", "", "https://c.com", nil, false, false)
|
||||||
|
|
||||||
|
list1, err := svc.ListByNode("node-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list1) != 2 {
|
||||||
|
t.Errorf("node-1 actions = %d, want 2", len(list1))
|
||||||
|
}
|
||||||
|
|
||||||
|
list2, err := svc.ListByNode("node-2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list2) != 1 {
|
||||||
|
t.Errorf("node-2 actions = %d, want 1", len(list2))
|
||||||
|
}
|
||||||
|
|
||||||
|
list3, err := svc.ListByNode("node-3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list3) != 0 {
|
||||||
|
t.Errorf("node-3 actions = %d, want 0", len(list3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
svc := NewService(db)
|
||||||
|
|
||||||
|
rec, _ := svc.Create("node-1", KindOpenURL, "ToDelete", "", "", "https://x.com", nil, false, false)
|
||||||
|
|
||||||
|
if err := svc.Delete(rec.ID); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.Get(rec.ID); err == nil {
|
||||||
|
t.Error("expected error after delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNotFound(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
svc := NewService(db)
|
||||||
|
|
||||||
|
if _, err := svc.Get("nonexistent"); err == nil {
|
||||||
|
t.Error("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKindLabel(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
KindOpenURL: "Открыть URL",
|
||||||
|
KindRunCommand: "Запустить команду",
|
||||||
|
KindOpenFolder: "Открыть папку",
|
||||||
|
"unknown": "unknown",
|
||||||
|
}
|
||||||
|
for kind, want := range cases {
|
||||||
|
got := KindLabel(kind)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("KindLabel(%q) = %q, want %q", kind, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidKinds(t *testing.T) {
|
||||||
|
kinds := ValidKinds()
|
||||||
|
if len(kinds) != 7 {
|
||||||
|
t.Errorf("valid kinds = %d, want 7", len(kinds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silence unused import.
|
||||||
|
var _ = os.Args
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
// migration005 — actions table for runnable actions attached to nodes.
|
||||||
|
const migration005 = `
|
||||||
|
CREATE TABLE IF NOT EXISTS actions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
node_id TEXT NOT NULL REFERENCES nodes(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
command TEXT NULL,
|
||||||
|
args_json TEXT NULL,
|
||||||
|
working_dir TEXT NULL,
|
||||||
|
url TEXT NULL,
|
||||||
|
confirm_required INTEGER NOT NULL DEFAULT 0,
|
||||||
|
capture_output INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_actions_node ON actions(node_id);
|
||||||
|
`
|
||||||
|
|
@ -61,7 +61,8 @@ var migrationFiles = map[int]string{
|
||||||
2: migration002,
|
2: migration002,
|
||||||
3: migration003,
|
3: migration003,
|
||||||
4: migration004,
|
4: migration004,
|
||||||
// 5: migration005, etc.
|
5: migration005,
|
||||||
|
// 6: migration006, etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|
|
||||||
|
|
@ -526,7 +526,7 @@ function switchTabNode(t){
|
||||||
else api('/api/nodes/'+id).then(d=>{nodeCache[id]={detail:d,ts:Date.now()};renderNodeDash(d)});
|
else api('/api/nodes/'+id).then(d=>{nodeCache[id]={detail:d,ts:Date.now()};renderNodeDash(d)});
|
||||||
}else if(t==='notes') loadNodeNotes(id);
|
}else if(t==='notes') loadNodeNotes(id);
|
||||||
else if(t==='files') loadNodeFiles(id);
|
else if(t==='files') loadNodeFiles(id);
|
||||||
else if(t==='actions') setCnt('<div class="empty" style="margin-top:60px">Действия — в разработке</div>');
|
else if(t==='actions') loadNodeActions(id);
|
||||||
else if(t==='worklog') setCnt('<div class="empty" style="margin-top:60px">Журнал — в разработке</div>');
|
else if(t==='worklog') setCnt('<div class="empty" style="margin-top:60px">Журнал — в разработке</div>');
|
||||||
else if(t==='activity') setCnt('<div class="empty" style="margin-top:60px">Активность — в разработке</div>');
|
else if(t==='activity') setCnt('<div class="empty" style="margin-top:60px">Активность — в разработке</div>');
|
||||||
}
|
}
|
||||||
|
|
@ -563,6 +563,48 @@ async function loadNodeFiles(nodeId){
|
||||||
|
|
||||||
function E(msg){setCnt('<div class="empty" style="margin-top:60px">'+esc(msg)+'</div>')}
|
function E(msg){setCnt('<div class="empty" style="margin-top:60px">'+esc(msg)+'</div>')}
|
||||||
|
|
||||||
|
async function loadNodeActions(nodeId){
|
||||||
|
try{
|
||||||
|
const list=await api('/api/actions?node='+nodeId);
|
||||||
|
if(!list.length){setCnt('<div class="empty" style="margin-top:60px">Нет действий. <button class="btn primary" style="margin-top:8px" onclick="openM(\'m-action\')">+ Добавить</button></div>');return}
|
||||||
|
let h='<div style="display:flex;flex-direction:column;gap:10px">';
|
||||||
|
for(const a of list){
|
||||||
|
h+='<div style="display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px 16px">';
|
||||||
|
h+='<button class="btn primary" style="min-width:120px;justify-content:center" onclick="runActionConfirm(\''+a.id+'\',\''+esc(a.title)+'\',\''+a.kind+'\','+a.confirm_required+')">'+esc(a.title)+'</button>';
|
||||||
|
h+='<span style="font-size:12px;color:var(--text3);flex:1">'+esc(a.kind||'')+'</span>';
|
||||||
|
h+='<button class="btn" onclick="delAction(\''+a.id+'\')" title="Удалить" style="padding:4px 10px">✕</button>';
|
||||||
|
h+='</div>';
|
||||||
|
}
|
||||||
|
h+='</div>';setCnt(h);
|
||||||
|
}catch(e){E('Ошибка')}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AL={open_url:'URL',open_file:'Файл',open_folder:'Папка',run_command:'Команда',run_script:'Скрипт',open_terminal:'Терминал',launch_app:'Приложение'};
|
||||||
|
function runActionConfirm(id,title,kind,confirm){
|
||||||
|
if(!confirm){runActionExec(id);return}
|
||||||
|
const lbl=AL[kind]||kind;
|
||||||
|
G('ed-crumb').textContent='Действие: '+title;
|
||||||
|
G('ed-title').textContent='Подтверждение';
|
||||||
|
G('ed-ta').value='Тип: '+lbl+'\n\nВыполнить действие «'+title+'»?';
|
||||||
|
G('ed').style.display='flex';
|
||||||
|
editId='__action__'+id;
|
||||||
|
}
|
||||||
|
async function runActionExec(id){
|
||||||
|
if(id.startsWith('__action__'))id=id.slice(10);
|
||||||
|
try{
|
||||||
|
const r=await api('/api/actions/'+id,{method:'POST',body:'{}'});
|
||||||
|
closeED();
|
||||||
|
if(r&&r.output)alert(r.output);
|
||||||
|
}catch(e){alert('Ошибка: '+e.message)}
|
||||||
|
}
|
||||||
|
async function delAction(id){
|
||||||
|
if(!confirm('Удалить действие?'))return;
|
||||||
|
try{
|
||||||
|
await api('/api/actions/'+id,{method:'DELETE'});
|
||||||
|
if(sel.kind==='node')loadNodeActions(sel.nodeId);
|
||||||
|
}catch(e){alert('Ошибка: '+e.message)}
|
||||||
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════
|
/* ════════════════════════════════════════════
|
||||||
EDITOR
|
EDITOR
|
||||||
════════════════════════════════════════════ */
|
════════════════════════════════════════════ */
|
||||||
|
|
@ -571,7 +613,16 @@ async function openNT(id){editId=id;
|
||||||
catch(e){alert('Ошибка: '+e.message)}
|
catch(e){alert('Ошибка: '+e.message)}
|
||||||
}
|
}
|
||||||
function closeED(){G('ed').style.display='none';editId=''}
|
function closeED(){G('ed').style.display='none';editId=''}
|
||||||
async function saveNT(){if(!editId)return;try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:G('ed-ta').value})});closeED();}catch(e){alert('Ошибка: '+e.message)}}
|
async function saveNT(){
|
||||||
|
if(!editId)return;
|
||||||
|
if(editId.startsWith('__action__')){
|
||||||
|
G('ed').style.display='none';
|
||||||
|
await runActionExec(editId);
|
||||||
|
editId='';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:G('ed-ta').value})});closeED();}catch(e){alert('Ошибка: '+e.message)}
|
||||||
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════
|
/* ════════════════════════════════════════════
|
||||||
MODALS
|
MODALS
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"verstak/internal/core/actions"
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
"verstak/internal/core/nodes"
|
"verstak/internal/core/nodes"
|
||||||
|
|
@ -22,6 +23,7 @@ type Server struct {
|
||||||
nodes *nodes.Repository
|
nodes *nodes.Repository
|
||||||
files *files.Service
|
files *files.Service
|
||||||
notes *notes.Service
|
notes *notes.Service
|
||||||
|
actions *actions.Service
|
||||||
srv *http.Server
|
srv *http.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
port int
|
port int
|
||||||
|
|
@ -32,9 +34,10 @@ func NewServer(db *storage.DB, vaultRoot string) *Server {
|
||||||
nodeRepo := nodes.NewRepository(db)
|
nodeRepo := nodes.NewRepository(db)
|
||||||
fileSvc := files.NewService(db, vaultRoot)
|
fileSvc := files.NewService(db, vaultRoot)
|
||||||
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
||||||
|
actionSvc := actions.NewService(db)
|
||||||
return &Server{
|
return &Server{
|
||||||
db: db, vaultRoot: vaultRoot,
|
db: db, vaultRoot: vaultRoot,
|
||||||
nodes: nodeRepo, files: fileSvc, notes: noteSvc,
|
nodes: nodeRepo, files: fileSvc, notes: noteSvc, actions: actionSvc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,6 +48,7 @@ func (s *Server) Start() (string, error) {
|
||||||
mux.HandleFunc("/api/nodes/", s.handleNodeDetail)
|
mux.HandleFunc("/api/nodes/", s.handleNodeDetail)
|
||||||
mux.HandleFunc("/api/notes/", s.handleNotes)
|
mux.HandleFunc("/api/notes/", s.handleNotes)
|
||||||
mux.HandleFunc("/api/files/", s.handleFiles)
|
mux.HandleFunc("/api/files/", s.handleFiles)
|
||||||
|
mux.HandleFunc("/api/actions/", s.handleActions)
|
||||||
mux.HandleFunc("/api/search", s.handleSearch)
|
mux.HandleFunc("/api/search", s.handleSearch)
|
||||||
mux.HandleFunc("/", s.handleStatic)
|
mux.HandleFunc("/", s.handleStatic)
|
||||||
|
|
||||||
|
|
@ -236,6 +240,65 @@ func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET/POST/DELETE /api/actions/{id} GET /api/actions?node=ID
|
||||||
|
func (s *Server) handleActions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/actions/")
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
if path != "" {
|
||||||
|
rec, err := s.actions.Get(path)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 404, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, rec)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodeID := r.URL.Query().Get("node")
|
||||||
|
if nodeID == "" {
|
||||||
|
jsonOK(w, []interface{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := s.actions.ListByNode(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, list)
|
||||||
|
case "POST":
|
||||||
|
var req struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
WorkingDir string `json:"working_dir"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
Capture bool `json:"capture"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
rec, err := s.actions.Create(req.NodeID, req.Kind, req.Title, req.Command, req.WorkingDir, req.URL, req.Args, req.Confirm, req.Capture)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, rec)
|
||||||
|
case "DELETE":
|
||||||
|
if path == "" {
|
||||||
|
jsonErr(w, 400, "id required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.actions.Delete(path); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]string{"status": "deleted"})
|
||||||
|
default:
|
||||||
|
jsonErr(w, 405, "method not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/search?q=...
|
// GET /api/search?q=...
|
||||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
q := strings.ToLower(r.URL.Query().Get("q"))
|
q := strings.ToLower(r.URL.Query().Get("q"))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue