From ba4781910d6be7c7e3987f4ef02b029d08852bae Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 22 May 2026 00:11:10 +0200
Subject: [PATCH] feat: marketplace update flow for installed plugins/themes
---
app/(main)/admin/_tabs/marketplace.tsx | 52 ++++++++++---
app/(main)/admin/marketplace/[slug]/page.tsx | 52 +++++++++++--
app/api/admin/marketplace/[slug]/route.ts | 9 ++-
app/api/admin/marketplace/route.ts | 82 +++++++++++++++-----
4 files changed, 157 insertions(+), 38 deletions(-)
diff --git a/app/(main)/admin/_tabs/marketplace.tsx b/app/(main)/admin/_tabs/marketplace.tsx
index af2af802..64fda763 100644
--- a/app/(main)/admin/_tabs/marketplace.tsx
+++ b/app/(main)/admin/_tabs/marketplace.tsx
@@ -2,9 +2,9 @@
import { useEffect, useState, useCallback } from 'react';
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 { 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';
@@ -21,6 +21,7 @@ interface Extension {
minAppVersion: string | null;
latestVersion: string | null;
installed: boolean;
+ installedVersion: string | null;
iconUrl: string | null;
bannerUrl: string | null;
author: {
@@ -104,6 +105,8 @@ export function MarketplaceTab() {
});
return;
}
+ const isUpdate = ext.installed;
+ const targetVersion = ext.latestVersion || '1.0.0';
setInstalling(ext.slug);
setMessage(null);
@@ -113,7 +116,7 @@ export function MarketplaceTab() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: ext.slug,
- version: ext.latestVersion || '1.0.0',
+ version: targetVersion,
type: ext.type,
}),
});
@@ -122,13 +125,22 @@ export function MarketplaceTab() {
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
- setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` });
- setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e));
+ setMessage({
+ 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 {
- setMessage({ type: 'error', text: data.error || 'Installation failed' });
+ setMessage({ type: 'error', text: data.error || (isUpdate ? 'Update failed' : 'Installation failed') });
}
} catch {
- setMessage({ type: 'error', text: 'Installation failed - network error' });
+ setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally {
setInstalling(null);
}
@@ -270,6 +282,11 @@ function ExtensionCard({
const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`;
const versionMismatch = !!extension.minAppVersion
&& !isVersionSatisfied(CURRENT_APP_VERSION, extension.minAppVersion);
+ const updateAvailable = extension.installed
+ && !!extension.installedVersion
+ && !!extension.latestVersion
+ && compareVersions(extension.latestVersion, extension.installedVersion) > 0
+ && !versionMismatch;
return (
@@ -359,8 +376,25 @@ function ExtensionCard({
- {extension.installed ? (
-
+ {extension.installed && updateAvailable ? (
+
+ ) : extension.installed ? (
+
Installed
diff --git a/app/(main)/admin/marketplace/[slug]/page.tsx b/app/(main)/admin/marketplace/[slug]/page.tsx
index 26fff536..e8095b05 100644
--- a/app/(main)/admin/marketplace/[slug]/page.tsx
+++ b/app/(main)/admin/marketplace/[slug]/page.tsx
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
import Link from 'next/link';
import {
ArrowLeft,
+ ArrowUpCircle,
Download,
Loader2,
Puzzle,
@@ -21,7 +22,7 @@ import {
ChevronUp,
} from 'lucide-react';
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';
@@ -73,6 +74,7 @@ interface PreviewData {
error: string | null;
};
installed: boolean;
+ installedVersion: string | null;
}
const RISKY_PERMISSIONS = new Set([
@@ -118,6 +120,8 @@ export default function MarketplacePreviewPage() {
async function handleInstall() {
if (!data) return;
+ const isUpdate = data.installed;
+ const targetVersion = data.extension.latestVersion || '1.0.0';
setInstalling(true);
setMessage(null);
try {
@@ -126,20 +130,25 @@ export default function MarketplacePreviewPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: data.extension.slug,
- version: data.extension.latestVersion || '1.0.0',
+ version: targetVersion,
type: data.extension.type,
}),
});
const body = await res.json();
if (res.ok) {
const warnings = body.warnings?.length ? ` (${body.warnings.length} warning(s))` : '';
- setMessage({ type: 'success', text: `"${data.extension.name}" installed${warnings}` });
- setData(prev => prev ? { ...prev, installed: true } : prev);
+ setMessage({
+ 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 {
- setMessage({ type: 'error', text: body.error || 'Installation failed' });
+ setMessage({ type: 'error', text: body.error || (isUpdate ? 'Update failed' : 'Installation failed') });
}
} catch {
- setMessage({ type: 'error', text: 'Installation failed - network error' });
+ setMessage({ type: 'error', text: isUpdate ? 'Update failed - network error' : 'Installation failed - network error' });
} finally {
setInstalling(false);
}
@@ -204,6 +213,11 @@ export default function MarketplacePreviewPage() {
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
const settingsSchema = bundle.manifest?.settingsSchema as Record | undefined;
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 (
@@ -248,11 +262,22 @@ export default function MarketplacePreviewPage() {
{ext.name}
{ext.featured &&
}
- {data.installed && (
-
+ {data.installed && !updateAvailable && (
+
Installed
)}
+ {data.installed && updateAvailable && (
+
+ Update available
+
+ )}
{data.installed ? (
<>
+ {updateAvailable && (
+
+ )}
t.id === slug)
- : pluginRegistry.plugins.some((p) => p.id === slug);
+ const installedEntry = type === 'theme'
+ ? themeRegistry.themes.find((t) => t.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).
const screenshots = Array.isArray(extension.screenshots)
@@ -211,6 +213,7 @@ export async function GET(
error: bundleError,
},
installed,
+ installedVersion,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts
index 907c37b7..ca31196d 100644
--- a/app/api/admin/marketplace/route.ts
+++ b/app/api/admin/marketplace/route.ts
@@ -5,6 +5,8 @@ import { logger } from '@/lib/logger';
import {
savePlugin,
saveTheme,
+ getPlugin,
+ getTheme,
getPluginRegistry,
getThemeRegistry,
type ServerPlugin,
@@ -64,8 +66,12 @@ export async function GET(request: NextRequest) {
getThemeRegistry(),
]);
- const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
- const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
+ const installedPluginVersions = new Map(
+ 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 =>
typeof path === 'string' && path
@@ -73,14 +79,19 @@ export async function GET(request: NextRequest) {
: null;
if (data.data) {
- data.data = data.data.map((ext: Record) => ({
- ...ext,
- iconUrl: fileUrl(ext.iconPath),
- bannerUrl: fileUrl(ext.bannerPath),
- installed: ext.type === 'theme'
- ? installedThemes.has(ext.slug as string)
- : installedPlugins.has(ext.slug as string),
- }));
+ data.data = data.data.map((ext: Record) => {
+ const slug = ext.slug as string;
+ const installedVersion = ext.type === 'theme'
+ ? installedThemeVersions.get(slug) ?? null
+ : installedPluginVersions.get(slug) ?? null;
+ return {
+ ...ext,
+ iconUrl: fileUrl(ext.iconPath),
+ bannerUrl: fileUrl(ext.bannerPath),
+ installed: installedVersion !== null,
+ installedVersion,
+ };
+ });
}
return NextResponse.json(data, {
@@ -200,6 +211,9 @@ export async function POST(request: NextRequest) {
warnings.push(...sanitized.warnings);
}
+ const existingTheme = await getTheme(resolvedId);
+ const isUpdate = existingTheme !== null;
+
const theme: ServerTheme = {
id: resolvedId,
name: (manifest.name as string) || slug,
@@ -207,15 +221,28 @@ export async function POST(request: NextRequest) {
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
variants: (manifest.variants as string[]) || ['light', 'dark'],
- enabled: true,
- installedAt: now,
+ enabled: existingTheme?.enabled ?? true,
+ ...(existingTheme?.forceEnabled !== undefined
+ ? { forceEnabled: existingTheme.forceEnabled }
+ : {}),
+ installedAt: existingTheme?.installedAt ?? now,
updatedAt: now,
};
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 {
// Plugin installation
// 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 = {
id: resolvedId,
name: (manifest.name as string) || slug,
@@ -306,8 +336,11 @@ export async function POST(request: NextRequest) {
type: (manifest.type as string) || 'hook',
permissions,
entrypoint,
- enabled: true,
- installedAt: now,
+ enabled: existingPlugin?.enabled ?? true,
+ ...(existingPlugin?.forceEnabled !== undefined
+ ? { forceEnabled: existingPlugin.forceEnabled }
+ : {}),
+ installedAt: existingPlugin?.installedAt ?? now,
updatedAt: now,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
@@ -328,9 +361,22 @@ export async function POST(request: NextRequest) {
await savePlugin(plugin, code);
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) {
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });