proto.go

  1package server
  2
  3import (
  4	"crypto/sha256"
  5	"encoding/hex"
  6	"encoding/json"
  7	"fmt"
  8	"log/slog"
  9	"net/http"
 10	"os"
 11	"path/filepath"
 12
 13	"github.com/charmbracelet/crush/internal/app"
 14	"github.com/charmbracelet/crush/internal/config"
 15	"github.com/charmbracelet/crush/internal/db"
 16	"github.com/charmbracelet/crush/internal/proto"
 17)
 18
 19func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
 20	jsonEncode(w, s.cfg)
 21}
 22
 23func (s *Server) handleGetInstances(w http.ResponseWriter, r *http.Request) {
 24	instances := []proto.Instance{}
 25	for _, ins := range s.instances.Seq2() {
 26		instances = append(instances, proto.Instance{
 27			ID:   ins.ID(),
 28			Path: ins.Path(),
 29			YOLO: ins.cfg.Permissions != nil && ins.cfg.Permissions.SkipRequests,
 30		})
 31	}
 32	jsonEncode(w, instances)
 33}
 34
 35func (s *Server) handleGetInstanceEvents(w http.ResponseWriter, r *http.Request) {
 36	flusher := http.NewResponseController(w)
 37	id := r.PathValue("id")
 38	ins, ok := s.instances.Get(id)
 39	if !ok {
 40		s.logError(r, "instance not found", "id", id)
 41		jsonError(w, http.StatusNotFound, "instance not found")
 42		return
 43	}
 44
 45	w.Header().Set("Content-Type", "text/event-stream")
 46	w.Header().Set("Cache-Control", "no-cache")
 47	w.Header().Set("Connection", "keep-alive")
 48
 49	for {
 50		select {
 51		case <-r.Context().Done():
 52			return
 53		case ev := <-ins.App.Events():
 54			data, err := json.Marshal(ev)
 55			if err != nil {
 56				s.logError(r, "failed to marshal event", "error", err)
 57				continue
 58			}
 59
 60			fmt.Fprintf(w, "data: %s\n\n", data)
 61			flusher.Flush()
 62		}
 63	}
 64}
 65
 66func (s *Server) handleDeleteInstances(w http.ResponseWriter, r *http.Request) {
 67	var ids []string
 68	id := r.URL.Query().Get("id")
 69	if id != "" {
 70		ids = append(ids, id)
 71	}
 72
 73	// Get IDs from body
 74	var args []proto.Instance
 75	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
 76		s.logError(r, "failed to decode request", "error", err)
 77		jsonError(w, http.StatusBadRequest, "failed to decode request")
 78		return
 79	}
 80	ids = append(ids, func() []string {
 81		out := make([]string, len(args))
 82		for i, arg := range args {
 83			out[i] = arg.ID
 84		}
 85		return out
 86	}()...)
 87
 88	for _, id := range ids {
 89		s.instances.Del(id)
 90	}
 91}
 92
 93func (s *Server) handlePostInstances(w http.ResponseWriter, r *http.Request) {
 94	var args proto.Instance
 95	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
 96		s.logError(r, "failed to decode request", "error", err)
 97		jsonError(w, http.StatusBadRequest, "failed to decode request")
 98		return
 99	}
100
101	ctx := r.Context()
102	hasher := sha256.New()
103	hasher.Write([]byte(filepath.Clean(args.Path)))
104	id := hex.EncodeToString(hasher.Sum(nil))
105	if existing, ok := s.instances.Get(id); ok {
106		jsonEncode(w, proto.Instance{
107			ID:   existing.ID(),
108			Path: existing.Path(),
109			YOLO: existing.cfg.Permissions != nil && existing.cfg.Permissions.SkipRequests,
110		})
111		return
112	}
113
114	cfg, err := config.Init(args.Path, s.cfg.Options.DataDirectory, s.cfg.Options.Debug)
115	if err != nil {
116		s.logError(r, "failed to initialize config", "error", err)
117		jsonError(w, http.StatusBadRequest, fmt.Sprintf("failed to initialize config: %v", err))
118		return
119	}
120
121	if cfg.Permissions == nil {
122		cfg.Permissions = &config.Permissions{}
123	}
124	cfg.Permissions.SkipRequests = args.YOLO
125
126	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
127		s.logError(r, "failed to create data directory", "error", err)
128		jsonError(w, http.StatusInternalServerError, "failed to create data directory")
129		return
130	}
131
132	// Connect to DB; this will also run migrations.
133	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
134	if err != nil {
135		s.logError(r, "failed to connect to database", "error", err)
136		jsonError(w, http.StatusInternalServerError, "failed to connect to database")
137		return
138	}
139
140	appInstance, err := app.New(ctx, conn, cfg)
141	if err != nil {
142		slog.Error("failed to create app instance", "error", err)
143		jsonError(w, http.StatusInternalServerError, "failed to create app instance")
144		return
145	}
146
147	ins := &Instance{
148		App:   appInstance,
149		State: InstanceStateCreated,
150		id:    id,
151		path:  args.Path,
152		cfg:   cfg,
153	}
154
155	s.instances.Set(id, ins)
156	jsonEncode(w, proto.Instance{
157		ID:   id,
158		Path: args.Path,
159		YOLO: cfg.Permissions.SkipRequests,
160	})
161}
162
163func createDotCrushDir(dir string) error {
164	if err := os.MkdirAll(dir, 0o700); err != nil {
165		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
166	}
167
168	gitIgnorePath := filepath.Join(dir, ".gitignore")
169	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
170		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
171			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
172		}
173	}
174
175	return nil
176}
177
178func jsonEncode(w http.ResponseWriter, v any) {
179	w.Header().Set("Content-Type", "application/json")
180	_ = json.NewEncoder(w).Encode(v)
181}
182
183func jsonError(w http.ResponseWriter, status int, message string) {
184	w.Header().Set("Content-Type", "application/json")
185	w.WriteHeader(status)
186	_ = json.NewEncoder(w).Encode(proto.Error{Message: message})
187}