From 121a29372eef8dc9420f6f9611c6a80daf6a5254 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Tue, 5 May 2026 20:02:17 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20make=20the=20hub=20feel=20alive=20?= =?UTF-8?q?=E2=80=94=20outdated=20detection,=20live=20install/update=20out?= =?UTF-8?q?put,=20copyable=20doctor=20fixes,=20leaner=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- public/app.js | 434 ++++++++++++++++++++++++++++--------------- public/index.html | 51 +++-- public/style.css | 336 +++++++++++++++++++-------------- src/api.ts | 146 +++++++++------ src/fledge.ts | 60 ++++++ src/github.ts | 57 ++++++ src/semver.ts | 51 +++++ tests/semver.test.ts | 70 +++++++ 9 files changed, 833 insertions(+), 377 deletions(-) create mode 100644 src/semver.ts create mode 100644 tests/semver.test.ts diff --git a/README.md b/README.md index 7e578e2..31504ba 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,8 @@ bun run start ### Global (your fledge install) -- **Store** — browse plugins and templates from GitHub, install / update / remove from the UI -- **Installed** — manage installed plugins, see all available commands -- **Lanes** — global lane catalog +- **Store** — browse plugins and templates from GitHub with faceted filtering (topic, language, author, license); install / update / remove from the UI +- **Installed** — manage installed plugins - **Config** — global fledge configuration - **Doctor** — environment diagnostics with structured pass/warn/fail view diff --git a/public/app.js b/public/app.js index ce41f11..a98d9f0 100644 --- a/public/app.js +++ b/public/app.js @@ -126,10 +126,134 @@ function renderMarkdown(raw) { return text; } +// --- Ops console (live SSE output) --- + +const opsConsole = { + current: null, // EventSource + failed: false, + lastTitle: "", + + el() { return $("#ops-console"); }, + bodyEl() { return $("#ops-console-body"); }, + titleEl() { return $("#ops-console-title"); }, + + open(title) { + const el = this.el(); + if (!el) return; + this.lastTitle = title; + this.titleEl().textContent = title; + this.bodyEl().innerHTML = ""; + this.failed = false; + el.classList.remove("hidden", "collapsed", "ops-console-success", "ops-console-error"); + el.classList.add("ops-console-running"); + }, + + append(line, kind = "stdout") { + const body = this.bodyEl(); + if (!body) return; + const div = document.createElement("div"); + div.className = `ops-console-line ops-console-line-${kind}`; + div.textContent = line; + body.appendChild(div); + body.scrollTop = body.scrollHeight; + }, + + meta(message) { this.append(message, "meta"); }, + + finish(success, summary) { + const el = this.el(); + if (!el) return; + el.classList.remove("ops-console-running"); + el.classList.add(success ? "ops-console-success" : "ops-console-error"); + if (summary) this.titleEl().textContent = summary; + }, + + close() { + if (this.current) { + this.current.close(); + this.current = null; + } + this.el()?.classList.add("hidden"); + }, + + attach(source, { title, onSuccess, onFailure } = {}) { + if (this.current) this.current.close(); + this.open(title); + const url = new URL(source, location.origin); + const evt = new EventSource(url.toString()); + this.current = evt; + + const handle = (event) => { + try { + const payload = JSON.parse(event.data); + if (payload.kind === "stdout" || payload.kind === "stderr") { + this.append(payload.line ?? "", payload.kind); + } else if (payload.kind === "error") { + this.append(payload.message ?? "error", "error"); + } else if (payload.kind === "done") { + const success = payload.exitCode === 0; + this.finish(success, `${title} · ${success ? "ok" : `exit ${payload.exitCode}`}`); + this.current?.close(); + this.current = null; + (success ? onSuccess : onFailure)?.(payload); + } + } catch { + this.append(event.data, "error"); + } + }; + + evt.addEventListener("stdout", handle); + evt.addEventListener("stderr", handle); + evt.addEventListener("done", handle); + evt.addEventListener("error", (e) => { + // EventSource itself errored (network / closed). If we already saw + // a `done` event, this is a benign close; otherwise surface it. + if (this.current === evt) { + this.append("connection lost", "error"); + this.finish(false, `${title} · connection lost`); + this.current?.close(); + this.current = null; + onFailure?.({ exitCode: -1 }); + } + }); + }, +}; + // --- Toast --- let toastTimer = null; +async function copyToClipboard(text, btn) { + if (!text) return; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + // Fallback for non-secure contexts (e.g. http on a remote IP). + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + } + showToast("Copied", "success", 1500); + if (btn) { + const original = btn.textContent; + btn.textContent = "Copied"; + btn.classList.add("btn-copy-done"); + setTimeout(() => { + btn.textContent = original; + btn.classList.remove("btn-copy-done"); + }, 1200); + } + } catch (e) { + showToast(`Copy failed: ${e.message || e}`, "error"); + } +} + function showToast(message, kind = "info", durationMs = 3500) { let toast = $("#toast"); if (!toast) { @@ -150,6 +274,7 @@ function showToast(message, kind = "info", durationMs = 3500) { let installedBySource = new Map(); let installedByName = new Map(); +let outdatedByName = new Map(); async function refreshInstalled() { const list = await api("/plugins"); @@ -163,56 +288,78 @@ async function refreshInstalled() { return plugins; } +async function refreshOutdated() { + const data = await api("/plugins/outdated"); + const items = Array.isArray(data?.items) ? data.items : []; + outdatedByName = new Map(); + for (const it of items) { + if (it?.name) outdatedByName.set(it.name, it); + } + return outdatedByName; +} + function isInstalled(repoFullName) { return installedBySource.has(String(repoFullName || "").toLowerCase()); } +function outdatedFor(name) { + if (!name) return null; + const entry = outdatedByName.get(name); + return entry?.outdated ? entry : null; +} + // --- Actions --- -async function installRepo(fullName, btn) { - if (btn) { btn.disabled = true; btn.textContent = "Installing…"; } - showToast(`Installing ${fullName}…`, "info", 0); - const result = await postJSON("/plugins/install", { source: fullName }); - if (result?.success) { - showToast(`Installed ${fullName}`, "success"); - await refreshInstalled(); - invalidatePages(["installed", "store"]); - refreshActivePage(); - } else { - showToast(`Install failed: ${truncate(result?.error || "unknown", 200)}`, "error", 6000); - if (btn) { btn.disabled = false; btn.textContent = "Install"; } - } +function streamPluginOp({ url, title, restoreLabel, btn, successLabel }) { + if (btn) { btn.disabled = true; btn.dataset.originalLabel = btn.textContent; btn.textContent = restoreLabel; } + showToast(`${title}…`, "info", 0); + + opsConsole.attach(url, { + title, + onSuccess: async () => { + showToast(successLabel, "success"); + await refreshInstalled(); + await refreshOutdated().catch(() => {}); + invalidatePages(["installed", "store"]); + refreshActivePage(); + if (btn) { btn.disabled = false; btn.textContent = btn.dataset.originalLabel || btn.textContent; } + }, + onFailure: () => { + showToast(`${title} failed — see console`, "error", 6000); + if (btn) { btn.disabled = false; btn.textContent = btn.dataset.originalLabel || btn.textContent; } + }, + }); +} + +function installRepo(fullName, btn) { + streamPluginOp({ + url: `/api/plugins/install/stream?source=${encodeURIComponent(fullName)}`, + title: `Install ${fullName}`, + restoreLabel: "Installing…", + successLabel: `Installed ${fullName}`, + btn, + }); } -async function removePlugin(name, btn) { +function removePlugin(name, btn) { if (!confirm(`Remove ${name}?`)) return; - if (btn) { btn.disabled = true; btn.textContent = "Removing…"; } - showToast(`Removing ${name}…`, "info", 0); - const result = await postJSON("/plugins/remove", { name }); - if (result?.success) { - showToast(`Removed ${name}`, "success"); - await refreshInstalled(); - invalidatePages(["installed", "store"]); - refreshActivePage(); - } else { - showToast(`Remove failed: ${truncate(result?.error || "unknown", 200)}`, "error", 6000); - if (btn) { btn.disabled = false; btn.textContent = "Remove"; } - } + streamPluginOp({ + url: `/api/plugins/remove/stream?name=${encodeURIComponent(name)}`, + title: `Remove ${name}`, + restoreLabel: "Removing…", + successLabel: `Removed ${name}`, + btn, + }); } -async function updatePlugin(name, btn) { - if (btn) { btn.disabled = true; btn.textContent = "Updating…"; } - showToast(`Updating ${name}…`, "info", 0); - const result = await postJSON("/plugins/update", { name }); - if (result?.success) { - showToast(`Updated ${name}`, "success"); - await refreshInstalled(); - invalidatePages(["installed", "store"]); - refreshActivePage(); - } else { - showToast(`Update failed: ${truncate(result?.error || "unknown", 200)}`, "error", 6000); - if (btn) { btn.disabled = false; btn.textContent = "Update"; } - } +function updatePlugin(name, btn) { + streamPluginOp({ + url: `/api/plugins/update/stream${name ? `?name=${encodeURIComponent(name)}` : ""}`, + title: name ? `Update ${name}` : "Update all plugins", + restoreLabel: "Updating…", + successLabel: name ? `Updated ${name}` : "Updated plugins", + btn, + }); } function invalidatePages(names) { @@ -273,6 +420,42 @@ function statusBadge(status) { return `${escape(status || "?")}`; } +// --- Installed list --- + +function renderInstalledCard(p) { + const update = outdatedFor(p.name); + const updateBtn = update + ? `` + : ``; + + const versionBadge = p.version + ? update + ? `v${escape(p.version)} → ${escape(update.latest)}` + : `v${escape(p.version)}` + : ""; + + return ` +
+
${escape(p.name)}
+
${escape(p.source || "")}
+
+ ${trustBadge(p.source, p.trust_tier)} + ${versionBadge} + ${(p.commands || []).map((c) => `${escape(c)}`).join("")} +
+
+ ${updateBtn} + +
+
`; +} + +function renderInstalledList(plugins) { + const list = $("#installed-plugins-list"); + if (!list) return; + html(list, plugins.map(renderInstalledCard).join("")); +} + // --- Store card --- function renderStoreCard(repo) { @@ -282,10 +465,15 @@ function renderStoreCard(repo) { const installed = isInstalled(repo.full_name); const fullEsc = escape(repo.full_name || ""); const installedPlugin = installedBySource.get((repo.full_name || "").toLowerCase()); + const update = installedPlugin ? outdatedFor(installedPlugin.name) : null; + + const updateBtn = update + ? `` + : ``; const actions = installed ? ` - + ${updateBtn} ` : ` `; @@ -304,7 +492,7 @@ function renderStoreCard(repo) { : owner; return ` -
+
${owner}
@@ -314,7 +502,8 @@ function renderStoreCard(repo) {
${ownerLink} ${isOfficial ? 'official' : ""} - ${installed ? 'installed' : ""} + ${installed && !update ? 'installed' : ""} + ${update ? `v${escape(update.current)} → ${escape(update.latest)}` : ""}
@@ -555,7 +744,12 @@ function renderDoctor(report) { ${chk.version ? `${escape(chk.version)}` : ""}
${chk.detail ? `
${escape(chk.detail)}
` : ""} - ${chk.fix ? `
Fix: ${escape(chk.fix)}
` : ""} + ${chk.fix ? ` +
+ Fix + ${escape(chk.fix)} + +
` : ""}
`) .join(""); @@ -778,113 +972,35 @@ const loaders = { renderResultCount(typeof data?.total === "number" ? data.total : items.length); if (items.length === 0) { html(grid, renderEmpty("No packages match these filters. Try removing one or clearing all.")); - } else { - html(grid, items.map(renderStoreCard).join("")); + return; } + html(grid, items.map(renderStoreCard).join("")); + + // Outdated info hits GitHub once per installed plugin, so layer it on + // after the grid is already visible. + refreshOutdated().then(() => { + if ($("#page-store.active")) html(grid, items.map(renderStoreCard).join("")); + }); }, async installed() { - if (loaded.installed) return; - const [info, plugins, templates] = await Promise.all([ - api("/introspect"), - refreshInstalled(), - api("/templates"), - ]); - - const grid = $("#stats-grid"); - const cmds = Array.isArray(info?.commands) ? info.commands : []; - const pluginList = Array.isArray(plugins) ? plugins : []; - const templateList = Array.isArray(templates) ? templates : []; - const topLevel = cmds.length; - const subCmds = cmds.reduce((n, c) => n + (c.subcommands?.length || 0), 0); - - html(grid, ` -
${pluginList.length}
Plugins
-
${templateList.length}
Templates
-
${topLevel}
Commands
-
${subCmds}
Subcommands
`); - - const pluginsList = $("#installed-plugins-list"); - if (pluginList.length === 0) { - html(pluginsList, renderEmpty('No plugins installed. Run fledge plugins install --defaults')); - } else { - html(pluginsList, pluginList.map((p) => ` -
-
${escape(p.name)}
-
${escape(p.source || "")}
-
- ${trustBadge(p.source, p.trust_tier)} - ${p.version ? `v${escape(p.version)}` : ""} - ${(p.commands || []).map((c) => `${escape(c)}`).join("")} -
-
- - -
-
`).join("")); - } + const pluginList = await refreshInstalled(); + const list = $("#installed-plugins-list"); - const templatesList = $("#installed-templates-list"); - if (templateList.length === 0) { - html(templatesList, renderEmpty("No templates found")); - } else { - html(templatesList, templateList.map((t) => ` -
-
${escape(t.name || t.full_name || "unknown")}
-
${escape(t.description || "")}
-
- ${t.language ? `${escape(t.language)}` : ""} - ${t.stars != null ? `${t.stars} stars` : ""} -
-
`).join("")); - } - - const tree = $("#command-tree"); - if (cmds.length === 0) { - html(tree, renderEmpty("No commands found. Is fledge installed?")); - } else { - html(tree, cmds.map((cmd) => ` -
-
fledge ${escape(cmd.name)}
- ${cmd.about ? `
${escape(cmd.about)}
` : ""} - ${(cmd.subcommands || []).map((sub) => - `
${escape(sub.name)} ${sub.about ? "— " + escape(sub.about) : ""}
` - ).join("")} -
`).join("")); + if (pluginList.length === 0) { + html(list, renderEmpty('No plugins installed. Browse the Store or run fledge plugins install --defaults')); + loaded.installed = true; + return; } + renderInstalledList(pluginList); loaded.installed = true; - }, - - async lanes() { - if (loaded.lanes) return; - const data = await api("/lanes"); - const list = $("#lanes-list"); - - if (data?.error) { - html(list, renderError(typeof data.error === "string" ? data.error : JSON.stringify(data.error))); - return; - } - const lanes = Array.isArray(data) ? data : []; - if (lanes.length === 0) { - html(list, renderEmpty('No lanes defined. Add lanes to your fledge.toml')); - } else { - html(list, lanes.map((l) => { - const stepCount = typeof l.step_count === "number" ? l.step_count : (typeof l.steps === "number" ? l.steps : null); - return ` -
-
${escape(l.name)}
- ${l.description ? `
${escape(l.description)}
` : ""} -
- ${stepCount != null ? `${stepCount} step${stepCount === 1 ? "" : "s"}` : ""} - ${l.fail_fast ? 'fail-fast' : ""} - ${l.trust_tier ? `${escape(l.trust_tier)}` : ""} -
-
`; - }).join("")); - } - loaded.lanes = true; + // Outdated lookup hits GitHub once per plugin, so render the list first + // then re-render with badges as soon as the data arrives. + refreshOutdated().then(() => { + if ($("#page-installed.active")) renderInstalledList(pluginList); + }); }, async config() { @@ -922,8 +1038,17 @@ const loaders = { async doctor() { const wrap = $("#doctor-output"); + const meta = $("#doctor-meta"); + const btn = $("#doctor-rerun"); html(wrap, '
Running doctor
'); + if (meta) meta.textContent = ""; + if (btn) { btn.disabled = true; btn.textContent = "Running…"; } + const data = await api("/doctor"); + + if (btn) { btn.disabled = false; btn.textContent = "Re-run"; } + if (meta) meta.textContent = `Last run · ${new Date().toLocaleTimeString()}`; + if (data?.error) { html(wrap, renderError(typeof data.error === "string" ? data.error : JSON.stringify(data.error))); return; @@ -944,7 +1069,6 @@ const cmdPages = [ { label: "Overview", page: "project", hint: "Project" }, { label: "Store", page: "store", hint: "Browse packages" }, { label: "Installed", page: "installed", hint: "Plugins & templates" }, - { label: "Lanes", page: "lanes", hint: "Workflow pipelines" }, { label: "Config", page: "config", hint: "Global config" }, { label: "Doctor", page: "doctor", hint: "Diagnostics" }, ]; @@ -1040,6 +1164,12 @@ document.addEventListener("click", (e) => { if (search) search.value = ""; loaders.store(); } + else if (action === "copy") { + copyToClipboard(btn.dataset.text || "", btn); + } + else if (action === "doctor-rerun") { + loaders.doctor(); + } else if (action === "toggle-facet") { const key = btn.dataset.facet; if (key && key in facetExpanded) { @@ -1067,14 +1197,12 @@ $$(".category-tabs .tab").forEach((tab) => { }); }); -// Section tabs -$$(".section-tab").forEach((tab) => { - tab.addEventListener("click", () => { - $$(".section-tab").forEach((t) => t.classList.remove("active")); - tab.classList.add("active"); - $$(".installed-section").forEach((s) => s.classList.remove("active")); - $(`#installed-${tab.dataset.section}`)?.classList.add("active"); - }); +// In-page nav links (empty-state CTAs) +document.addEventListener("click", (e) => { + const link = e.target.closest("[data-nav]"); + if (!link) return; + e.preventDefault(); + navigate(link.dataset.nav); }); // Store search — debounced live filter; Enter forces an immediate run. @@ -1106,6 +1234,12 @@ $("#store-search").addEventListener("keydown", (e) => { $("#clear-filters")?.addEventListener("click", () => clearStoreFilters()); +// Ops console controls +$("#ops-console-clear")?.addEventListener("click", () => opsConsole.close()); +$("#ops-console-toggle")?.addEventListener("click", () => { + $("#ops-console")?.classList.toggle("collapsed"); +}); + // Modal close $(".modal-backdrop")?.addEventListener("click", () => { $("#readme-modal")?.classList.add("hidden"); diff --git a/public/index.html b/public/index.html index 41a33d4..c18555f 100644 --- a/public/index.html +++ b/public/index.html @@ -37,10 +37,6 @@ Installed -
  • - - Lanes -
  • Config @@ -103,33 +99,9 @@

    Browse Packages

    Installed

    Installed

    -

    Plugins, templates, and commands on this machine.

    +

    Plugins on this machine. Update or remove from here.

    -
    -
    - - - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - - -
    -
    -
    Lanes
    -

    Lanes

    -

    Composable workflow pipelines from your fledge configuration.

    -
    -
    +
    @@ -149,6 +121,10 @@

    Config

    Doctor

    Environment diagnostics and pre-flight checks.

    +
    + + +
    @@ -165,6 +141,21 @@

    + + +