From d2badeaabc59fbdf9eddcd756896744ee06e509e Mon Sep 17 00:00:00 2001 From: Agung Subastian Date: Thu, 9 Jul 2026 07:54:43 +0700 Subject: [PATCH 1/4] feat: add Manager.Update for in-place plugin bundle upgrades Extract refuses to run if the plugin folder already exists, so there was no way to apply a newer bundle over an already-installed plugin. Update mirrors Extract's per-entry logic but requires the plugin to already exist and never deletes the folder first, so any local-only file the plugin wrote at runtime survives the upgrade. --- core/plugins/bundle.go | 37 ++++++++++++ core/plugins/bundle_test.go | 113 ++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 core/plugins/bundle_test.go diff --git a/core/plugins/bundle.go b/core/plugins/bundle.go index e74a9f7..b358ccc 100644 --- a/core/plugins/bundle.go +++ b/core/plugins/bundle.go @@ -109,6 +109,43 @@ func (m *Manager) Extract(id string, zipBytes []byte) error { return nil } +// Update overwrites an existing plugin's files with a newer bundle. Unlike +// Extract, it requires the plugin to already exist, and never deletes the +// destination folder first, so any local-only file the plugin wrote at +// runtime survives the update. +func (m *Manager) Update(id string, zipBytes []byte) error { + if !idRe.MatchString(id) { + return fmt.Errorf("invalid plugin id") + } + dest := filepath.Join(m.dir, id) + if _, err := os.Stat(dest); err != nil { + return fmt.Errorf("no installed plugin with id %q", id) + } + zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) + if err != nil { + return fmt.Errorf("invalid bundle: %w", err) + } + for _, f := range zr.File { + if strings.Contains(f.Name, "..") || strings.HasPrefix(f.Name, "/") { + return fmt.Errorf("unsafe path in bundle: %s", f.Name) + } + target := filepath.Join(dest, filepath.FromSlash(f.Name)) + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := writeZipEntry(f, target); err != nil { + return err + } + } + return nil +} + func writeZipEntry(f *zip.File, target string) error { rc, err := f.Open() if err != nil { diff --git a/core/plugins/bundle_test.go b/core/plugins/bundle_test.go new file mode 100644 index 0000000..6eb6a57 --- /dev/null +++ b/core/plugins/bundle_test.go @@ -0,0 +1,113 @@ +package plugins + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "testing" +) + +func buildZip(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, content := range files { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestUpdate(t *testing.T) { + t.Run("fails when the plugin does not exist", func(t *testing.T) { + dir := t.TempDir() + m := New(dir, 1430) + zipBytes := buildZip(t, map[string]string{"plugin.json": `{"id":"demo"}`}) + + if err := m.Update("demo", zipBytes); err == nil { + t.Fatal("expected an error, got nil") + } + if _, err := os.Stat(filepath.Join(dir, "demo")); !os.IsNotExist(err) { + t.Fatalf("expected no folder to be created, stat err = %v", err) + } + }) + + t.Run("overwrites a file present in the new bundle", func(t *testing.T) { + dir := t.TempDir() + m := New(dir, 1430) + pluginDir := filepath.Join(dir, "demo") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(`{"id":"demo","version":"1.0.0"}`), 0o644); err != nil { + t.Fatal(err) + } + + zipBytes := buildZip(t, map[string]string{"plugin.json": `{"id":"demo","version":"2.0.0"}`}) + if err := m.Update("demo", zipBytes); err != nil { + t.Fatalf("Update: %v", err) + } + + got, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json")) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"id":"demo","version":"2.0.0"}` { + t.Fatalf("plugin.json = %q, want the new bundle's content", got) + } + }) + + t.Run("leaves a local-only file alone", func(t *testing.T) { + dir := t.TempDir() + m := New(dir, 1430) + pluginDir := filepath.Join(dir, "demo") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(`{"id":"demo"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "local-state.txt"), []byte("keep me"), 0o644); err != nil { + t.Fatal(err) + } + + zipBytes := buildZip(t, map[string]string{"plugin.json": `{"id":"demo","version":"2.0.0"}`}) + if err := m.Update("demo", zipBytes); err != nil { + t.Fatalf("Update: %v", err) + } + + got, err := os.ReadFile(filepath.Join(pluginDir, "local-state.txt")) + if err != nil { + t.Fatalf("local-state.txt should still exist: %v", err) + } + if string(got) != "keep me" { + t.Fatalf("local-state.txt = %q, want unchanged", got) + } + }) + + t.Run("rejects a path-escape entry", func(t *testing.T) { + dir := t.TempDir() + m := New(dir, 1430) + pluginDir := filepath.Join(dir, "demo") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(`{"id":"demo"}`), 0o644); err != nil { + t.Fatal(err) + } + + zipBytes := buildZip(t, map[string]string{"../escape.txt": "pwned"}) + if err := m.Update("demo", zipBytes); err == nil { + t.Fatal("expected an error for a path-escape entry, got nil") + } + }) +} From 6422b34f3a33115d37db416f0aa8d15bc91776bb Mon Sep 17 00:00:00 2001 From: Agung Subastian Date: Thu, 9 Jul 2026 08:00:01 +0700 Subject: [PATCH 2/4] feat: add POST /api/market/update/{id} endpoint Mirrors Install's cloud-metadata + bundle-download flow, but stops the plugin first and calls the new Manager.Update instead of Extract, so it works on an already-installed plugin instead of erroring. --- server/handlers/api_market.go | 34 ++++++++++++++++++++++++++++++++++ server/server.go | 6 +++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/server/handlers/api_market.go b/server/handlers/api_market.go index 5193aae..a3e961b 100644 --- a/server/handlers/api_market.go +++ b/server/handlers/api_market.go @@ -123,6 +123,40 @@ func (h *Market) Install(w http.ResponseWriter, r *http.Request) { writeData(w, map[string]any{"installed": true, "id": meta.Slug}) } +// POST /api/market/update/{id} — download the bundle + overwrite an +// existing install (same flow as Install, but calls Update, not Extract). +func (h *Market) Update(w http.ResponseWriter, r *http.Request) { + if !h.guard(w, r) { + return + } + id := chi.URLParam(r, "id") + raw, err := h.sync.InstallPlugin(r.Context(), id) + if err != nil { + writeAPIErr(w, http.StatusBadGateway, err.Error()) + return + } + var meta struct { + BundleURL string `json:"bundle_url"` + Slug string `json:"slug"` + } + _ = json.Unmarshal([]byte(raw), &meta) + if meta.BundleURL == "" || meta.Slug == "" { + writeAPIErr(w, http.StatusBadGateway, "missing bundle info") + return + } + zipBytes, err := h.sync.DownloadBundle(r.Context(), meta.BundleURL) + if err != nil { + writeAPIErr(w, http.StatusBadGateway, err.Error()) + return + } + h.mgr.Stop(meta.Slug) + if err := h.mgr.Update(meta.Slug, zipBytes); err != nil { + writeAPIErr(w, http.StatusBadRequest, err.Error()) + return + } + writeData(w, map[string]any{"updated": true, "id": meta.Slug}) +} + // GET /api/admin/marketplace?status= — moderator plugin list (approve/reject queue). func (h *Market) AdminPlugins(w http.ResponseWriter, r *http.Request) { if !h.guard(w, r) { diff --git a/server/server.go b/server/server.go index c50a77a..ce3b643 100644 --- a/server/server.go +++ b/server/server.go @@ -9,14 +9,14 @@ import ( "github.com/go-chi/chi/v5" + "github.com/enowdev/enowx/core/mitm" + "github.com/enowdev/enowx/core/plugins" "github.com/enowdev/enowx/core/provider" "github.com/enowdev/enowx/core/provider/custommgr" - "github.com/enowdev/enowx/core/plugins" "github.com/enowdev/enowx/core/proxy" "github.com/enowdev/enowx/core/suno" syncpkg "github.com/enowdev/enowx/core/sync" "github.com/enowdev/enowx/core/transport" - "github.com/enowdev/enowx/core/mitm" "github.com/enowdev/enowx/core/tunnel" "github.com/enowdev/enowx/server/handlers" "github.com/enowdev/enowx/server/middleware" @@ -273,7 +273,6 @@ func New(addr string, d Deps) *Server { r.Get("/files", files.List) r.Get("/files/watch", files.Watch) - // Terminal profiles (per-terminal credential isolation via HOME). termProfiles := handlers.NewTermProfiles(dash) r.Get("/term-profiles", termProfiles.List) @@ -489,6 +488,7 @@ func New(addr string, d Deps) *Server { r.Post("/publish", market.Publish) r.Get("/plugins", market.List) r.Post("/install/{id}", market.Install) + r.Post("/update/{id}", market.Update) }) r.Get("/api/admin/plugin-scan", market.GetScanSettings) r.Put("/api/admin/plugin-scan", market.SaveScanSettings) From 1358b74e1498df3d5c039eb99bbdec7499610898 Mon Sep 17 00:00:00 2001 From: Agung Subastian Date: Thu, 9 Jul 2026 08:07:36 +0700 Subject: [PATCH 3/4] feat: show plugin update availability in PluginsApp Cross-references the already-installed plugin list against an unfiltered marketplace catalog fetch (both client-side, no new backend query) to badge outdated plugins in My Plugins and fix the Marketplace browse view, which previously always showed "Install" even for an already-installed plugin. --- web/src/apps/PluginsApp.tsx | 57 +++++++++++++++++++++++++++++++++---- web/src/lib/api.ts | 1 + web/src/lib/semver.ts | 13 +++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/semver.ts diff --git a/web/src/apps/PluginsApp.tsx b/web/src/apps/PluginsApp.tsx index 0ad2e6c..ae5b8b3 100644 --- a/web/src/apps/PluginsApp.tsx +++ b/web/src/apps/PluginsApp.tsx @@ -8,6 +8,7 @@ import { subscribeLive } from "../os/liveBus"; import { useProfile } from "../os/useProfile"; import { SignInGate } from "../components/SignInGate"; import { pluginsApi, marketApi, type PluginManifest, type PluginRuntime, type MarketPlugin } from "../lib/api"; +import { compareVersions } from "../lib/semver"; const RUNTIMES = [ { id: "python", label: "Python" }, @@ -19,6 +20,7 @@ const RUNTIMES = [ export function PluginsApp() { const [plugins, setPlugins] = useState([]); const [runtimes, setRuntimes] = useState([]); + const [marketCatalog, setMarketCatalog] = useState([]); const [error, setError] = useState(""); const [busy, setBusy] = useState(null); const [creating, setCreating] = useState(false); @@ -40,6 +42,9 @@ export function PluginsApp() { useEffect(() => { load(); }, []); + useEffect(() => { + marketApi.list().then((r) => setMarketCatalog(r?.plugins ?? [])).catch(() => {}); + }, []); // static + bin need no toolchain (bin ships prebuilt per-OS binaries). const runtimeOk = (id: string) => id === "static" || id === "bin" || runtimes.find((r) => r.id === id)?.available; @@ -91,6 +96,8 @@ export function PluginsApp() { const [tab, setTab] = useState<"mine" | "market">("mine"); + const marketBySlug = new Map(marketCatalog.map((m) => [m.slug, m])); + return ( {!isPremium && ( @@ -106,7 +113,7 @@ export function PluginsApp() { - {tab === "market" ? : ( + {tab === "market" ? [p.id, p.version ?? ""]))} /> : ( <>
))} + {(() => { + const listing = marketBySlug.get(p.id); + if (!listing || compareVersions(listing.version, p.version ?? "0") <= 0) return null; + return ( + + + + ); + })()} @@ -303,8 +319,8 @@ function PluginWindow({ plugin, onClose }: { plugin: PluginManifest; onClose: () ); } -// Marketplace browses published plugins and installs them locally. -function Marketplace({ onInstalled }: { onInstalled: () => void }) { +// Marketplace browses published plugins and installs/updates them locally. +function Marketplace({ onInstalled, installedVersions }: { onInstalled: () => void; installedVersions: Map }) { const profile = useProfile(); const [items, setItems] = useState([]); const [q, setQ] = useState(""); @@ -352,6 +368,20 @@ function Marketplace({ onInstalled }: { onInstalled: () => void }) { } }; + const update = async (p: MarketPlugin) => { + setBusy(p.id); + setError(""); + try { + await marketApi.update(p.id); + setNote(`Updated ${p.name} to v${p.version}.`); + onInstalled(); + } catch (e) { + setError(e instanceof Error ? e.message : "update failed"); + } finally { + setBusy(null); + } + }; + return (
@@ -379,9 +409,24 @@ function Marketplace({ onInstalled }: { onInstalled: () => void }) {
{p.description || "No description"}
by {p.display_name || p.username} · {p.install_count} installs
- + {(() => { + const installed = installedVersions.get(p.slug); + if (installed === undefined) { + return ( + + ); + } + if (compareVersions(p.version, installed) > 0) { + return ( + + ); + } + return Installed; + })()}
))}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 23fc1b3..4e2c2e2 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -401,6 +401,7 @@ export const marketApi = { publish: (id: string) => api.post<{ status: string; reason?: string; id?: number; file?: string; updated?: boolean }>("/api/market/publish", { id }), list: (q = "") => api.get<{ plugins: MarketPlugin[] }>(`/api/market/plugins${q ? `?q=${encodeURIComponent(q)}` : ""}`), install: (id: number) => api.post<{ installed: boolean; id: string }>(`/api/market/install/${id}`), + update: (id: number) => api.post<{ updated: boolean; id: string }>(`/api/market/update/${id}`), }; export interface PluginScanSettings { ai_review_endpoint: string; ai_review_model: string; ai_review_enabled: boolean; has_key: boolean } diff --git a/web/src/lib/semver.ts b/web/src/lib/semver.ts new file mode 100644 index 0000000..47d65c6 --- /dev/null +++ b/web/src/lib/semver.ts @@ -0,0 +1,13 @@ +// compareVersions compares dot-separated numeric version strings ("1.2.3"). +// Returns >0 if a is newer than b, <0 if older, 0 if equal. Non-numeric or +// missing segments compare as 0, so "1.2" and "1.2.0" are equal. +export function compareVersions(a: string, b: string): number { + const pa = a.split(".").map((n) => parseInt(n, 10) || 0); + const pb = b.split(".").map((n) => parseInt(n, 10) || 0); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} From 32cf49efe04f1912adb329d0552bc1ad71b5a192 Mon Sep 17 00:00:00 2001 From: Agung Subastian Date: Thu, 9 Jul 2026 08:10:02 +0700 Subject: [PATCH 4/4] style: trim overlong comments on Update/compareVersions Both ran 3-4 lines; compressed to 1-2 lines per the codebase's comment-brevity convention. --- core/plugins/bundle.go | 6 ++---- web/src/lib/semver.ts | 5 ++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/core/plugins/bundle.go b/core/plugins/bundle.go index b358ccc..801e1e0 100644 --- a/core/plugins/bundle.go +++ b/core/plugins/bundle.go @@ -109,10 +109,8 @@ func (m *Manager) Extract(id string, zipBytes []byte) error { return nil } -// Update overwrites an existing plugin's files with a newer bundle. Unlike -// Extract, it requires the plugin to already exist, and never deletes the -// destination folder first, so any local-only file the plugin wrote at -// runtime survives the update. +// Update overwrites an existing plugin with a newer bundle. Unlike Extract, +// it never deletes the folder first, so local-only files survive. func (m *Manager) Update(id string, zipBytes []byte) error { if !idRe.MatchString(id) { return fmt.Errorf("invalid plugin id") diff --git a/web/src/lib/semver.ts b/web/src/lib/semver.ts index 47d65c6..92631c5 100644 --- a/web/src/lib/semver.ts +++ b/web/src/lib/semver.ts @@ -1,6 +1,5 @@ -// compareVersions compares dot-separated numeric version strings ("1.2.3"). -// Returns >0 if a is newer than b, <0 if older, 0 if equal. Non-numeric or -// missing segments compare as 0, so "1.2" and "1.2.0" are equal. +// compareVersions compares dot-separated version strings ("1.2.3"); >0 if a +// is newer, <0 if older, 0 if equal ("1.2" equals "1.2.0"). export function compareVersions(a: string, b: string): number { const pa = a.split(".").map((n) => parseInt(n, 10) || 0); const pb = b.split(".").map((n) => parseInt(n, 10) || 0);