fix: warn + block install when app version is below plugin's minAppVersion
This commit is contained in:
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
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 { 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 {
|
interface Extension {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -94,6 +97,13 @@ export function MarketplaceTab() {
|
|||||||
}, [searchInput]);
|
}, [searchInput]);
|
||||||
|
|
||||||
async function handleInstall(ext: Extension) {
|
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);
|
setInstalling(ext.slug);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
@@ -258,6 +268,8 @@ function ExtensionCard({
|
|||||||
}) {
|
}) {
|
||||||
const isPlugin = extension.type === 'plugin';
|
const isPlugin = extension.type === 'plugin';
|
||||||
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
|
||||||
|
const versionMismatch = !!extension.minAppVersion
|
||||||
|
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
|
||||||
@@ -346,12 +358,20 @@ function ExtensionCard({
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="px-4 pb-4 -mt-1">
|
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
|
||||||
{extension.installed ? (
|
{extension.installed ? (
|
||||||
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
<span className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium">
|
||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
Installed
|
Installed
|
||||||
</span>
|
</span>
|
||||||
|
) : versionMismatch ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 text-xs font-medium"
|
||||||
|
title={`Requires app v${extension.minAppVersion}+. You are running v${CURRENT_APP_VERSION}.`}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Requires v{extension.minAppVersion}+
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
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 PreviewData {
|
interface PreviewData {
|
||||||
extension: {
|
extension: {
|
||||||
@@ -200,6 +203,7 @@ export default function MarketplacePreviewPage() {
|
|||||||
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || ext.permissions || [];
|
||||||
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
|
||||||
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
|
||||||
|
const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
@@ -294,8 +298,11 @@ export default function MarketplacePreviewPage() {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleInstall}
|
onClick={handleInstall}
|
||||||
disabled={installing || !!bundle.error}
|
disabled={installing || !!bundle.error || versionMismatch}
|
||||||
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
title={versionMismatch
|
||||||
|
? `Requires app v${ext.minAppVersion}+. You are running v${CURRENT_APP_VERSION}. Update Bulwark to install.`
|
||||||
|
: undefined}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||||
Install
|
Install
|
||||||
@@ -310,6 +317,18 @@ export default function MarketplacePreviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{versionMismatch && (
|
||||||
|
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Update Bulwark to install this extension</p>
|
||||||
|
<p className="text-xs mt-0.5 opacity-90">
|
||||||
|
Requires app v{ext.minAppVersion}+. You are running v{CURRENT_APP_VERSION}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{bundle.error && (
|
{bundle.error && (
|
||||||
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
<div className="flex items-start gap-2 text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user