Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions core/plugins/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,41 @@ func (m *Manager) Extract(id string, zipBytes []byte) error {
return nil
}

// 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")
}
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 {
Expand Down
113 changes: 113 additions & 0 deletions core/plugins/bundle_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
34 changes: 34 additions & 0 deletions server/handlers/api_market.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
57 changes: 51 additions & 6 deletions web/src/apps/PluginsApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -19,6 +20,7 @@ const RUNTIMES = [
export function PluginsApp() {
const [plugins, setPlugins] = useState<PluginManifest[]>([]);
const [runtimes, setRuntimes] = useState<PluginRuntime[]>([]);
const [marketCatalog, setMarketCatalog] = useState<MarketPlugin[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<AppShell title="Plugins" subtitle="Build and run your own apps & automations">
{!isPremium && (
Expand All @@ -106,7 +113,7 @@ export function PluginsApp() {
<button onClick={() => setTab("market")} className={`flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium ${tab === "market" ? "bg-white/12 text-white" : "text-white/50 hover:bg-white/5"}`}><Store className="h-3.5 w-3.5" /> Marketplace</button>
</div>

{tab === "market" ? <Marketplace onInstalled={load} /> : (
{tab === "market" ? <Marketplace onInstalled={load} installedVersions={new Map(plugins.map((p) => [p.id, p.version ?? ""]))} /> : (
<>
<div className="mb-3 flex items-center gap-2">
<button onClick={() => setCreating(true)} className="flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-black hover:opacity-90">
Expand Down Expand Up @@ -158,6 +165,15 @@ export function PluginsApp() {
<Tooltip label="Start"><button onClick={() => act(() => pluginsApi.start(p.id), p.id)} disabled={busy === p.id || !runtimeOk(p.runtime)} className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5 text-white/55 hover:bg-white/10 hover:text-white disabled:opacity-40"><Play className="h-3.5 w-3.5" /></button></Tooltip>
))}
<Tooltip label="Open folder (edit in your IDE)"><button onClick={() => pluginsApi.reveal(p.id).catch(() => {})} className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5 text-white/55 hover:bg-white/10 hover:text-white"><FolderOpen className="h-3.5 w-3.5" /></button></Tooltip>
{(() => {
const listing = marketBySlug.get(p.id);
if (!listing || compareVersions(listing.version, p.version ?? "0") <= 0) return null;
return (
<Tooltip label={`Update to v${listing.version}`}>
<button onClick={() => act(() => marketApi.update(listing.id), p.id)} disabled={busy === p.id} className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-1.5 text-emerald-300 hover:bg-emerald-500/20 disabled:opacity-40"><Download className="h-3.5 w-3.5" /></button>
</Tooltip>
);
})()}
<Tooltip label="Publish to marketplace"><button onClick={() => publish(p)} disabled={busy === p.id} className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5 text-white/55 hover:bg-white/10 hover:text-white disabled:opacity-40"><UploadCloud className="h-3.5 w-3.5" /></button></Tooltip>
<Tooltip label="Logs"><button onClick={() => setLogsFor(p.id)} className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5 text-white/55 hover:bg-white/10 hover:text-white"><ScrollText className="h-3.5 w-3.5" /></button></Tooltip>
<Tooltip label="Delete"><button onClick={() => remove(p)} disabled={busy === p.id} className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5 text-white/55 hover:bg-red-500/30 hover:text-red-200 disabled:opacity-40"><Trash2 className="h-3.5 w-3.5" /></button></Tooltip>
Expand Down Expand Up @@ -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<string, string> }) {
const profile = useProfile();
const [items, setItems] = useState<MarketPlugin[]>([]);
const [q, setQ] = useState("");
Expand Down Expand Up @@ -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 (
<div>
<div className="mb-3 flex items-center gap-2">
Expand Down Expand Up @@ -379,9 +409,24 @@ function Marketplace({ onInstalled }: { onInstalled: () => void }) {
<div className="mt-0.5 truncate text-[11px] text-white/45">{p.description || "No description"}</div>
<div className="mt-0.5 text-[10px] text-white/30">by {p.display_name || p.username} · {p.install_count} installs</div>
</div>
<button onClick={() => install(p)} disabled={busy === p.id} className="flex shrink-0 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs text-white/70 hover:bg-white/10 disabled:opacity-40">
{busy === p.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />} Install
</button>
{(() => {
const installed = installedVersions.get(p.slug);
if (installed === undefined) {
return (
<button onClick={() => install(p)} disabled={busy === p.id} className="flex shrink-0 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs text-white/70 hover:bg-white/10 disabled:opacity-40">
{busy === p.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />} Install
</button>
);
}
if (compareVersions(p.version, installed) > 0) {
return (
<button onClick={() => update(p)} disabled={busy === p.id} className="flex shrink-0 items-center gap-1.5 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs text-emerald-300 hover:bg-emerald-500/20 disabled:opacity-40">
{busy === p.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />} Update
</button>
);
}
return <span className="shrink-0 rounded-lg border border-white/5 bg-white/[0.02] px-3 py-1.5 text-xs text-white/35">Installed</span>;
})()}
</div>
))}
</div>
Expand Down
1 change: 1 addition & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
12 changes: 12 additions & 0 deletions web/src/lib/semver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// 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);
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;
}