diff --git a/app/admin/_tabs/marketplace.tsx b/app/admin/_tabs/marketplace.tsx index 7aefa46d..af2af802 100644 --- a/app/admin/_tabs/marketplace.tsx +++ b/app/admin/_tabs/marketplace.tsx @@ -2,8 +2,11 @@ import { useEffect, useState, useCallback } from 'react'; import Link from 'next/link'; -import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react'; +import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; +import { isVersionSatisfied } from '@/lib/version-compare'; + +const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; interface Extension { slug: string; @@ -94,6 +97,13 @@ export function MarketplaceTab() { }, [searchInput]); async function handleInstall(ext: Extension) { + if (ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion)) { + setMessage({ + type: 'error', + text: `"${ext.name}" requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`, + }); + return; + } setInstalling(ext.slug); setMessage(null); @@ -258,6 +268,8 @@ function ExtensionCard({ }) { const isPlugin = extension.type === 'plugin'; const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`; + const versionMismatch = !!extension.minAppVersion + && !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion); return (
@@ -346,12 +358,20 @@ function ExtensionCard({
-
+
{extension.installed ? ( Installed + ) : versionMismatch ? ( + + + Requires v{extension.minAppVersion}+ + ) : (
)} + {versionMismatch && ( +
+ +
+

Update Bulwark to install this extension

+

+ Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}. +

+
+
+ )} + {bundle.error && (
diff --git a/lib/__tests__/version-compare.test.ts b/lib/__tests__/version-compare.test.ts new file mode 100644 index 00000000..8d937398 --- /dev/null +++ b/lib/__tests__/version-compare.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { compareVersions, isVersionSatisfied } from '@/lib/version-compare'; + +describe('compareVersions', () => { + it('orders by major, minor, patch', () => { + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + expect(compareVersions('1.0.1', '1.0.0')).toBeGreaterThan(0); + expect(compareVersions('1.0.0', '1.0.1')).toBeLessThan(0); + expect(compareVersions('2.0.0', '1.9.9')).toBeGreaterThan(0); + expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0); + }); + + it('treats missing segments as 0', () => { + expect(compareVersions('1', '1.0.0')).toBe(0); + expect(compareVersions('1.2', '1.2.0')).toBe(0); + }); + + it('tolerates a leading v', () => { + expect(compareVersions('v1.6.7', '1.6.7')).toBe(0); + }); + + it('ignores pre-release / build metadata', () => { + expect(compareVersions('1.6.7-rc.1', '1.6.7')).toBe(0); + expect(compareVersions('1.6.7+build.5', '1.6.7')).toBe(0); + }); +}); + +describe('isVersionSatisfied', () => { + it('returns true when current >= required', () => { + expect(isVersionSatisfied('1.6.7', '1.6.7')).toBe(true); + expect(isVersionSatisfied('1.6.8', '1.6.7')).toBe(true); + expect(isVersionSatisfied('2.0.0', '1.9.9')).toBe(true); + }); + + it('returns false when current < required', () => { + expect(isVersionSatisfied('1.6.6', '1.6.7')).toBe(false); + expect(isVersionSatisfied('1.5.0', '1.6.0')).toBe(false); + expect(isVersionSatisfied('0.0.0', '1.0.0')).toBe(false); + }); + + it('treats empty / null / undefined required as no requirement', () => { + expect(isVersionSatisfied('1.0.0', '')).toBe(true); + expect(isVersionSatisfied('1.0.0', null)).toBe(true); + expect(isVersionSatisfied('1.0.0', undefined)).toBe(true); + }); +}); diff --git a/lib/version-compare.ts b/lib/version-compare.ts new file mode 100644 index 00000000..560fd99b --- /dev/null +++ b/lib/version-compare.ts @@ -0,0 +1,39 @@ +/** + * Lenient semver comparison for the marketplace's `minAppVersion` gate. + * + * Parses "major.minor.patch" (any segment may be missing — treated as 0) + * and ignores pre-release / build metadata. Returns negative, zero or + * positive in the same shape as Array.prototype.sort comparators. + * + * We intentionally do NOT pull in a full semver dependency: plugins + * declare minimum app versions as simple "X.Y.Z" strings and we only + * need a >= check. + */ +export function compareVersions(a: string, b: string): number { + const pa = parseVersion(a); + const pb = parseVersion(b); + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) return pa[i] - pb[i]; + } + return 0; +} + +function parseVersion(v: string): [number, number, number] { + const cleaned = String(v || '').trim().replace(/^v/i, ''); + // Drop pre-release / build metadata. + const core = cleaned.split(/[-+]/)[0]; + const parts = core.split('.').map((p) => { + const n = parseInt(p, 10); + return Number.isFinite(n) ? n : 0; + }); + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; +} + +/** + * True when `current` satisfies `required` (i.e. current >= required). + * Empty / null / undefined `required` is treated as no requirement. + */ +export function isVersionSatisfied(current: string, required: string | null | undefined): boolean { + if (!required) return true; + return compareVersions(current, required) >= 0; +}