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 @@ Environment diagnostics and pre-flight checks.
+
diff --git a/public/style.css b/public/style.css
index 4ee4e59..873900c 100644
--- a/public/style.css
+++ b/public/style.css
@@ -380,14 +380,14 @@ h3 {
/* === TABS === */
-.category-tabs, .section-tabs {
+.category-tabs {
display: flex;
gap: 0;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--border);
}
-.tab, .section-tab {
+.tab {
background: none;
border: none;
color: var(--text-faint);
@@ -402,16 +402,13 @@ h3 {
transition: color var(--transition), border-color var(--transition);
}
-.tab:hover, .section-tab:hover { color: var(--text-secondary); }
+.tab:hover { color: var(--text-secondary); }
-.tab.active, .section-tab.active {
+.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
-.installed-section { display: none; }
-.installed-section.active { display: block; }
-
/* === STORE LAYOUT === */
.store-layout {
@@ -660,6 +657,33 @@ h3 {
background: var(--ok);
}
+.store-card-outdated { border-color: rgba(96, 165, 250, 0.3); }
+.store-card-outdated::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 2px;
+ height: 100%;
+ background: var(--info);
+}
+
+.item-card-outdated {
+ border-color: rgba(96, 165, 250, 0.3);
+ position: relative;
+}
+
+.item-card-outdated::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 2px;
+ height: 100%;
+ background: var(--info);
+ border-radius: var(--radius) 0 0 var(--radius);
+}
+
.store-card-header {
display: flex;
gap: 0.6rem;
@@ -828,42 +852,7 @@ h3 {
.badge-version { background: transparent; color: var(--text-faint); border: 1px solid var(--border); font-weight: 400; text-transform: none; }
.badge-plugin { background: var(--accent-subtle); color: var(--accent); border: 1px solid var(--accent-muted); }
.badge-template { background: rgba(251, 191, 36, 0.06); color: var(--warn); border: 1px solid rgba(251, 191, 36, 0.15); }
-
-/* === STATS === */
-
-.stats-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
- gap: 0.6rem;
- margin-bottom: 1.5rem;
-}
-
-.stat-card {
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 1rem;
- transition: border-color var(--transition);
-}
-
-.stat-card:hover { border-color: var(--border-hover); }
-
-.stat-value {
- font-family: var(--font-display);
- font-weight: 700;
- font-size: 2rem;
- line-height: 1;
- letter-spacing: -0.03em;
-}
-
-.stat-label {
- font-family: var(--font-mono);
- font-size: 0.55rem;
- letter-spacing: 0.14em;
- text-transform: uppercase;
- color: var(--text-faint);
- margin-top: 0.35rem;
-}
+.badge-outdated { background: rgba(96, 165, 250, 0.08); color: var(--info); border: 1px solid rgba(96, 165, 250, 0.25); font-weight: 500; text-transform: none; letter-spacing: 0; }
/* === ITEM CARDS (installed) === */
@@ -914,91 +903,10 @@ h3 {
border-top: 1px solid var(--border-faint);
}
-/* === COMMAND TREE === */
-
-.command-tree { display: flex; flex-direction: column; gap: 0.5rem; }
-
-.cmd-group {
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 0.75rem 1rem;
-}
-
-.cmd-group-name {
- font-family: var(--font-mono);
- font-weight: 600;
- font-size: 0.8rem;
- color: var(--accent);
- margin-bottom: 0.35rem;
-}
+/* === CONFIG === */
-.cmd-item {
- font-family: var(--font-mono);
- font-size: 0.72rem;
- color: var(--text-faint);
- padding: 0.2rem 0 0.2rem 0.85rem;
- border-left: 1px solid var(--border);
- margin-left: 0.2rem;
-}
-
-.cmd-item span { color: var(--text-primary); }
-
-/* === LANES & CONFIG === */
+.config-list { display: flex; flex-direction: column; gap: 0.5rem; }
-.lanes-list, .config-list { display: flex; flex-direction: column; gap: 0.5rem; }
-
-.lane-card {
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 0.85rem 1rem;
- transition: border-color var(--transition);
-}
-
-.lane-card:hover { border-color: var(--border-hover); }
-
-.lane-name {
- font-family: var(--font-mono);
- font-weight: 600;
- color: var(--accent);
- font-size: 0.8rem;
- margin-bottom: 0.35rem;
-}
-
-.lane-desc {
- color: var(--text-secondary);
- font-size: 0.78rem;
- margin-bottom: 0.4rem;
- line-height: 1.45;
-}
-
-.lane-steps, .lane-meta {
- display: flex;
- gap: 0.3rem;
- flex-wrap: wrap;
- align-items: center;
-}
-
-.lane-meta { margin-top: 0.4rem; }
-
-.lane-step {
- font-family: var(--font-mono);
- font-size: 0.65rem;
- padding: 0.2rem 0.5rem;
- background: var(--bg-raised);
- border: 1px solid var(--border);
- border-radius: 3px;
- color: var(--text-secondary);
-}
-
-.lane-step-arrow {
- color: var(--text-faint);
- font-size: 0.65rem;
- font-family: var(--font-mono);
-}
-
-/* Config */
.config-path {
font-family: var(--font-mono);
@@ -1148,22 +1056,77 @@ h3 {
}
.doctor-check-fix {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 0.55rem;
font-family: var(--font-mono);
font-size: 0.7rem;
- color: var(--warn);
- margin-top: 0.25rem;
- padding: 0.35rem 0.5rem;
+ color: var(--text-secondary);
+ margin-top: 0.3rem;
+ padding: 0.4rem 0.55rem;
background: rgba(251, 191, 36, 0.04);
border-left: 2px solid var(--warn);
border-radius: 0 3px 3px 0;
}
-.doctor-check-fix strong {
+.doctor-check-fix-label {
font-weight: 600;
- letter-spacing: 0.06em;
+ letter-spacing: 0.1em;
text-transform: uppercase;
- font-size: 0.58rem;
- margin-right: 0.35rem;
+ font-size: 0.55rem;
+ color: var(--warn);
+}
+
+.doctor-check-fix-cmd {
+ font-size: 0.72rem;
+ color: var(--text-primary);
+ background: var(--bg-deep);
+ padding: 0.2rem 0.4rem;
+ border-radius: 3px;
+ border: 1px solid var(--border);
+ min-width: 0;
+ overflow-x: auto;
+ white-space: nowrap;
+}
+
+.btn-copy {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--text-faint);
+ padding: 0.2rem 0.55rem;
+ border-radius: 3px;
+ font-family: var(--font-mono);
+ font-size: 0.55rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ transition: color var(--transition), border-color var(--transition), background var(--transition);
+ flex-shrink: 0;
+}
+
+.btn-copy:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+ background: var(--accent-subtle);
+}
+
+.btn-copy-done {
+ color: var(--ok) !important;
+ border-color: rgba(74, 222, 128, 0.3) !important;
+ background: rgba(74, 222, 128, 0.06) !important;
+}
+
+.toolbar-inline {
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.toolbar-meta {
+ font-family: var(--font-mono);
+ font-size: 0.62rem;
+ color: var(--text-faint);
+ letter-spacing: 0.04em;
}
.status-pill {
@@ -1619,6 +1582,111 @@ h3 {
margin-right: 0.2rem;
}
+/* === OPS CONSOLE === */
+
+.ops-console {
+ position: fixed;
+ bottom: 0;
+ left: var(--sidebar-w);
+ right: 0;
+ background: var(--bg-base);
+ border-top: 1px solid var(--border-hover);
+ z-index: 250;
+ display: flex;
+ flex-direction: column;
+ max-height: 50vh;
+ box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.35);
+ animation: opsSlideUp 0.18s ease;
+}
+
+.ops-console.hidden { display: none; }
+
+.ops-console.collapsed { max-height: 32px; }
+.ops-console.collapsed .ops-console-body { display: none; }
+.ops-console.collapsed .ops-console-toggle::before { content: "▴"; }
+
+@keyframes opsSlideUp {
+ from { transform: translateY(10px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+.ops-console-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.4rem 0.85rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-surface);
+ flex-shrink: 0;
+}
+
+.ops-console-status {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ color: var(--text-secondary);
+ min-width: 0;
+}
+
+.ops-console-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ops-console-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--text-faint);
+ flex-shrink: 0;
+}
+
+.ops-console-running .ops-console-dot {
+ background: var(--info);
+ animation: opsPulse 1s infinite ease-in-out;
+}
+
+.ops-console-success .ops-console-dot { background: var(--ok); }
+.ops-console-error .ops-console-dot { background: var(--fail); }
+
+@keyframes opsPulse {
+ 0%, 100% { opacity: 0.35; transform: scale(0.8); }
+ 50% { opacity: 1; transform: scale(1.15); }
+}
+
+.ops-console-actions {
+ display: flex;
+ gap: 0.2rem;
+}
+
+.ops-console-body {
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ line-height: 1.55;
+ padding: 0.75rem 0.85rem;
+ margin: 0;
+ overflow-y: auto;
+ background: var(--bg-deep);
+ color: var(--text-secondary);
+ flex: 1;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.ops-console-line-stderr { color: var(--warn); }
+.ops-console-line-error { color: var(--fail); }
+.ops-console-line-meta {
+ color: var(--text-faint);
+ font-style: italic;
+}
+
+@media (max-width: 768px) {
+ .ops-console { left: 0; }
+}
+
/* === TOAST === */
.toast {
@@ -1768,11 +1836,9 @@ h3 {
.project-header { grid-template-columns: 1fr; }
.commit-meta { display: none; }
.commit-row { grid-template-columns: 3.5rem 1fr; }
- .stats-grid { grid-template-columns: repeat(2, 1fr); }
.doctor-summary { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
- .stats-grid { grid-template-columns: 1fr; }
.item-grid { grid-template-columns: 1fr; }
}
diff --git a/src/api.ts b/src/api.ts
index c86a5fb..ef8162e 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -1,15 +1,19 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Hono } from "hono";
-import { fledge, fledgeJson, projectCwd } from "./fledge";
+import type { Context } from "hono";
+import { streamSSE } from "hono/streaming";
+import { fledge, fledgeJson, fledgeStream, projectCwd } from "./fledge";
import {
browseAll,
browsePlugins,
browseTemplates,
computeFacets,
+ fetchLatestVersion,
getRepoReadme,
type BrowseFilters,
} from "./github";
+import { isOutdated } from "./semver";
import { gatherProjectInfo, openInBrowser } from "./project";
import { parseConfigList } from "./config";
@@ -38,13 +42,6 @@ function unwrap
(value: unknown, key: string): T[] | unknown {
return value;
}
-function isEmptyLaneError(value: unknown): boolean {
- if (!value || typeof value !== "object") return false;
- const err = (value as { error?: unknown }).error;
- if (typeof err !== "string") return false;
- return err.includes("No fledge.toml") || err.includes("No lanes defined");
-}
-
api.get("/project", async (c) => {
const info = await gatherProjectInfo();
return c.json(info);
@@ -103,71 +100,102 @@ api.get("/plugins", async (c) => {
return c.json(unwrap(result, "plugins"));
});
+interface InstalledPluginRow {
+ name?: unknown;
+ source?: unknown;
+ version?: unknown;
+}
+
+api.get("/plugins/outdated", async (c) => {
+ const list = await fledgeJson(["plugins", "list", "--json"]);
+ const plugins = unwrap(list, "plugins");
+ if (!Array.isArray(plugins)) return c.json({ items: [] });
+
+ const items = await Promise.all(
+ (plugins as InstalledPluginRow[]).map(async (p) => {
+ const name = typeof p.name === "string" ? p.name : "";
+ const source = typeof p.source === "string" ? p.source : "";
+ const current = typeof p.version === "string" ? p.version : "";
+ const slash = source.indexOf("/");
+ if (slash <= 0) {
+ return { name, source, current, latest: null, outdated: false };
+ }
+ const owner = source.slice(0, slash);
+ const repo = source.slice(slash + 1);
+ try {
+ const latest = await fetchLatestVersion(owner, repo);
+ return { name, source, current, latest, outdated: isOutdated(current, latest) };
+ } catch {
+ return { name, source, current, latest: null, outdated: false };
+ }
+ }),
+ );
+
+ return c.json({ items });
+});
+
api.get("/plugins/search", async (c) => {
const query = c.req.query("q") || "";
const result = await fledgeJson(["plugins", "search", query, "--json"]);
return c.json(unwrap(result, "results"));
});
-api.post("/plugins/install", async (c) => {
- const body: { source?: string; force?: boolean } = await c
- .req
- .json<{ source?: string; force?: boolean }>()
- .catch(() => ({}));
- if (!body.source) return c.json({ error: "source required" }, 400);
- // --yes is required because FLEDGE_NON_INTERACTIVE is set in the env and
- // install would otherwise bail on the trust-prompt.
- const args = ["plugins", "install", body.source, "--yes", "--json"];
- if (body.force) args.push("--force");
- const result = await fledge(args);
- return c.json({
- success: result.exitCode === 0,
- exitCode: result.exitCode,
- output: result.stdout,
- error: result.exitCode !== 0 ? result.stderr || result.stdout : null,
- });
-});
+// SSE-based streaming endpoints. EventSource only supports GET, so the
+// payload travels in the query string. Sources are validated to keep
+// shell-style metacharacters out of argv.
-api.post("/plugins/remove", async (c) => {
- const body: { name?: string } = await c.req.json<{ name?: string }>().catch(() => ({}));
- if (!body.name) return c.json({ error: "name required" }, 400);
- const result = await fledge(["plugins", "remove", body.name, "--json"]);
- return c.json({
- success: result.exitCode === 0,
- exitCode: result.exitCode,
- output: result.stdout,
- error: result.exitCode !== 0 ? result.stderr || result.stdout : null,
+const VALID_SOURCE = /^[A-Za-z0-9_./@:-]+$/;
+const VALID_NAME = /^[A-Za-z0-9_-]+$/;
+
+function streamFledge(c: Context, args: string[], opts: { cwd?: string; timeoutMs?: number } = {}) {
+ return streamSSE(c, async (stream) => {
+ let id = 0;
+ const send = async (event: { kind: string; line?: string; exitCode?: number; message?: string }) => {
+ await stream.writeSSE({
+ id: String(id++),
+ event: event.kind,
+ data: JSON.stringify(event),
+ });
+ };
+ try {
+ const exitCode = await fledgeStream(args, send, opts);
+ await send({ kind: "done", exitCode, message: exitCode === 0 ? "ok" : "failed" });
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : String(e);
+ await send({ kind: "error", message });
+ await send({ kind: "done", exitCode: -1, message });
+ }
});
+}
+
+api.get("/plugins/install/stream", (c) => {
+ const source = c.req.query("source") || "";
+ const force = c.req.query("force") === "1";
+ if (!source || !VALID_SOURCE.test(source)) {
+ return c.json({ error: "valid source required" }, 400);
+ }
+ const args = ["plugins", "install", source, "--yes", "--json"];
+ if (force) args.push("--force");
+ return streamFledge(c, args);
});
-api.post("/plugins/update", async (c) => {
- const body: { name?: string } = await c.req.json<{ name?: string }>().catch(() => ({}));
+api.get("/plugins/update/stream", (c) => {
+ const name = c.req.query("name") || "";
+ if (name && !VALID_NAME.test(name)) {
+ return c.json({ error: "invalid plugin name" }, 400);
+ }
const args = ["plugins", "update"];
- if (body.name) args.push(body.name);
+ if (name) args.push(name);
args.push("--json");
- const result = await fledge(args);
- return c.json({
- success: result.exitCode === 0,
- exitCode: result.exitCode,
- output: result.stdout,
- error: result.exitCode !== 0 ? result.stderr || result.stdout : null,
- });
+ return streamFledge(c, args);
});
-api.get("/templates", async (c) => {
- // Fledge 1.0 moved this under `templates search` and there is no longer a
- // top-level `fledge search` command. See `fledge templates --help`.
- const result = await fledgeJson(["templates", "search", "--json"]);
- return c.json(unwrap(result, "results"));
-});
-
-api.get("/lanes", async (c) => {
- // Lanes are project-scoped (declared in fledge.toml). Run in the project
- // cwd so a user launching `fledge hub` from a project sees their lanes.
- const cwd = projectCwd();
- const result = await fledgeJson(["lanes", "list", "--json"], { cwd });
- if (isEmptyLaneError(result)) return c.json([]);
- return c.json(unwrap(result, "lanes"));
+api.get("/plugins/remove/stream", (c) => {
+ const name = c.req.query("name") || "";
+ if (!name || !VALID_NAME.test(name)) {
+ return c.json({ error: "valid plugin name required" }, 400);
+ }
+ return streamFledge(c, ["plugins", "remove", name, "--json"]);
});
api.get("/config", async (c) => {
diff --git a/src/fledge.ts b/src/fledge.ts
index 36b6972..9ad836d 100644
--- a/src/fledge.ts
+++ b/src/fledge.ts
@@ -48,6 +48,66 @@ export async function spawnExec(cmd: string[], opts: ExecOptions = {}): Promise<
return { stdout, stderr, exitCode };
}
+export interface StreamEvent {
+ kind: "stdout" | "stderr" | "done" | "error";
+ line?: string;
+ exitCode?: number;
+ message?: string;
+}
+
+export type StreamHandler = (event: StreamEvent) => void | Promise;
+
+async function pumpLines(
+ stream: ReadableStream,
+ kind: "stdout" | "stderr",
+ send: StreamHandler,
+): Promise {
+ const reader = stream.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ let idx;
+ while ((idx = buffer.indexOf("\n")) !== -1) {
+ const line = buffer.slice(0, idx);
+ buffer = buffer.slice(idx + 1);
+ await send({ kind, line });
+ }
+ }
+ if (buffer.length > 0) await send({ kind, line: buffer });
+}
+
+export async function spawnStream(
+ cmd: string[],
+ send: StreamHandler,
+ opts: ExecOptions = {},
+): Promise {
+ const proc = Bun.spawn(cmd, {
+ stdout: "pipe",
+ stderr: "pipe",
+ cwd: opts.cwd,
+ env: { ...process.env, NO_COLOR: "1", FLEDGE_NON_INTERACTIVE: "1" },
+ });
+
+ const timer = opts.timeoutMs
+ ? setTimeout(() => proc.kill(), opts.timeoutMs)
+ : null;
+
+ await Promise.all([
+ pumpLines(proc.stdout, "stdout", send),
+ pumpLines(proc.stderr, "stderr", send),
+ ]);
+ const exitCode = await proc.exited;
+ if (timer) clearTimeout(timer);
+ return exitCode;
+}
+
+export async function fledgeStream(args: string[], send: StreamHandler, opts: ExecOptions = {}): Promise {
+ return spawnStream([FLEDGE_BIN, ...args], send, opts);
+}
+
export function projectCwd(): string {
return process.env.FLEDGE_HUB_CWD || process.cwd();
}
diff --git a/src/github.ts b/src/github.ts
index 6ae0e2a..cdd4d13 100644
--- a/src/github.ts
+++ b/src/github.ts
@@ -40,6 +40,63 @@ async function ghFetch(url: string): Promise {
return data;
}
+// `null` means "we asked GitHub and it had nothing". A thrown error means the
+// request itself failed — caller decides whether to surface or swallow.
+async function ghFetchOptional(url: string): Promise {
+ const hit = cached(url);
+ if (hit !== null && hit !== undefined) return hit;
+
+ const res = await fetch(url, {
+ headers: {
+ Accept: "application/vnd.github.v3+json",
+ "User-Agent": "fledge-plugin-hub",
+ },
+ });
+
+ if (res.status === 404) {
+ setCache(url, null);
+ return null;
+ }
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`GitHub API ${res.status}: ${text}`);
+ }
+
+ const data = await res.json();
+ setCache(url, data);
+ return data;
+}
+
+export async function fetchLatestVersion(owner: string, repo: string): Promise {
+ // Prefer GitHub Releases (richer metadata, the maintainer marked it).
+ // Fall back to the most recent tag, since plenty of fledge repos tag
+ // without cutting a Release.
+ const releaseUrl = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`;
+ try {
+ const release = await ghFetchOptional(releaseUrl);
+ if (release && typeof release === "object") {
+ const tag = (release as { tag_name?: unknown }).tag_name;
+ if (typeof tag === "string" && tag) return tag;
+ }
+ } catch {
+ // fall through to tags
+ }
+
+ const tagsUrl = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tags?per_page=1`;
+ try {
+ const tags = await ghFetchOptional(tagsUrl);
+ if (Array.isArray(tags) && tags.length > 0) {
+ const name = (tags[0] as { name?: unknown }).name;
+ if (typeof name === "string" && name) return name;
+ }
+ } catch {
+ return null;
+ }
+
+ return null;
+}
+
export interface GHRepo {
full_name: string;
name: string;
diff --git a/src/semver.ts b/src/semver.ts
new file mode 100644
index 0000000..6e98c8d
--- /dev/null
+++ b/src/semver.ts
@@ -0,0 +1,51 @@
+// Tiny semver comparator. Intentionally lenient — fledge plugins don't
+// always tag with strict semver, so we tolerate a leading "v", missing
+// patch/minor segments, and pre-release suffixes (treated as < release).
+
+export interface ParsedVersion {
+ major: number;
+ minor: number;
+ patch: number;
+ pre: string;
+}
+
+export function parseVersion(input: string | null | undefined): ParsedVersion | null {
+ if (!input) return null;
+ const trimmed = String(input).trim().replace(/^v/i, "");
+ if (!trimmed) return null;
+
+ const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+](.+))?$/.exec(trimmed);
+ if (!match) return null;
+
+ return {
+ major: Number(match[1]) || 0,
+ minor: match[2] ? Number(match[2]) : 0,
+ patch: match[3] ? Number(match[3]) : 0,
+ pre: match[4] || "",
+ };
+}
+
+export function compareVersions(a: string | null | undefined, b: string | null | undefined): number {
+ const pa = parseVersion(a);
+ const pb = parseVersion(b);
+
+ // Both unparseable → treat as equal (string compare would be misleading).
+ if (!pa && !pb) return 0;
+ if (!pa) return -1;
+ if (!pb) return 1;
+
+ if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
+ if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
+ if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
+
+ // Pre-release versions sort BEFORE the release. So 1.0.0-rc1 < 1.0.0.
+ if (pa.pre === pb.pre) return 0;
+ if (!pa.pre) return 1;
+ if (!pb.pre) return -1;
+ return pa.pre < pb.pre ? -1 : 1;
+}
+
+export function isOutdated(current: string | null | undefined, latest: string | null | undefined): boolean {
+ if (!current || !latest) return false;
+ return compareVersions(current, latest) < 0;
+}
diff --git a/tests/semver.test.ts b/tests/semver.test.ts
new file mode 100644
index 0000000..5afcd6a
--- /dev/null
+++ b/tests/semver.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, test } from "bun:test";
+import { compareVersions, isOutdated, parseVersion } from "../src/semver";
+
+describe("parseVersion", () => {
+ test("parses x.y.z", () => {
+ expect(parseVersion("1.2.3")).toEqual({ major: 1, minor: 2, patch: 3, pre: "" });
+ });
+
+ test("strips a leading v", () => {
+ expect(parseVersion("v0.7.0")).toEqual({ major: 0, minor: 7, patch: 0, pre: "" });
+ });
+
+ test("fills missing minor/patch with 0", () => {
+ expect(parseVersion("2")).toEqual({ major: 2, minor: 0, patch: 0, pre: "" });
+ expect(parseVersion("2.5")).toEqual({ major: 2, minor: 5, patch: 0, pre: "" });
+ });
+
+ test("captures a pre-release suffix", () => {
+ expect(parseVersion("1.0.0-rc1")).toEqual({ major: 1, minor: 0, patch: 0, pre: "rc1" });
+ expect(parseVersion("1.0.0+build.5")).toEqual({ major: 1, minor: 0, patch: 0, pre: "build.5" });
+ });
+
+ test("returns null for falsy / unparseable input", () => {
+ expect(parseVersion(null)).toBeNull();
+ expect(parseVersion("")).toBeNull();
+ expect(parseVersion("not-a-version")).toBeNull();
+ });
+});
+
+describe("compareVersions", () => {
+ test("returns 0 for equal versions", () => {
+ expect(compareVersions("1.2.3", "1.2.3")).toBe(0);
+ expect(compareVersions("v1.2.3", "1.2.3")).toBe(0);
+ });
+
+ test("orders by major then minor then patch", () => {
+ expect(compareVersions("1.0.0", "2.0.0")).toBe(-1);
+ expect(compareVersions("1.5.0", "1.4.9")).toBe(1);
+ expect(compareVersions("1.2.3", "1.2.4")).toBe(-1);
+ });
+
+ test("treats pre-release as less than the matching release", () => {
+ expect(compareVersions("1.0.0-rc1", "1.0.0")).toBe(-1);
+ expect(compareVersions("1.0.0", "1.0.0-rc1")).toBe(1);
+ });
+
+ test("treats two unparseable versions as equal, parseable wins otherwise", () => {
+ expect(compareVersions("xxx", "yyy")).toBe(0);
+ expect(compareVersions("xxx", "1.0.0")).toBe(-1);
+ expect(compareVersions("1.0.0", "xxx")).toBe(1);
+ });
+});
+
+describe("isOutdated", () => {
+ test("true when latest is greater", () => {
+ expect(isOutdated("1.0.0", "1.1.0")).toBe(true);
+ expect(isOutdated("v0.6.0", "v0.7.0")).toBe(true);
+ });
+
+ test("false when equal or current is greater", () => {
+ expect(isOutdated("1.0.0", "1.0.0")).toBe(false);
+ expect(isOutdated("2.0.0", "1.5.0")).toBe(false);
+ });
+
+ test("false when either side is missing", () => {
+ expect(isOutdated(null, "1.0.0")).toBe(false);
+ expect(isOutdated("1.0.0", null)).toBe(false);
+ expect(isOutdated("", "")).toBe(false);
+ });
+});