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' });