304 lines
7.7 KiB
Go
304 lines
7.7 KiB
Go
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
|