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