feat: marketplace update flow for installed plugins/themes

This commit is contained in:
Linus Rath
2026-05-22 00:11:10 +02:00
parent 08c85a42e1
commit ba4781910d
4 changed files with 157 additions and 38 deletions
+43 -9
View File
@@ -2,9 +2,9 @@
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, AlertTriangle } from 'lucide-react'; import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle, ArrowUpCircle } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { isVersionSatisfied } from '@/lib/version-compare'; import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
@@ -21,6 +21,7 @@ interface Extension {
minAppVersion: string | null; minAppVersion: string | null;
latestVersion: string | null; latestVersion: string | null;
installed: boolean; installed: boolean;
installedVersion: string | null;
iconUrl: string | null; iconUrl: string | null;
bannerUrl: string | null; bannerUrl: string | null;
author: { author: {
@@ -104,6 +105,8 @@ export function MarketplaceTab() {
}); });
return; return;
} }
const isUpdate = ext.installed;
const targetVersion = ext.latestVersion || '1.0.0';
setInstalling(ext.slug); setInstalling(ext.slug);
setMessage(null); setMessage(null);
@@ -113,7 +116,7 @@ export function MarketplaceTab() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
slug: ext.slug, slug: ext.slug,
version: ext.latestVersion || '1.0.0', version: targetVersion,
type: ext.type, type: ext.type,
}), }),
}); });
@@ -122,13 +125,22 @@ export function MarketplaceTab() {
if (res.ok) { if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` }); setMessage({
setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e)); type: 'success',
text: isUpdate
? `"${ext.name}" updated to v${targetVersion}${warnings}`
: `"${ext.name}" installed successfully${warnings}`,
});
setExtensions(prev => prev.map(e =>
e.slug === ext.slug
? { ...e, installed: true, installedVersion: targetVersion }
: e,
));
} else { } else {
setMessage({ type: 'error', text: data.error || 'Installation failed' }); setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
} }
} catch { } catch {
setMessage({ type: 'error', text: 'Installation failed - network error' }); setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally { } finally {
setInstalling(null); setInstalling(null);
} }
@@ -270,6 +282,11 @@ function ExtensionCard({
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`; const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
const versionMismatch = !!extension.minAppVersion const versionMismatch = !!extension.minAppVersion
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion); && !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
const updateAvailable = extension.installed
&& !!extension.installedVersion
&& !!extension.latestVersion
&& compareVersions(extension.latestVersion, extension.installedVersion) > 0
&& !versionMismatch;
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">
@@ -359,8 +376,25 @@ function ExtensionCard({
</Link> </Link>
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap"> <div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
{extension.installed ? ( {extension.installed && updateAvailable ? (
<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"> <button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
disabled={installing}
title={`Update from v${extension.installedVersion} to v${extension.latestVersion}`}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<ArrowUpCircle className="w-3 h-3" />
)}
Update to v{extension.latestVersion}
</button>
) : 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"
title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
>
<Check className="w-3 h-3" /> <Check className="w-3 h-3" />
Installed Installed
</span> </span>
+44 -8
View File
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { import {
ArrowLeft, ArrowLeft,
ArrowUpCircle,
Download, Download,
Loader2, Loader2,
Puzzle, Puzzle,
@@ -21,7 +22,7 @@ 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'; import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
@@ -73,6 +74,7 @@ interface PreviewData {
error: string | null; error: string | null;
}; };
installed: boolean; installed: boolean;
installedVersion: string | null;
} }
const RISKY_PERMISSIONS = new Set([ const RISKY_PERMISSIONS = new Set([
@@ -118,6 +120,8 @@ export default function MarketplacePreviewPage() {
async function handleInstall() { async function handleInstall() {
if (!data) return; if (!data) return;
const isUpdate = data.installed;
const targetVersion = data.extension.latestVersion || '1.0.0';
setInstalling(true); setInstalling(true);
setMessage(null); setMessage(null);
try { try {
@@ -126,20 +130,25 @@ export default function MarketplacePreviewPage() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
slug: data.extension.slug, slug: data.extension.slug,
version: data.extension.latestVersion || '1.0.0', version: targetVersion,
type: data.extension.type, type: data.extension.type,
}), }),
}); });
const body = await res.json(); const body = await res.json();
if (res.ok) { if (res.ok) {
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : ''; const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` }); setMessage({
setData(prev => prev ? { ...prev, installed: true } : prev); type: 'success',
text: isUpdate
? `"${data.extension.name}" updated to v${targetVersion}${warnings}`
: `"${data.extension.name}" installed${warnings}`,
});
setData(prev => prev ? { ...prev, installed: true, installedVersion: targetVersion } : prev);
} else { } else {
setMessage({ type: 'error', text: body.error || 'Installation failed' }); setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
} }
} catch { } catch {
setMessage({ type: 'error', text: 'Installation failed - network error' }); setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally { } finally {
setInstalling(false); setInstalling(false);
} }
@@ -204,6 +213,11 @@ export default function MarketplacePreviewPage() {
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); const versionMismatch = !!ext.minAppVersion && !isVersionSatisfied(CURRENT_APP_VERSION, ext.minAppVersion);
const updateAvailable = data.installed
&& !!data.installedVersion
&& !!ext.latestVersion
&& compareVersions(ext.latestVersion, data.installedVersion) > 0
&& !versionMismatch;
return ( return (
<div className="space-y-6 max-w-4xl"> <div className="space-y-6 max-w-4xl">
@@ -248,11 +262,22 @@ export default function MarketplacePreviewPage() {
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1> <h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />} {ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && ( {data.installed && !updateAvailable && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"> <span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"
title={data.installedVersion ? `Installed: v${data.installedVersion}` : undefined}
>
<Check className="w-3 h-3" /> Installed <Check className="w-3 h-3" /> Installed
</span> </span>
)} )}
{data.installed && updateAvailable && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 font-medium"
title={`Installed v${data.installedVersion} → v${ext.latestVersion} available`}
>
<ArrowUpCircle className="w-3 h-3" /> Update available
</span>
)}
</div> </div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap"> <div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${ <span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
@@ -279,6 +304,17 @@ export default function MarketplacePreviewPage() {
<div className="flex flex-wrap items-center gap-2 shrink-0"> <div className="flex flex-wrap items-center gap-2 shrink-0">
{data.installed ? ( {data.installed ? (
<> <>
{updateAvailable && (
<button
onClick={handleInstall}
disabled={installing || !!bundle.error}
title={`Update from v${data.installedVersion} to v${ext.latestVersion}`}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{installing ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUpCircle className="w-4 h-4" />}
Update to v{ext.latestVersion}
</button>
)}
<Link <Link
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'} href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors" className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
+6 -3
View File
@@ -149,9 +149,11 @@ export async function GET(
getPluginRegistry(), getPluginRegistry(),
getThemeRegistry(), getThemeRegistry(),
]); ]);
const installed = type === 'theme' const installedEntry = type === 'theme'
? themeRegistry.themes.some((t) => t.id === slug) ? themeRegistry.themes.find((t) => t.id === slug)
: pluginRegistry.plugins.some((p) => p.id === slug); : pluginRegistry.plugins.find((p) => p.id === slug);
const installed = installedEntry !== undefined;
const installedVersion = installedEntry?.version ?? null;
// 4. Build screenshot URLs (proxy through the directory's public files endpoint). // 4. Build screenshot URLs (proxy through the directory's public files endpoint).
const screenshots = Array.isArray(extension.screenshots) const screenshots = Array.isArray(extension.screenshots)
@@ -211,6 +213,7 @@ export async function GET(
error: bundleError, error: bundleError,
}, },
installed, installed,
installedVersion,
}, },
{ headers: { 'Cache-Control': 'no-store' } }, { headers: { 'Cache-Control': 'no-store' } },
); );
+64 -18
View File
@@ -5,6 +5,8 @@ import { logger } from '@/lib/logger';
import { import {
savePlugin, savePlugin,
saveTheme, saveTheme,
getPlugin,
getTheme,
getPluginRegistry, getPluginRegistry,
getThemeRegistry, getThemeRegistry,
type ServerPlugin, type ServerPlugin,
@@ -64,8 +66,12 @@ export async function GET(request: NextRequest) {
getThemeRegistry(), getThemeRegistry(),
]); ]);
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id)); const installedPluginVersions = new Map(
const installedThemes = new Set(themeRegistry.themes.map(t => t.id)); pluginRegistry.plugins.map(p => [p.id, p.version] as const),
);
const installedThemeVersions = new Map(
themeRegistry.themes.map(t => [t.id, t.version] as const),
);
const fileUrl = (path: unknown): string | null => const fileUrl = (path: unknown): string | null =>
typeof path === 'string' && path typeof path === 'string' && path
@@ -73,14 +79,19 @@ export async function GET(request: NextRequest) {
: null; : null;
if (data.data) { if (data.data) {
data.data = data.data.map((ext: Record<string, unknown>) => ({ data.data = data.data.map((ext: Record<string, unknown>) => {
...ext, const slug = ext.slug as string;
iconUrl: fileUrl(ext.iconPath), const installedVersion = ext.type === 'theme'
bannerUrl: fileUrl(ext.bannerPath), ? installedThemeVersions.get(slug) ?? null
installed: ext.type === 'theme' : installedPluginVersions.get(slug) ?? null;
? installedThemes.has(ext.slug as string) return {
: installedPlugins.has(ext.slug as string), ...ext,
})); iconUrl: fileUrl(ext.iconPath),
bannerUrl: fileUrl(ext.bannerPath),
installed: installedVersion !== null,
installedVersion,
};
});
} }
return NextResponse.json(data, { return NextResponse.json(data, {
@@ -200,6 +211,9 @@ export async function POST(request: NextRequest) {
warnings.push(...sanitized.warnings); warnings.push(...sanitized.warnings);
} }
const existingTheme = await getTheme(resolvedId);
const isUpdate = existingTheme !== null;
const theme: ServerTheme = { const theme: ServerTheme = {
id: resolvedId, id: resolvedId,
name: (manifest.name as string) || slug, name: (manifest.name as string) || slug,
@@ -207,15 +221,28 @@ export async function POST(request: NextRequest) {
author: (manifest.author as string) || 'Unknown', author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '', description: (manifest.description as string) || '',
variants: (manifest.variants as string[]) || ['light', 'dark'], variants: (manifest.variants as string[]) || ['light', 'dark'],
enabled: true, enabled: existingTheme?.enabled ?? true,
installedAt: now, ...(existingTheme?.forceEnabled !== undefined
? { forceEnabled: existingTheme.forceEnabled }
: {}),
installedAt: existingTheme?.installedAt ?? now,
updatedAt: now, updatedAt: now,
}; };
await saveTheme(theme, css); await saveTheme(theme, css);
await auditLog('marketplace.install_theme', { id: theme.id, name: theme.name, version: theme.version, slug }, ip); await auditLog(
isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme',
{
id: theme.id,
name: theme.name,
version: theme.version,
slug,
...(isUpdate ? { previousVersion: existingTheme.version } : {}),
},
ip,
);
return NextResponse.json({ success: true, theme, warnings }); return NextResponse.json({ success: true, theme, warnings, updated: isUpdate });
} else { } else {
// Plugin installation // Plugin installation
// Read entrypoint JS // Read entrypoint JS
@@ -297,6 +324,9 @@ export async function POST(request: NextRequest) {
); );
} }
const existingPlugin = await getPlugin(resolvedId);
const isUpdate = existingPlugin !== null;
const plugin: ServerPlugin = { const plugin: ServerPlugin = {
id: resolvedId, id: resolvedId,
name: (manifest.name as string) || slug, name: (manifest.name as string) || slug,
@@ -306,8 +336,11 @@ export async function POST(request: NextRequest) {
type: (manifest.type as string) || 'hook', type: (manifest.type as string) || 'hook',
permissions, permissions,
entrypoint, entrypoint,
enabled: true, enabled: existingPlugin?.enabled ?? true,
installedAt: now, ...(existingPlugin?.forceEnabled !== undefined
? { forceEnabled: existingPlugin.forceEnabled }
: {}),
installedAt: existingPlugin?.installedAt ?? now,
updatedAt: now, updatedAt: now,
...(manifest.configSchema && typeof manifest.configSchema === 'object' ...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
@@ -328,9 +361,22 @@ export async function POST(request: NextRequest) {
await savePlugin(plugin, code); await savePlugin(plugin, code);
invalidateFrameOriginsCache(); invalidateFrameOriginsCache();
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip); await auditLog(
isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
{
id: plugin.id,
name: plugin.name,
version: plugin.version,
slug,
frameOrigins: declaredFrameOrigins,
httpOrigins: declaredHttpOrigins,
apiPostPaths: declaredApiPostPaths,
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
},
ip,
);
return NextResponse.json({ success: true, plugin, warnings }); return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate });
} }
} catch (error) { } catch (error) {
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' }); logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });