From 436732dac0fd8490b8e12597c591a35d6be43551 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Thu, 17 Sep 2026 10:25:35 -0700 Subject: [PATCH] MVP: Go daemon, password auth, persistent PTY terminals, React/xterm UI, PWA Co-Authored-By: Claude Fable 5.1 --- .gitignore | 4 + Makefile | 26 + cmd/hamlet/main.go | 160 +++ deploy/hamlet.service | 18 + deploy/install.sh | 11 + go.mod | 12 + go.sum | 10 + internal/api/server.go | 326 +++++ internal/auth/auth.go | 160 +++ internal/config/config.go | 55 + internal/store/store.go | 71 + internal/term/manager.go | 287 ++++ internal/term/ring.go | 37 + internal/web/embed.go | 18 + web/index.html | 20 + web/package-lock.json | 1777 ++++++++++++++++++++++++ web/package.json | 25 + web/public/icons/apple-touch-icon.png | Bin 0 -> 1740 bytes web/public/icons/icon-192.png | Bin 0 -> 1725 bytes web/public/icons/icon-512-maskable.png | Bin 0 -> 3930 bytes web/public/icons/icon-512.png | Bin 0 -> 4463 bytes web/public/manifest.webmanifest | 16 + web/public/sw.js | 17 + web/scripts/icons.mjs | 26 + web/src/App.tsx | 23 + web/src/KeyBar.tsx | 18 + web/src/Login.tsx | 28 + web/src/Rail.tsx | 49 + web/src/Shell.tsx | 100 ++ web/src/TerminalPane.tsx | 128 ++ web/src/api.ts | 36 + web/src/main.tsx | 15 + web/src/styles.css | 78 ++ web/tsconfig.json | 15 + web/vite.config.ts | 10 + 35 files changed, 3576 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/hamlet/main.go create mode 100644 deploy/hamlet.service create mode 100755 deploy/install.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/server.go create mode 100644 internal/auth/auth.go create mode 100644 internal/config/config.go create mode 100644 internal/store/store.go create mode 100644 internal/term/manager.go create mode 100644 internal/term/ring.go create mode 100644 internal/web/embed.go create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/icons/apple-touch-icon.png create mode 100644 web/public/icons/icon-192.png create mode 100644 web/public/icons/icon-512-maskable.png create mode 100644 web/public/icons/icon-512.png create mode 100644 web/public/manifest.webmanifest create mode 100644 web/public/sw.js create mode 100644 web/scripts/icons.mjs create mode 100644 web/src/App.tsx create mode 100644 web/src/KeyBar.tsx create mode 100644 web/src/Login.tsx create mode 100644 web/src/Rail.tsx create mode 100644 web/src/Shell.tsx create mode 100644 web/src/TerminalPane.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/styles.css create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23d18e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/ +internal/web/dist/ +web/node_modules/ +web/dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1f19e1b --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +GO ?= go +PREFIX ?= /usr/local/bin + +.PHONY: all web build dev deploy clean + +all: build + +web: + cd web && npm ci --no-audit --no-fund && npm run build + +build: web + $(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlet ./cmd/hamlet + +# backend only (assumes web/dist already built) +go: + $(GO) build -ldflags "-X main.version=$(VERSION)" -o bin/hamlet ./cmd/hamlet + +dev: + cd web && npm run dev + +deploy: build + ./deploy/install.sh + +clean: + rm -rf bin internal/web/dist web/dist diff --git a/cmd/hamlet/main.go b/cmd/hamlet/main.go new file mode 100644 index 0000000..e6359d1 --- /dev/null +++ b/cmd/hamlet/main.go @@ -0,0 +1,160 @@ +// Hamlet — Proxmox-style web console for a bare-metal Debian host. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "os/user" + "path/filepath" + "syscall" + "time" + + "golang.org/x/term" + + "hamlet/internal/api" + "hamlet/internal/auth" + "hamlet/internal/config" + "hamlet/internal/store" + hterm "hamlet/internal/term" + "hamlet/internal/web" +) + +var version = "dev" + +func main() { + defaultCfg := os.Getenv("HAMLET_CONFIG") + if defaultCfg == "" { + home, _ := os.UserHomeDir() + defaultCfg = filepath.Join(home, "apps", "hamlet", "config.json") + } + cfgPath := flag.String("config", defaultCfg, "path to config.json") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "usage: hamlet [-config path] [serve|passwd|version]\n") + flag.PrintDefaults() + } + flag.Parse() + + cmd := "serve" + if flag.NArg() > 0 { + cmd = flag.Arg(0) + } + cfg, err := config.Load(*cfgPath) + if err != nil { + log.Fatal(err) + } + switch cmd { + case "version": + fmt.Println("hamlet", version) + case "passwd": + if err := passwd(cfg); err != nil { + log.Fatal(err) + } + case "serve": + if err := serve(cfg); err != nil { + log.Fatal(err) + } + default: + flag.Usage() + os.Exit(2) + } +} + +func passwd(cfg *config.Config) error { + fmt.Fprint(os.Stderr, "New password: ") + p1, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return err + } + fmt.Fprint(os.Stderr, "Again: ") + p2, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return err + } + if string(p1) != string(p2) { + return errors.New("passwords do not match") + } + if len(p1) < 12 { + return errors.New("password must be at least 12 characters") + } + h, err := auth.HashPassword(string(p1)) + if err != nil { + return err + } + cfg.PasswordHash = h + if err := cfg.Save(); err != nil { + return err + } + fmt.Fprintln(os.Stderr, "password set in", cfg.Path()) + return nil +} + +func serve(cfg *config.Config) error { + if cfg.PasswordHash == "" { + return fmt.Errorf("no password set — run: hamlet -config %s passwd", cfg.Path()) + } + if err := os.MkdirAll(cfg.DataDir(), 0o700); err != nil { + return err + } + st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json")) + if err != nil { + return err + } + sessions := auth.NewSessions(st) + + u, err := user.Current() + if err != nil { + return err + } + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/bash" + } + mgr := hterm.NewManager(shell, u.HomeDir) + mgr.OnChange = func(list []hterm.Info) { + _ = st.Update(func(s *store.State) { + s.Terminals = s.Terminals[:0] + for _, t := range list { + s.Terminals = append(s.Terminals, store.Terminal{ID: t.ID, Name: t.Name}) + } + }) + } + // Re-open the shells that existed before the last restart (fresh processes, + // same names/order) so the sidebar comes back as it was. + var saved []store.Terminal + st.View(func(s *store.State) { saved = append(saved, s.Terminals...) }) + for _, t := range saved { + if _, err := mgr.Create(t.ID, t.Name); err != nil { + log.Printf("restore terminal %q: %v", t.Name, err) + } + } + + srv := api.New(cfg, sessions, mgr, web.FS(), version) + hs := &http.Server{ + Addr: cfg.Listen, + Handler: srv.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + log.Print("shutting down") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = hs.Shutdown(ctx) + mgr.Shutdown() + }() + log.Printf("hamlet %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell) + if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/deploy/hamlet.service b/deploy/hamlet.service new file mode 100644 index 0000000..069cfff --- /dev/null +++ b/deploy/hamlet.service @@ -0,0 +1,18 @@ +[Unit] +Description=Hamlet web console +After=network.target + +[Service] +Type=simple +User=core +Group=core +WorkingDirectory=/home/core +Environment=HAMLET_CONFIG=/home/core/apps/hamlet/config.json +ExecStart=/usr/local/bin/hamlet serve +Restart=always +RestartSec=3 +KillMode=control-group +TimeoutStopSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..575edb8 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Install the built binary and unit on this host, then (re)start the service. +set -eu +cd "$(dirname "$0")/.." +sudo install -m 0755 bin/hamlet /usr/local/bin/hamlet +sudo install -m 0644 deploy/hamlet.service /etc/systemd/system/hamlet.service +sudo systemctl daemon-reload +sudo systemctl enable --now hamlet.service >/dev/null +sudo systemctl restart hamlet.service +sleep 1 +systemctl is-active hamlet.service && /usr/local/bin/hamlet version diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26fb4e9 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module hamlet + +go 1.26.0 + +require ( + github.com/coder/websocket v1.8.15 + github.com/creack/pty v1.1.24 + golang.org/x/crypto v0.57.0 + golang.org/x/term v0.46.0 +) + +require golang.org/x/sys v0.48.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f1c4538 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..cd21fa6 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,326 @@ +// Package api is the HTTP/WebSocket surface: auth, terminal CRUD, attach, static UI. +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "io/fs" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/coder/websocket" + + "hamlet/internal/auth" + "hamlet/internal/config" + "hamlet/internal/term" +) + +const cookieName = "hamlet_session" + +type Server struct { + cfg *config.Config + sessions *auth.Sessions + limiter auth.Limiter + mgr *term.Manager + version string + started time.Time + user string + host string + ui fs.FS + mux *http.ServeMux +} + +func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, ui fs.FS, version string) *Server { + host, _ := os.Hostname() + s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, ui: ui, version: version, + started: time.Now(), host: host, user: os.Getenv("USER")} + if s.user == "" { + s.user = "core" + } + m := http.NewServeMux() + m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }) + m.HandleFunc("POST /api/login", s.login) + m.HandleFunc("POST /api/logout", s.auth(s.logout)) + m.HandleFunc("GET /api/me", s.auth(s.me)) + m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals)) + m.HandleFunc("POST /api/terminals", s.auth(s.createTerminal)) + m.HandleFunc("PATCH /api/terminals/{id}", s.auth(s.renameTerminal)) + m.HandleFunc("DELETE /api/terminals/{id}", s.auth(s.deleteTerminal)) + m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach)) + m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") }) + m.HandleFunc("/", s.static) + s.mux = m + return s +} + +func (s *Server) Handler() http.Handler { return s.mux } + +// ---- helpers ---- + +func jsonOK(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func jsonErr(w http.ResponseWriter, code int, msg string) { + jsonOK(w, code, map[string]string{"error": msg}) +} + +func secure(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, maxAge int) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, Value: tok, Path: "/", MaxAge: maxAge, + HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteStrictMode, + }) +} + +func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(cookieName) + if err != nil || !s.sessions.Valid(c.Value) { + jsonErr(w, 401, "unauthenticated") + return + } + next(w, r) + } +} + +// ---- auth ---- + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + if err := s.limiter.Allow(); err != nil { + jsonErr(w, 429, "too many attempts, try again later") + return + } + var body struct { + Password string `json:"password"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { + jsonErr(w, 400, "bad request") + return + } + if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) { + s.limiter.Fail() + log.Printf("login failed from %s", clientIP(r)) + jsonErr(w, 401, "wrong password") + return + } + s.limiter.Reset() + tok, err := s.sessions.Create() + if err != nil { + jsonErr(w, 500, "session error") + return + } + s.setCookie(w, r, tok, int(auth.SessionTTL.Seconds())) + log.Printf("login ok from %s", clientIP(r)) + w.WriteHeader(204) +} + +func (s *Server) logout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(cookieName); err == nil { + s.sessions.Delete(c.Value) + } + s.setCookie(w, r, "", -1) + w.WriteHeader(204) +} + +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr + } + return r.RemoteAddr +} + +// ---- me ---- + +func (s *Server) me(w http.ResponseWriter, _ *http.Request) { + load := "" + if b, err := os.ReadFile("/proc/loadavg"); err == nil { + f := strings.Fields(string(b)) + if len(f) >= 3 { + load = strings.Join(f[:3], " ") + } + } + uptime := 0.0 + if b, err := os.ReadFile("/proc/uptime"); err == nil { + if f := strings.Fields(string(b)); len(f) > 0 { + uptime, _ = json.Number(f[0]).Float64() + } + } + jsonOK(w, 200, map[string]any{ + "host": s.host, "user": s.user, "version": s.version, + "load": load, "uptime": uptime, "service_started": s.started, + }) +} + +// ---- terminals ---- + +func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) { + jsonOK(w, 200, s.mgr.List()) +} + +func (s *Server) createTerminal(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + } + _ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body) + name := strings.TrimSpace(body.Name) + if name == "" { + name = "shell " + time.Now().Format("15:04") + } + sess, err := s.mgr.Create("", name) + if err != nil { + log.Printf("create terminal: %v", err) + jsonErr(w, 500, err.Error()) + return + } + jsonOK(w, 201, map[string]string{"id": sess.ID, "name": sess.Name}) +} + +func (s *Server) renameTerminal(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" { + jsonErr(w, 400, "name required") + return + } + if err := s.mgr.Rename(r.PathValue("id"), strings.TrimSpace(body.Name)); err != nil { + jsonErr(w, 404, err.Error()) + return + } + w.WriteHeader(204) +} + +func (s *Server) deleteTerminal(w http.ResponseWriter, r *http.Request) { + if err := s.mgr.Close(r.PathValue("id")); err != nil { + jsonErr(w, 404, err.Error()) + return + } + w.WriteHeader(204) +} + +type resizeMsg struct { + Type string `json:"type"` + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` +} + +func (s *Server) attach(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + client, snapshot, running, err := s.mgr.Attach(id) + if err != nil { + jsonErr(w, 404, err.Error()) + return + } + // Origin must match Host (coder/websocket default) — blocks cross-site attaches. + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled}) + if err != nil { + s.mgr.Detach(client) + return + } + defer s.mgr.Detach(client) + defer conn.Close(websocket.StatusNormalClosure, "") + conn.SetReadLimit(1 << 20) + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + if len(snapshot) > 0 { + if err := conn.Write(ctx, websocket.MessageBinary, snapshot); err != nil { + return + } + } + if !running { + _ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"exit"}`)) + } + + // writer: PTY → socket, plus keepalive pings so idle proxies don't cut us. + go func() { + defer cancel() + ping := time.NewTicker(25 * time.Second) + defer ping.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ping.C: + pctx, pc := context.WithTimeout(ctx, 10*time.Second) + err := conn.Ping(pctx) + pc() + if err != nil { + return + } + case msg, ok := <-client.Ch: + if !ok { + _ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"closed"}`)) + return + } + if msg.Data != nil { + if err := conn.Write(ctx, websocket.MessageBinary, msg.Data); err != nil { + return + } + continue + } + if msg.Event == "exit" { + b, _ := json.Marshal(map[string]any{"type": "exit", "code": msg.Code}) + _ = conn.Write(ctx, websocket.MessageText, b) + } + } + } + }() + + // reader: socket → PTY (binary) or control (text JSON). + for { + typ, data, err := conn.Read(ctx) + if err != nil { + return + } + switch typ { + case websocket.MessageBinary: + if err := s.mgr.Write(id, data); err != nil && !errors.Is(err, term.ErrNotFound) { + log.Printf("pty write %s: %v", id, err) + } + case websocket.MessageText: + var m resizeMsg + if json.Unmarshal(data, &m) == nil && m.Type == "resize" { + _ = s.mgr.Resize(id, m.Cols, m.Rows) + } + } + } +} + +// ---- static UI with SPA fallback ---- + +func (s *Server) static(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/") + if p == "" { + p = "index.html" + } + f, err := s.ui.Open(p) + if err == nil { + st, _ := f.Stat() + f.Close() + if st != nil && !st.IsDir() { + if strings.HasPrefix(p, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache") + } + http.ServeFileFS(w, r, s.ui, p) + return + } + } + // SPA fallback + w.Header().Set("Cache-Control", "no-cache") + http.ServeFileFS(w, r, s.ui, "index.html") +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..316b907 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,160 @@ +// Package auth: argon2id password hashing, cookie sessions, login rate limiting. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + "time" + + "golang.org/x/crypto/argon2" + + "hamlet/internal/store" +) + +const ( + argonTime = 3 + argonMemory = 64 * 1024 + argonThreads = 2 + argonKeyLen = 32 + + SessionTTL = 30 * 24 * time.Hour +) + +func HashPassword(pw string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + enc := base64.RawStdEncoding.EncodeToString + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + argonMemory, argonTime, argonThreads, enc(salt), enc(key)), nil +} + +func VerifyPassword(hash, pw string) bool { + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + var m, t uint32 + var p uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// Sessions is a token store backed by the state file. Expiry is sliding. +type Sessions struct { + st *store.Store +} + +func NewSessions(st *store.Store) *Sessions { + s := &Sessions{st: st} + _ = st.Update(func(state *store.State) { pruneExpired(state) }) + return s +} + +func pruneExpired(state *store.State) { + now := time.Now() + for tok, exp := range state.Sessions { + if exp.Before(now) { + delete(state.Sessions, tok) + } + } +} + +func (s *Sessions) Create() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + tok := hex.EncodeToString(b) + err := s.st.Update(func(state *store.State) { + pruneExpired(state) + state.Sessions[tok] = time.Now().Add(SessionTTL) + }) + return tok, err +} + +// Valid reports whether tok is live, sliding its expiry (persisted at most hourly). +func (s *Sessions) Valid(tok string) bool { + if tok == "" { + return false + } + ok := false + extend := false + s.st.View(func(state *store.State) { + exp, found := state.Sessions[tok] + if found && exp.After(time.Now()) { + ok = true + extend = time.Until(exp) < SessionTTL-time.Hour + } + }) + if ok && extend { + _ = s.st.Update(func(state *store.State) { + if _, found := state.Sessions[tok]; found { + state.Sessions[tok] = time.Now().Add(SessionTTL) + } + }) + } + return ok +} + +func (s *Sessions) Delete(tok string) { + _ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) }) +} + +// Limiter is a single global backoff: after 5 failures, each further failure +// doubles a lockout starting at 30s, capped at 10 minutes. Single-user box. +type Limiter struct { + mu sync.Mutex + failures int + lockUntil time.Time +} + +var ErrLocked = errors.New("too many failed logins") + +func (l *Limiter) Allow() error { + l.mu.Lock() + defer l.mu.Unlock() + if time.Now().Before(l.lockUntil) { + return ErrLocked + } + return nil +} + +func (l *Limiter) Fail() { + l.mu.Lock() + defer l.mu.Unlock() + l.failures++ + if l.failures >= 5 { + d := 30 * time.Second << uint(l.failures-5) + if d > 10*time.Minute { + d = 10 * time.Minute + } + l.lockUntil = time.Now().Add(d) + } +} + +func (l *Limiter) Reset() { + l.mu.Lock() + defer l.mu.Unlock() + l.failures = 0 + l.lockUntil = time.Time{} +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3a72f21 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,55 @@ +// Package config loads and saves hamlet's JSON config file. +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +type Config struct { + Listen string `json:"listen"` + PasswordHash string `json:"password_hash"` + + path string +} + +func Default(path string) *Config { + return &Config{Listen: "127.0.0.1:8100", path: path} +} + +// Load reads the config at path. A missing file yields the defaults. +func Load(path string) (*Config, error) { + c := Default(path) + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return c, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(b, c); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return c, nil +} + +func (c *Config) Path() string { return c.path } +func (c *Config) DataDir() string { return filepath.Dir(c.path) } + +func (c *Config) Save() error { + if err := os.MkdirAll(c.DataDir(), 0o700); err != nil { + return err + } + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + tmp := c.path + ".tmp" + if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { + return err + } + return os.Rename(tmp, c.path) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..c905722 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,71 @@ +// Package store persists small runtime state (terminal names, login sessions) +// to a JSON file with atomic writes. +package store + +import ( + "encoding/json" + "errors" + "os" + "sync" + "time" +) + +type Terminal struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type State struct { + Terminals []Terminal `json:"terminals"` + Sessions map[string]time.Time `json:"sessions"` // token -> expiry +} + +type Store struct { + mu sync.Mutex + path string + state State +} + +func Open(path string) (*Store, error) { + s := &Store{path: path} + b, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + if err == nil { + if err := json.Unmarshal(b, &s.state); err != nil { + return nil, err + } + } + if s.state.Sessions == nil { + s.state.Sessions = map[string]time.Time{} + } + return s, nil +} + +// Update applies fn under the lock and writes the result to disk. +func (s *Store) Update(fn func(*State)) error { + s.mu.Lock() + defer s.mu.Unlock() + fn(&s.state) + return s.save() +} + +// View reads state under the lock. +func (s *Store) View(fn func(*State)) { + s.mu.Lock() + defer s.mu.Unlock() + fn(&s.state) +} + +func (s *Store) save() error { + b, err := json.MarshalIndent(&s.state, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} diff --git a/internal/term/manager.go b/internal/term/manager.go new file mode 100644 index 0000000..5d284a5 --- /dev/null +++ b/internal/term/manager.go @@ -0,0 +1,287 @@ +// Package term owns PTY-backed shell sessions that outlive any one browser tab. +package term + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "os" + "os/exec" + "sort" + "sync" + "syscall" + "time" + + "github.com/creack/pty" +) + +const ( + ringSize = 256 * 1024 + clientBuf = 512 + killGrace = 3 * time.Second + readChunk = 32 * 1024 + defaultCol = 120 + defaultRow = 30 +) + +var ErrNotFound = errors.New("terminal not found") + +// Msg is one unit of output to a client: raw PTY bytes, or a control event. +type Msg struct { + Data []byte // non-nil for PTY output + Event string // "exit" when the shell has ended + Code int +} + +type Client struct { + Ch chan Msg + session *Session + dropped bool +} + +type Info struct { + ID string `json:"id"` + Name string `json:"name"` + Running bool `json:"running"` + ExitCode int `json:"exit_code"` + Created time.Time `json:"created"` + Clients int `json:"clients"` +} + +type Session struct { + ID string + Name string + Created time.Time + + mu sync.Mutex + ptmx *os.File + cmd *exec.Cmd + ring *Ring + clients map[*Client]struct{} + running bool + exitCode int +} + +type Manager struct { + mu sync.RWMutex + sessions map[string]*Session + shell string + home string + OnChange func([]Info) // called (without locks held) when the list changes +} + +func NewManager(shell, home string) *Manager { + return &Manager{sessions: map[string]*Session{}, shell: shell, home: home} +} + +func newID() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// Create starts a new login shell. id may be empty. +func (m *Manager) Create(id, name string) (*Session, error) { + if id == "" { + id = newID() + } + cmd := exec.Command(m.shell, "-l") + cmd.Dir = m.home + cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLET=1") + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow}) + if err != nil { + return nil, err + } + s := &Session{ + ID: id, Name: name, Created: time.Now(), + ptmx: ptmx, cmd: cmd, ring: NewRing(ringSize), + clients: map[*Client]struct{}{}, running: true, + } + m.mu.Lock() + m.sessions[id] = s + m.mu.Unlock() + go m.pump(s) + m.changed() + return s, nil +} + +func (m *Manager) pump(s *Session) { + buf := make([]byte, readChunk) + for { + n, err := s.ptmx.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + s.mu.Lock() + s.ring.Write(chunk) + s.broadcast(Msg{Data: chunk}) + s.mu.Unlock() + } + if err != nil { + break // EIO when the slave side closes + } + } + _ = s.cmd.Wait() + code := s.cmd.ProcessState.ExitCode() + s.mu.Lock() + s.running = false + s.exitCode = code + s.broadcast(Msg{Event: "exit", Code: code}) + s.mu.Unlock() + _ = s.ptmx.Close() + m.changed() +} + +// broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY. +func (s *Session) broadcast(msg Msg) { + for c := range s.clients { + select { + case c.Ch <- msg: + default: + if !c.dropped { + c.dropped = true + close(c.Ch) + } + delete(s.clients, c) + } + } +} + +func (m *Manager) get(id string) (*Session, error) { + m.mu.RLock() + defer m.mu.RUnlock() + s, ok := m.sessions[id] + if !ok { + return nil, ErrNotFound + } + return s, nil +} + +func (m *Manager) List() []Info { + m.mu.RLock() + out := make([]Info, 0, len(m.sessions)) + for _, s := range m.sessions { + s.mu.Lock() + out = append(out, Info{ID: s.ID, Name: s.Name, Running: s.running, ExitCode: s.exitCode, + Created: s.Created, Clients: len(s.clients)}) + s.mu.Unlock() + } + m.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { return out[i].Created.Before(out[j].Created) }) + return out +} + +func (m *Manager) Rename(id, name string) error { + s, err := m.get(id) + if err != nil { + return err + } + s.mu.Lock() + s.Name = name + s.mu.Unlock() + m.changed() + return nil +} + +// Attach subscribes a client and returns the scrollback snapshot to replay first. +func (m *Manager) Attach(id string) (*Client, []byte, bool, error) { + s, err := m.get(id) + if err != nil { + return nil, nil, false, err + } + c := &Client{Ch: make(chan Msg, clientBuf), session: s} + s.mu.Lock() + snap := s.ring.Bytes() + running := s.running + s.clients[c] = struct{}{} + s.mu.Unlock() + m.changed() + return c, snap, running, nil +} + +func (m *Manager) Detach(c *Client) { + s := c.session + s.mu.Lock() + if _, ok := s.clients[c]; ok { + delete(s.clients, c) + if !c.dropped { + c.dropped = true + close(c.Ch) + } + } + s.mu.Unlock() + m.changed() +} + +func (m *Manager) Write(id string, p []byte) error { + s, err := m.get(id) + if err != nil { + return err + } + s.mu.Lock() + running := s.running + s.mu.Unlock() + if !running { + return nil + } + _, err = s.ptmx.Write(p) + return err +} + +func (m *Manager) Resize(id string, cols, rows uint16) error { + s, err := m.get(id) + if err != nil { + return err + } + if cols == 0 || rows == 0 { + return nil + } + return pty.Setsize(s.ptmx, &pty.Winsize{Cols: cols, Rows: rows}) +} + +// Close ends the shell (SIGHUP to its process group, SIGKILL after a grace +// period) and forgets the session. +func (m *Manager) Close(id string) error { + m.mu.Lock() + s, ok := m.sessions[id] + if ok { + delete(m.sessions, id) + } + m.mu.Unlock() + if !ok { + return ErrNotFound + } + s.mu.Lock() + running := s.running + for c := range s.clients { + if !c.dropped { + c.dropped = true + close(c.Ch) + } + delete(s.clients, c) + } + s.mu.Unlock() + if running && s.cmd.Process != nil { + pid := s.cmd.Process.Pid + _ = syscall.Kill(-pid, syscall.SIGHUP) + go func() { + time.Sleep(killGrace) + _ = syscall.Kill(-pid, syscall.SIGKILL) + }() + } + m.changed() + return nil +} + +func (m *Manager) changed() { + if m.OnChange != nil { + m.OnChange(m.List()) + } +} + +// Shutdown hangs up every shell. Called on daemon exit. +func (m *Manager) Shutdown() { + for _, s := range m.List() { + _ = m.Close(s.ID) + } +} diff --git a/internal/term/ring.go b/internal/term/ring.go new file mode 100644 index 0000000..dbf37e3 --- /dev/null +++ b/internal/term/ring.go @@ -0,0 +1,37 @@ +package term + +// Ring is a fixed-size byte ring buffer holding the most recent output. +type Ring struct { + buf []byte + start int + n int +} + +func NewRing(size int) *Ring { return &Ring{buf: make([]byte, size)} } + +func (r *Ring) Write(p []byte) { + size := len(r.buf) + if len(p) >= size { + copy(r.buf, p[len(p)-size:]) + r.start, r.n = 0, size + return + } + end := (r.start + r.n) % size + first := copy(r.buf[end:], p) + copy(r.buf, p[first:]) + if r.n+len(p) > size { + r.start = (end + len(p)) % size + r.n = size + } else { + r.n += len(p) + } +} + +func (r *Ring) Bytes() []byte { + out := make([]byte, r.n) + first := copy(out, r.buf[r.start:]) + if first < r.n { + copy(out[first:], r.buf[:r.n-first]) + } + return out +} diff --git a/internal/web/embed.go b/internal/web/embed.go new file mode 100644 index 0000000..583c901 --- /dev/null +++ b/internal/web/embed.go @@ -0,0 +1,18 @@ +// Package web embeds the built frontend (web/dist, produced by `make web`). +package web + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var dist embed.FS + +func FS() fs.FS { + sub, err := fs.Sub(dist, "dist") + if err != nil { + panic(err) + } + return sub +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..8660e4f --- /dev/null +++ b/web/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + Hamlet + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..bbab6fd --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1777 @@ +{ + "name": "hamlet-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hamlet-web", + "version": "0.1.0", + "dependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", + "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", + "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.430", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz", + "integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..e7e4428 --- /dev/null +++ b/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "hamlet-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "icons": "node scripts/icons.mjs" + }, + "dependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/web/public/icons/apple-touch-icon.png b/web/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..156229a6cd45f5b27fc81e6fae715804a8e9ba34 GIT binary patch literal 1740 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 Qus&k&boFyt=akR{0LFUQWB>pF literal 0 HcmV?d00001 diff --git a/web/public/icons/icon-512-maskable.png b/web/public/icons/icon-512-maskable.png new file mode 100644 index 0000000000000000000000000000000000000000..fb5cbb95ef6183f4f107c8c043d2b7f77c155694 GIT binary patch literal 3930 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 self.skipWaiting()); +self.addEventListener('activate', (e) => { + e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim())); +}); +self.addEventListener('fetch', (e) => { + const url = new URL(e.request.url); + if (e.request.method !== 'GET' || url.origin !== location.origin || url.pathname.startsWith('/api/')) return; + if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/icons/')) { + e.respondWith(caches.open(CACHE).then(async (c) => (await c.match(e.request)) || fetch(e.request).then((r) => { if (r.ok) c.put(e.request, r.clone()); return r; }))); + return; + } + // navigations + everything else: network first, fall back to cached index + e.respondWith(fetch(e.request).then((r) => { if (r.ok && e.request.mode === 'navigate') caches.open(CACHE).then((c) => c.put('/', r.clone())); return r; }) + .catch(() => caches.match(e.request.mode === 'navigate' ? '/' : e.request))); +}); diff --git a/web/scripts/icons.mjs b/web/scripts/icons.mjs new file mode 100644 index 0000000..9f102f7 --- /dev/null +++ b/web/scripts/icons.mjs @@ -0,0 +1,26 @@ +// Renders the PWA icons with node-canvas (borrowed from ../../calorie-counter/node_modules). +import { createRequire } from 'node:module' +import { writeFileSync } from 'node:fs' +const require = createRequire(import.meta.url) +const { createCanvas } = require('/home/core/projects/calorie-counter/node_modules/canvas') + +function draw(size, { pad = 0, radius = 0 }) { + const c = createCanvas(size, size), g = c.getContext('2d') + g.fillStyle = '#111418' + if (radius) { g.beginPath(); g.roundRect(0, 0, size, size, radius); g.fill() } else g.fillRect(0, 0, size, size) + const inner = size - pad * 2 + g.fillStyle = '#e3a33b' + g.font = `bold ${inner * 0.62}px "DejaVu Serif", serif` + g.textAlign = 'center'; g.textBaseline = 'middle' + g.fillText('H', size / 2, size / 2 + inner * 0.03) + // prompt caret + g.fillStyle = '#58a6ff' + g.font = `bold ${inner * 0.22}px "DejaVu Sans Mono", monospace` + g.fillText('>_', size / 2 + inner * 0.27, size / 2 + inner * 0.27) + return c.toBuffer('image/png') +} +writeFileSync('public/icons/icon-192.png', draw(192, {})) +writeFileSync('public/icons/icon-512.png', draw(512, {})) +writeFileSync('public/icons/icon-512-maskable.png', draw(512, { pad: 80 })) +writeFileSync('public/icons/apple-touch-icon.png', draw(180, {})) +console.log('icons written') diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..6dfe9eb --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,23 @@ +import { useCallback, useEffect, useState } from 'react' +import { api, Me, Unauthenticated } from './api' +import Login from './Login' +import Shell from './Shell' + +export default function App() { + const [me, setMe] = useState(undefined) + + const refresh = useCallback(async () => { + try { setMe(await api.me()) } catch (e) { if (e instanceof Unauthenticated) setMe(null) } + }, []) + + useEffect(() => { refresh() }, [refresh]) + useEffect(() => { + if (!me) return + const t = setInterval(refresh, 30000) + return () => clearInterval(t) + }, [me, refresh]) + + if (me === undefined) return
…
+ if (me === null) return + return setMe(null)} /> +} diff --git a/web/src/KeyBar.tsx b/web/src/KeyBar.tsx new file mode 100644 index 0000000..ba45f83 --- /dev/null +++ b/web/src/KeyBar.tsx @@ -0,0 +1,18 @@ +import { useState } from 'react' + +const KEYS: [string, string][] = [ + ['Esc', '\x1b'], ['Tab', '\t'], ['↑', '\x1b[A'], ['↓', '\x1b[B'], ['←', '\x1b[D'], ['→', '\x1b[C'], + ['Home', '\x1b[H'], ['End', '\x1b[F'], ['|', '|'], ['~', '~'], ['-', '-'], ['/', '/'], ['^C', '\x03'], ['^D', '\x04'], +] + +export default function KeyBar({ send, setCtrl }: { send: (s: string) => void; setCtrl: (v: boolean) => void }) { + const [ctrl, setCtrlState] = useState(false) + const toggle = () => { const v = !ctrl; setCtrlState(v); setCtrl(v) } + const press = (s: string) => { send(s); if (ctrl) { setCtrlState(false); setCtrl(false) } } + return ( +
e.preventDefault()}> + + {KEYS.map(([label, seq]) => )} +
+ ) +} diff --git a/web/src/Login.tsx b/web/src/Login.tsx new file mode 100644 index 0000000..a05e8b9 --- /dev/null +++ b/web/src/Login.tsx @@ -0,0 +1,28 @@ +import { FormEvent, useState } from 'react' +import { api } from './api' + +export default function Login({ onLogin }: { onLogin: () => void }) { + const [pw, setPw] = useState('') + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + + async function submit(e: FormEvent) { + e.preventDefault() + setBusy(true); setErr('') + try { await api.login(pw); onLogin() } + catch (e) { setErr(e instanceof Error ? e.message : 'login failed') } + finally { setBusy(false) } + } + + return ( +
+
+

Hamlet

+ setPw(e.target.value)} /> + + {err &&
{err}
} +
+
+ ) +} diff --git a/web/src/Rail.tsx b/web/src/Rail.tsx new file mode 100644 index 0000000..1d555fa --- /dev/null +++ b/web/src/Rail.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react' +import { Term } from './api' + +export interface Group { id: string; label: string; icon: string; soon?: boolean } + +interface Props { + groups: Group[]; terms: Term[]; active: string | null + onSelect: (id: string) => void; onNew: () => void + onRename: (t: Term) => void; onClose: (t: Term) => void +} + +export default function Rail({ groups, terms, active, onSelect, onNew, onRename, onClose }: Props) { + const [open, setOpen] = useState('terminal') + return ( + + ) +} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx new file mode 100644 index 0000000..fb2b358 --- /dev/null +++ b/web/src/Shell.tsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { api, Me, Term } from './api' +import Rail, { Group } from './Rail' +import TerminalPane, { TermHandle } from './TerminalPane' +import KeyBar from './KeyBar' + +const GROUPS: Group[] = [ + { id: 'terminal', label: 'Terminal', icon: '>_' }, + { id: 'services', label: 'Services', icon: '⚙', soon: true }, + { id: 'cron', label: 'Cron', icon: '⏱', soon: true }, + { id: 'zfs', label: 'ZFS', icon: '▤', soon: true }, + { id: 'dashboard', label: 'Dashboard', icon: '◔', soon: true }, +] + +function fmtUptime(s: number): string { + const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60) + return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` +} + +export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => void }) { + const [terms, setTerms] = useState([]) + const [active, setActive] = useState(null) + const [drawer, setDrawer] = useState(false) + const [online, setOnline] = useState(true) + const handles = useRef>({}) + + const reload = useCallback(async () => { + const list = await api.terminals() + setTerms(list) + setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null)) + }, []) + + useEffect(() => { reload() }, [reload]) + + async function create() { + const t = await api.createTerminal() + await reload() + setActive(t.id) + setDrawer(false) + } + async function rename(t: Term) { + const name = window.prompt('Rename terminal', t.name) + if (name && name.trim() && name !== t.name) { await api.renameTerminal(t.id, name.trim()); await reload() } + } + async function close(t: Term) { + if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return + await api.deleteTerminal(t.id) + delete handles.current[t.id] + await reload() + } + async function logout() { await api.logout(); onLoggedOut() } + + const activeTerm = terms.find((t) => t.id === active) + + return ( +
+
+ + Hamlet + {me.user}@{me.host} + + {me.load} + + +
+ +
+ { setActive(id); setDrawer(false) }} + onNew={create} onRename={rename} onClose={close} /> +
setDrawer(false)} /> + +
+ {terms.length === 0 && ( +
+
No terminals.
+ +
+ )} + {terms.map((t) => ( + { if (h) handles.current[t.id] = h; else delete handles.current[t.id] }} + onStatus={(ok) => { if (t.id === active) setOnline(ok) }} + onExit={reload} /> + ))} +
+
+ + {activeTerm && handles.current[activeTerm.id]?.input(s)} + setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />} + +
+ v{me.version} + up {fmtUptime(me.uptime)} + + {activeTerm ? activeTerm.name : ''} +
+
+ ) +} diff --git a/web/src/TerminalPane.tsx b/web/src/TerminalPane.tsx new file mode 100644 index 0000000..eeb5155 --- /dev/null +++ b/web/src/TerminalPane.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef } from 'react' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { WebLinksAddon } from '@xterm/addon-web-links' +import { wsURL } from './api' + +export interface TermHandle { input: (s: string) => void; armCtrl: (v: boolean) => void } + +interface Props { + id: string; active: boolean + register: (h: TermHandle | null) => void + onStatus: (ok: boolean) => void + onExit: () => void +} + +const THEME = { + background: '#0d1117', foreground: '#d0d7de', cursor: '#d0d7de', selectionBackground: '#264f78', + black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff', + magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4', brightBlack: '#6e7681', brightRed: '#ffa198', + brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff', + brightCyan: '#56d4dd', brightWhite: '#f0f6fc', +} + +export default function TerminalPane({ id, active, register, onStatus, onExit }: Props) { + const box = useRef(null) + const activeRef = useRef(active) + activeRef.current = active + const cbs = useRef({ onStatus, onExit }) + cbs.current = { onStatus, onExit } + const fitRef = useRef<() => void>(() => {}) + + useEffect(() => { + const el = box.current! + const term = new Terminal({ + theme: THEME, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', + fontSize: window.matchMedia('(pointer: coarse)').matches ? 13 : 14, + cursorBlink: true, scrollback: 5000, allowProposedApi: true, + }) + const fit = new FitAddon() + term.loadAddon(fit) + term.loadAddon(new WebLinksAddon()) + term.open(el) + + let ws: WebSocket | null = null + let closed = false + let ctrlArmed = false + let retry = 0 + let retryTimer: number | undefined + let lastSize = '' + const enc = new TextEncoder() + + const send = (s: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(enc.encode(s)) } + const sendResize = () => { + const key = `${term.cols}x${term.rows}` + if (key === lastSize) return + lastSize = key + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })) + } + const doFit = () => { + if (!activeRef.current || el.clientWidth === 0 || el.clientHeight === 0) return + fit.fit() + sendResize() + } + + const connect = () => { + if (closed) return + const sock = new WebSocket(wsURL(id)) + sock.binaryType = 'arraybuffer' + ws = sock + sock.onopen = () => { retry = 0; cbs.current.onStatus(true); lastSize = ''; doFit() } + sock.onmessage = (ev) => { + if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return } + try { + const m = JSON.parse(ev.data) + if (m.type === 'exit') { term.write('\r\n\x1b[90m[process exited' + (m.code !== undefined ? ` (${m.code})` : '') + ']\x1b[0m\r\n'); closed = true; cbs.current.onExit() } + if (m.type === 'closed') closed = true + } catch { /* ignore */ } + } + sock.onclose = () => { + if (ws !== sock) return + cbs.current.onStatus(false) + if (closed) return + retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000)) + } + sock.onerror = () => sock.close() + } + connect() + + term.onData((d) => { + if (ctrlArmed && d.length === 1) { + ctrlArmed = false + const c = d.toUpperCase().charCodeAt(0) + if (c >= 64 && c <= 95) { send(String.fromCharCode(c & 0x1f)); return } + } + send(d) + }) + term.onBinary((d) => send(d)) + term.onResize(sendResize) + + fitRef.current = () => { doFit(); term.focus() } + const ro = new ResizeObserver(() => doFit()) + ro.observe(el) + const onVis = () => { if (document.visibilityState === 'visible' && ws && ws.readyState !== WebSocket.OPEN && !closed) { clearTimeout(retryTimer); connect() } } + document.addEventListener('visibilitychange', onVis) + + register({ input: send, armCtrl: (v) => { ctrlArmed = v; term.focus() } }) + + return () => { + register(null) + closed = true + clearTimeout(retryTimer) + document.removeEventListener('visibilitychange', onVis) + ro.disconnect() + ws?.close() + term.dispose() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]) + + // Refit + focus whenever this pane becomes the visible one (after the display change applies). + useEffect(() => { + if (!active) return + const t = window.setTimeout(() => fitRef.current(), 0) + return () => clearTimeout(t) + }, [active]) + + return
+} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..2fd9430 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,36 @@ +export class Unauthenticated extends Error {} + +async function req(method: string, path: string, body?: unknown): Promise { + const r = await fetch(path, { + method, + credentials: 'same-origin', + headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + if (r.status === 401) throw new Unauthenticated() + if (!r.ok) { + let msg = r.statusText + try { msg = (await r.json()).error ?? msg } catch { /* ignore */ } + throw new Error(msg) + } + if (r.status === 204) return undefined as T + return r.json() +} + +export interface Me { host: string; user: string; version: string; load: string; uptime: number } +export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number } + +export const api = { + login: (password: string) => req('POST', '/api/login', { password }), + logout: () => req('POST', '/api/logout'), + me: () => req('GET', '/api/me'), + terminals: () => req('GET', '/api/terminals'), + createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }), + renameTerminal: (id: string, name: string) => req('PATCH', `/api/terminals/${id}`, { name }), + deleteTerminal: (id: string) => req('DELETE', `/api/terminals/${id}`), +} + +export function wsURL(id: string): string { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${proto}//${location.host}/api/terminals/${id}/attach` +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..5d18c6e --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import '@xterm/xterm/css/xterm.css' +import './styles.css' + +createRoot(document.getElementById('root')!).render( + + + , +) + +if ('serviceWorker' in navigator && location.protocol === 'https:') { + window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => {}) }) +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..658796e --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,78 @@ +:root { + --bg: #111418; --bg2: #171b21; --bg3: #1e242c; --border: #2a313b; + --fg: #d0d7de; --muted: #7d8590; --accent: #e3a33b; --accent2: #58a6ff; + --ok: #3fb950; --bad: #ff7b72; + --header-h: 44px; --footer-h: 28px; --rail-w: 220px; +} +* { box-sizing: border-box; } +html, body, #root { height: 100%; margin: 0; } +body { background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overscroll-behavior: none; } +button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; } +.spacer { flex: 1; } +.muted { color: var(--muted); } +.center { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; } + +/* login */ +.login { display: flex; flex-direction: column; gap: 10px; width: min(320px, 90vw); } +.login h1 { margin: 0 0 8px; font-weight: 600; color: var(--accent); letter-spacing: .5px; } +.login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; } +.login button, .primary { padding: 10px 12px; background: var(--accent); color: #111; border-radius: 6px; font-weight: 600; } +.login button:disabled { opacity: .5; } +.error { color: var(--bad); font-size: 13px; } + +/* layout */ +.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); } +.header { height: var(--header-h); display: flex; align-items: center; gap: 12px; padding: 0 12px; background: var(--bg2); border-bottom: 1px solid var(--border); } +.brand { font-weight: 700; color: var(--accent); letter-spacing: .5px; } +.host { color: var(--muted); font-family: ui-monospace, monospace; font-size: 13px; } +.stat { font-family: ui-monospace, monospace; font-size: 12px; color: var(--muted); } +.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); } +.dot.ok { background: var(--ok); } .dot.bad { background: var(--bad); } +.link { color: var(--muted); } .link:hover { color: var(--fg); } +.icon-btn { padding: 2px 6px; color: var(--muted); border-radius: 4px; } .icon-btn:hover { color: var(--fg); background: var(--bg3); } +.menu { display: none; font-size: 18px; } +.body { flex: 1; display: flex; min-height: 0; position: relative; } +.footer { height: var(--footer-h); display: flex; align-items: center; gap: 16px; padding: 0 12px; background: var(--bg2); border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); padding-bottom: env(safe-area-inset-bottom); box-sizing: content-box; } + +/* rail */ +.rail { width: var(--rail-w); background: var(--bg2); border-right: 1px solid var(--border); overflow-y: auto; padding: 6px 0; flex-shrink: 0; } +.group-head { width: 100%; display: flex; align-items: center; gap: 8px; padding: 8px 12px; text-align: left; } +.group-head:hover { background: var(--bg3); } +.group.soon .group-head { color: var(--muted); cursor: default; } +.group.soon .group-head:hover { background: none; } +.chev { width: 10px; color: var(--muted); font-size: 11px; } +.gicon { width: 18px; text-align: center; color: var(--accent2); font-family: ui-monospace, monospace; font-size: 12px; } +.badge { margin-left: auto; font-size: 10px; text-transform: uppercase; border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; } +.leaves { padding: 2px 0 6px; } +.leaf { display: flex; align-items: center; gap: 8px; padding: 7px 12px 7px 30px; cursor: pointer; width: 100%; text-align: left; } +.leaf:hover { background: var(--bg3); } +.leaf.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); } +.leaf.exited .lname { color: var(--muted); text-decoration: line-through; } +.leaf.new { color: var(--accent2); } +.lname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.leaf-actions { display: flex; gap: 2px; } +.sdot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex-shrink: 0; } +.sdot.run { background: var(--ok); } + +/* main + terminal */ +.main { flex: 1; min-width: 0; position: relative; background: #0d1117; } +.term { position: absolute; inset: 0; padding: 6px 0 0 6px; } +.term .xterm { height: 100%; } +.xterm-viewport::-webkit-scrollbar { width: 8px; } .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border); } + +/* key bar (touch devices / narrow screens) */ +.keybar { display: none; gap: 4px; padding: 4px 6px; background: var(--bg2); border-top: 1px solid var(--border); overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; } +.keybar button { flex: 0 0 auto; padding: 8px 10px; min-width: 40px; background: var(--bg3); border: 1px solid var(--border); border-radius: 5px; font-family: ui-monospace, monospace; font-size: 13px; } +.keybar button.on { background: var(--accent); color: #111; border-color: var(--accent); } +@media (pointer: coarse), (max-width: 800px) { .keybar { display: flex; } } + +/* mobile: rail becomes a drawer */ +.backdrop { display: none; } +@media (max-width: 800px) { + .menu { display: inline-block; } + .host, .stat { display: none; } + .rail { position: absolute; top: 0; bottom: 0; left: 0; z-index: 10; transform: translateX(-100%); transition: transform .15s ease; box-shadow: 4px 0 16px rgba(0,0,0,.5); } + .drawer-open .rail { transform: none; } + .drawer-open .backdrop { display: block; position: absolute; inset: 0; z-index: 9; background: rgba(0,0,0,.5); } + .footer { font-size: 11px; } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..61b917c --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..a34d20b --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + build: { outDir: '../internal/web/dist', emptyOutDir: true }, + server: { + proxy: { '/api': { target: 'http://127.0.0.1:8100', ws: true } }, + }, +})