feat(sdk): support for plugin icons

This commit is contained in:
jubnl
2026-07-13 15:48:01 +02:00
parent ebc98f0a3f
commit bae98d1144
18 changed files with 1709 additions and 30 deletions
@@ -4,9 +4,10 @@ import {
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
Radio, Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass,
BookOpen, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText,
Radio, Luggage, Globe, Image, CalendarDays, Bell,
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText,
} from 'lucide-react'
import PluginIcon from '../shared/PluginIcon'
import { adminApi } from '../../api/client'
import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n'
@@ -70,6 +71,8 @@ interface RegistryItem {
repo: string
homepage?: string | null
type: string
/** Lucide icon name from the registry entry; absent → Blocks. */
icon?: string | null
latest: string | null
minTrekVersion: string | null
/** The latest version's declared TREK range; null on a legacy registry entry. */
@@ -171,11 +174,6 @@ const HEALTH: Record<string, string> = {
incompatible: 'bg-warning',
}
// Manifest `icon` is a lucide name; map the common ones, fall back to Blocks.
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
Luggage, Plane, Globe, Image, CalendarDays, Map, Bell, Cloud, Camera, Compass, BookOpen, Wallet, Puzzle, Blocks,
}
// Known permissions → human-readable i18n key; unknown ones render as raw code.
const PERM_KEYS = [
'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:read:files:content',
@@ -297,11 +295,6 @@ function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: s
return { blocked: true, label: t('admin.plugins.incompatible'), title }
}
function PluginIcon({ name, size = 20, className }: { name: string | null; size?: number; className?: string }) {
const Icon = (name && Object.prototype.hasOwnProperty.call(ICON_MAP, name) && ICON_MAP[name]) || Blocks
return <Icon size={size} className={className} />
}
function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) {
if (compact) return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
return (
@@ -1340,7 +1333,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
</span>
)}
<div className="absolute left-3 -bottom-4 w-11 h-11 rounded-xl bg-surface-card border border-edge grid place-items-center shadow-card z-[1]">
<PluginIcon name={item.type === 'widget' ? 'Blocks' : null} size={22} className="text-content-secondary" />
<PluginIcon name={item.icon} size={22} className="text-content-secondary" />
</div>
</div>
<div className="pt-6 px-3.5 pb-3.5 flex flex-col flex-1">
@@ -21,6 +21,7 @@ import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { usePluginStore } from '../../store/pluginStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import BottomNav from './BottomNav';
@@ -113,4 +114,21 @@ describe('BottomNav', () => {
await user.click(screen.getByRole('button', { name: 'Add expense' }));
expect(mockNavigate).toHaveBeenCalledWith('/trips/42?create=expense');
});
it('FE-COMP-BOTTOMNAV-011: page plugin renders the icon its manifest declares', () => {
seedStore(usePluginStore, {
plugins: [{ id: 'trip-doctor', name: 'Trip Doctor', type: 'page', icon: 'Stethoscope' }],
});
const { container } = render(<BottomNav />);
expect(screen.getByText('Trip Doctor')).toBeInTheDocument();
expect(container.querySelector('.lucide-stethoscope')).not.toBeNull();
});
it('FE-COMP-BOTTOMNAV-012: page plugin with an unknown icon falls back to Blocks', () => {
seedStore(usePluginStore, {
plugins: [{ id: 'bogus', name: 'Bogus', type: 'page', icon: 'NotAnIcon' }],
});
const { container } = render(<BottomNav />);
expect(container.querySelector('.lucide-blocks')).not.toBeNull();
});
});
+3 -2
View File
@@ -3,8 +3,9 @@ import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus, Blocks } from 'lucide-react'
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { resolvePluginIcon } from '../shared/pluginIcon'
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
@@ -65,7 +66,7 @@ export default function BottomNav() {
const nav = ADDON_NAV[addon.id]
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
}),
...pagePlugins.map(p => ({ to: `/plugins/${p.id}`, label: p.name, icon: Blocks })),
...pagePlugins.map(p => ({ to: `/plugins/${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })),
]
// Split the items so the raised "+" sits dead centre.
const splitAt = Math.ceil(items.length / 2)
@@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { usePluginStore } from '../../store/pluginStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import Navbar from './Navbar';
@@ -304,4 +305,21 @@ describe('Navbar', () => {
await user.click(screen.getByText('adminuser'));
expect(screen.getByText('Administrator')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-034: page plugin renders the icon its manifest declares', () => {
seedStore(usePluginStore, {
plugins: [{ id: 'trip-doctor', name: 'Trip Doctor', type: 'page', icon: 'Stethoscope' }],
});
const { container } = render(<Navbar />);
expect(screen.getByRole('link', { name: /trip doctor/i })).toBeInTheDocument();
expect(container.querySelector('.lucide-stethoscope')).not.toBeNull();
});
it('FE-COMP-NAVBAR-035: page plugin with an unknown icon falls back to Blocks', () => {
seedStore(usePluginStore, {
plugins: [{ id: 'bogus', name: 'Bogus', type: 'page', icon: 'NotAnIcon' }],
});
const { container } = render(<Navbar />);
expect(container.querySelector('.lucide-blocks')).not.toBeNull();
});
});
+3 -2
View File
@@ -6,9 +6,10 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark, Blocks } from 'lucide-react'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
import { resolvePluginIcon } from '../shared/pluginIcon'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
@@ -150,7 +151,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
>
{[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: Blocks }))
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) }))
].map(tab => {
const isActive = location.pathname === tab.path
return (
@@ -1,4 +1,4 @@
import { Blocks } from 'lucide-react'
import PluginIcon from '../shared/PluginIcon'
import PluginFrame from './PluginFrame'
import type { ActivePlugin } from '../../store/pluginStore'
@@ -35,7 +35,7 @@ export default function PluginWidgets({ plugins, tripId = null }: { plugins: Act
color: 'var(--ink-3)',
}}
>
<Blocks size={14} style={{ flexShrink: 0 }} />
<PluginIcon name={p.icon} size={14} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
</div>
{/* min-height is just a pre-resize floor; trek:resize drives the real height. */}
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { Blocks, Save, Loader2, Link2, Unlink, CheckCircle } from 'lucide-react'
import { Save, Loader2, Link2, Unlink, CheckCircle } from 'lucide-react'
import PluginIcon from '../shared/PluginIcon'
import { pluginsApi, type PluginUserSettingField, type PluginAction } from '../../api/client'
import { usePluginStore } from '../../store/pluginStore'
import { useToast } from '../shared/Toast'
@@ -57,7 +58,7 @@ const SECRET_MASK = '••••••••'
* here; the field list is trusted, validated manifest data. Secrets stay write-only
* (masked, never echoed back). One form per active plugin that declares user fields.
*/
function PluginSettingsForm({ id, name }: { id: string; name: string }) {
function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon: string | null }) {
const { t } = useTranslation()
const toast = useToast()
const [fields, setFields] = useState<PluginUserSettingField[] | null>(null)
@@ -134,7 +135,7 @@ function PluginSettingsForm({ id, name }: { id: string; name: string }) {
return (
<div className="rounded-xl border border-border bg-surface p-5">
<div className="flex items-center gap-2 mb-4">
<Blocks className="w-4 h-4 text-content-secondary" />
<PluginIcon name={icon} className="w-4 h-4 text-content-secondary" />
<h3 className="text-sm font-semibold text-content">{name}</h3>
</div>
<div className="space-y-4">
@@ -232,7 +233,7 @@ export default function PluginSettingsTab() {
</div>
{plugins.length === 0
? <p className="text-sm text-content-muted">{t('settings.plugins.empty')}</p>
: plugins.map(p => <PluginSettingsForm key={p.id} id={p.id} name={p.name} />)}
: plugins.map(p => <PluginSettingsForm key={p.id} id={p.id} name={p.name} icon={p.icon} />)}
<PluginActivityPanel />
</div>
)
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Blocks, Stethoscope } from 'lucide-react'
import { resolvePluginIcon } from './pluginIcon'
import PluginIcon from './PluginIcon'
describe('resolvePluginIcon', () => {
it('resolves a lucide name outside the old 14-icon allowlist', () => {
expect(resolvePluginIcon('Stethoscope')).toBe(Stethoscope)
})
it('falls back to Blocks for a missing or unknown name', () => {
expect(resolvePluginIcon(null)).toBe(Blocks)
expect(resolvePluginIcon(undefined)).toBe(Blocks)
expect(resolvePluginIcon('')).toBe(Blocks)
expect(resolvePluginIcon('Stethscope')).toBe(Blocks)
})
// A manifest must not be able to reach lucide's non-icon exports.
it.each(['icons', 'createLucideIcon', 'default', 'module.exports'])(
'falls back to Blocks for the non-icon export %s',
(name) => expect(resolvePluginIcon(name)).toBe(Blocks),
)
it('returns a stable reference so nav arrays do not remount the icon', () => {
expect(resolvePluginIcon('Stethoscope')).toBe(resolvePluginIcon('Stethoscope'))
})
})
describe('PluginIcon', () => {
it('renders the plugin-declared icon', () => {
const { container } = render(<PluginIcon name="Stethoscope" />)
expect(container.querySelector('.lucide-stethoscope')).not.toBeNull()
})
it('renders Blocks when the plugin declares nothing', () => {
const { container } = render(<PluginIcon name={null} />)
expect(container.querySelector('.lucide-blocks')).not.toBeNull()
})
})
@@ -0,0 +1,13 @@
import type { CSSProperties } from 'react'
import { resolvePluginIcon } from './pluginIcon'
/** Renders the lucide icon a plugin declares in its manifest (Blocks if unknown). */
export default function PluginIcon({ name, size = 20, className, style }: {
name: string | null | undefined
size?: number
className?: string
style?: CSSProperties
}) {
const Icon = resolvePluginIcon(name)
return <Icon size={size} className={className} style={style} />
}
@@ -0,0 +1,22 @@
import * as LucideIcons from 'lucide-react'
import { Blocks, type LucideIcon } from 'lucide-react'
// Lucide names are PascalCase. Restricting the lookup to that shape keeps a manifest
// from reaching lucide's non-icon exports (`createLucideIcon`, `icons`, `default`,
// `module.exports`), which are all lowercase-initial.
const LUCIDE_ICON_NAME = /^[A-Z][A-Za-z0-9]*$/
/**
* Resolves a plugin manifest's `icon` (a lucide icon name, e.g. "Stethoscope") to the
* matching lucide component, falling back to Blocks for a missing or unknown name.
*
* Note lucide icons are forwardRef objects, not functions — never narrow this with
* `typeof x === 'function'`. The lookup returns a module-level reference, so the
* component identity is stable across renders and is safe to call inline while
* building nav/tab arrays.
*/
export function resolvePluginIcon(name: string | null | undefined): LucideIcon {
if (!name || !LUCIDE_ICON_NAME.test(name)) return Blocks
const icon = (LucideIcons as unknown as Record<string, LucideIcon | undefined>)[name]
return icon ?? Blocks
}
@@ -5,7 +5,8 @@ import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { getCached, fetchPhoto } from '../../services/photoService'
import { useToast } from '../../components/shared/Toast'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Blocks } from 'lucide-react'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
import { resolvePluginIcon } from '../../components/shared/pluginIcon'
import { useTranslation, translateApiError } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
@@ -127,8 +128,8 @@ export function useTripPlanner() {
// Positioned plugin tabs splice in ascending order so two positions stay stable;
// the rest append, exactly as before this capability existed.
const positioned = tripPagePlugins.filter(p => p.tripPage?.position != null).sort((a, b) => (a.tripPage!.position! - b.tripPage!.position!))
for (const p of positioned) TRIP_TABS.splice(Math.min(p.tripPage!.position!, TRIP_TABS.length), 0, { id: `plugin:${p.id}`, label: p.name, icon: Blocks })
for (const p of tripPagePlugins.filter(p => p.tripPage?.position == null)) TRIP_TABS.push({ id: `plugin:${p.id}`, label: p.name, icon: Blocks })
for (const p of positioned) TRIP_TABS.splice(Math.min(p.tripPage!.position!, TRIP_TABS.length), 0, { id: `plugin:${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })
for (const p of tripPagePlugins.filter(p => p.tripPage?.position == null)) TRIP_TABS.push({ id: `plugin:${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })
const [activeTab, setActiveTab] = useState<string>(() => {
const saved = sessionStorage.getItem(`trip-tab-${tripId}`)
@@ -0,0 +1,36 @@
// Regenerates src/lucide-icon-names.ts from the lucide-react version installed at
// the repo root. Run from plugin-sdk/: `node scripts/gen-lucide-icon-names.mjs`.
//
// The SDK ships no lucide dependency of its own (it is a dependency-light CLI), so
// the name list is a checked-in snapshot rather than a runtime lookup — same shape
// as KNOWN_ADDONS in src/manifest.ts.
import { writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const require = createRequire(join(here, '..', '..', 'package.json'));
const dynamicIconImports = require('lucide-react/dynamicIconImports.js');
const iconsByKebabName = dynamicIconImports.default ?? dynamicIconImports;
const { version } = require('lucide-react/package.json');
const toPascalCase = (kebab) =>
kebab.split('-').map((part) => part[0].toUpperCase() + part.slice(1)).join('');
const names = Object.keys(iconsByKebabName).map(toPascalCase).sort();
const source = `// GENERATED — do not edit by hand.
// Snapshot of every lucide icon name (lucide-react ${version}), used by
// \`trek-plugin validate\` to WARN on a manifest \`icon\` lucide doesn't know —
// never a hard error (the host falls back to Blocks, and a plugin built for a
// newer TREK may name an icon this SDK predates). Regenerate with
// \`node scripts/gen-lucide-icon-names.mjs\`.
export const LUCIDE_ICON_NAMES: ReadonlySet<string> = new Set([
${names.map((n) => ' ' + JSON.stringify(n) + ',').join('\n')}
]);
`;
writeFileSync(join(here, '..', 'src', 'lucide-icon-names.ts'), source);
console.log(`wrote ${names.length} icon names from lucide-react ${version}`);
+7 -2
View File
@@ -36,7 +36,7 @@ interface Version {
}
interface Entry {
id: string; name: string; author: string; description: string; repo: string;
homepage?: string; tags?: string[]; type: string; authorPublicKey?: string; versions: Version[];
homepage?: string; tags?: string[]; type: string; icon?: string; authorPublicKey?: string; versions: Version[];
}
/**
@@ -114,7 +114,11 @@ export function buildEntry(opts: {
throw new Error('this plugin was published signed — sign the update too (pass --sign) or TREK will refuse it.');
}
const versions = [version, ...existing.versions.filter((v) => v.version !== version.version)];
return { ...existing, authorPublicKey: authorPublicKey ?? existing.authorPublicKey, versions };
// Refresh the icon from the manifest when this release declares one (an author who adds
// or changes it should see it in the store), but never wipe an icon the entry already
// carries just because the manifest omits it.
const icon = typeof manifest.icon === 'string' && manifest.icon ? manifest.icon : existing.icon;
return { ...existing, icon, authorPublicKey: authorPublicKey ?? existing.authorPublicKey, versions };
}
const entry: Entry = {
@@ -126,6 +130,7 @@ export function buildEntry(opts: {
type: String(manifest.type),
versions: [version],
};
if (typeof manifest.icon === 'string' && manifest.icon) entry.icon = manifest.icon;
if (typeof manifest.homepage === 'string') entry.homepage = manifest.homepage;
if (Array.isArray(manifest.tags)) entry.tags = manifest.tags.map(String).slice(0, 8);
if (authorPublicKey) entry.authorPublicKey = authorPublicKey;
+9
View File
@@ -8,6 +8,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { validateManifest, isUnboundedRange, KNOWN_ADDONS } from '../manifest.js';
import { LUCIDE_ICON_NAMES } from '../lucide-icon-names.js';
import { readJsonFile } from './json.js';
export interface ValidateReport {
@@ -45,6 +46,14 @@ export function validatePluginDir(dir: string): ValidateReport {
for (const a of res.manifest?.requiredAddons ?? []) {
if (!KNOWN_ADDONS.includes(a)) warnings.push(`requiredAddons: "${a}" is not a known TREK addon on this SDK version`);
}
// Same deal for `icon`: TREK resolves it against lucide at render time and falls
// back to Blocks on a name it can't find, so a typo is silent in the UI. Warn
// rather than error — the list is a snapshot, and a newer lucide may have names
// this SDK predates.
const icon = typeof raw.icon === 'string' ? raw.icon : '';
if (icon && !LUCIDE_ICON_NAMES.has(icon)) {
warnings.push(`icon: "${icon}" is not a known lucide icon name on this SDK version — TREK will fall back to Blocks`);
}
// TREK renders its UI with lucide icons only. It STRIPS emojis from the declarative
// text your hooks return (badges, columns, warnings, PDF sections, map/calendar/photo
// labels) and from notifications, so emojis in that text simply vanish at render.
File diff suppressed because it is too large Load Diff
+69
View File
@@ -409,6 +409,32 @@ describe('scaffold + validate CLIs', () => {
expect(r.warnings.some((w) => /placeholder|screenshot/.test(w))).toBe(true); // README is the unfilled template
});
// TREK resolves `icon` against lucide at render time and falls back to Blocks on a
// name it can't find, so a typo is invisible in the UI — validate has to surface it.
it('warns on an icon lucide does not know, without failing validation', () => {
scaffold('icon-plug', 'widget', tmp);
const dir = path.join(tmp, 'icon-plug');
const file = path.join(dir, 'trek-plugin.json');
const m = JSON.parse(fs.readFileSync(file, 'utf8'));
fs.writeFileSync(file, JSON.stringify({ ...m, icon: 'Stethscope' }, null, 2));
const r = validatePluginDir(dir);
expect(r.ok).toBe(true); // a bad icon is never a hard error
expect(r.warnings.some((w) => /icon: "Stethscope" is not a known lucide icon name/.test(w))).toBe(true);
});
it('accepts any real lucide icon name, not just a curated few', () => {
scaffold('icon-plug', 'widget', tmp);
const dir = path.join(tmp, 'icon-plug');
const file = path.join(dir, 'trek-plugin.json');
const m = JSON.parse(fs.readFileSync(file, 'utf8'));
fs.writeFileSync(file, JSON.stringify({ ...m, icon: 'Stethoscope' }, null, 2));
const r = validatePluginDir(dir);
expect(r.ok).toBe(true);
expect(r.warnings.some((w) => /lucide icon name/.test(w))).toBe(false);
});
it('scaffolds a client that opts into the design kit via the marker (source stays one line)', () => {
scaffold('kit-plug', 'widget', tmp);
const html = fs.readFileSync(path.join(tmp, 'kit-plug', 'client', 'index.html'), 'utf8');
@@ -618,6 +644,49 @@ describe('pack + entry (publishing automation)', () => {
expect(v.nativeModules).toBe(false);
});
// The store tile reads `icon` off the registry ENTRY (the index has no manifest to consult),
// so if the entry generator drops it, every plugin in the store renders as a generic Blocks.
it('carries the manifest icon onto the registry entry', () => {
const out = path.join(tmp, 'plugin.zip');
packPluginDir(koffi, out);
const entry = buildEntry({
dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out,
commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z',
});
expect(entry.icon).toBe('Luggage'); // koffi's trek-plugin.json declares it
});
it('--merge refreshes the icon from the manifest, but never wipes one the entry already carries', () => {
const out = path.join(tmp, 'plugin.zip');
packPluginDir(koffi, out);
const prior = (icon: string) => {
const p = path.join(tmp, `prior-${icon}.json`);
fs.writeFileSync(p, JSON.stringify({
id: 'koffi', name: 'Koffi', author: 'TREK', description: 'x',
repo: 'mauriceboe/trek-plugin-koffi', type: 'widget', icon,
versions: [{ version: '0.9.0', gitTag: 'v0.9.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/y.zip', sha256: 'c'.repeat(64), size: 10, apiVersion: 1, nativeModules: false, publishedAt: '2026-01-01T00:00:00.000Z' }],
}));
return p;
};
// koffi's manifest declares Luggage → an author who rebrands sees it in the store
const rebranded = buildEntry({
dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out,
commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z', mergePath: prior('Coffee'),
});
expect(rebranded.icon).toBe('Luggage');
// a manifest that declares NO icon leaves the entry's existing one alone
const noIconOpts = entryOptsFor('no-icon', '>=3.2.0 <4.0.0');
const keepPath = path.join(tmp, 'keep.json');
fs.writeFileSync(keepPath, JSON.stringify({
id: 'no-icon', name: 'No Icon', author: 'TREK', description: 'x', repo: 'a/b',
type: 'integration', icon: 'Coffee', versions: [],
}));
const kept = buildEntry({ ...noIconOpts, now: '2026-07-04T00:00:00.000Z', mergePath: keepPath });
expect(kept.icon).toBe('Coffee');
});
it('publishes an upper-bound-only range verbatim, with no floor to get wrong', () => {
// This is why the derived floor is gone. "<4.0.0" has a first X.Y.Z of 4.0.0 but a lower
// bound of 0.0.0, and the old regex published 4.0.0 as the MINIMUM — advertising a plugin
@@ -68,6 +68,8 @@ export interface RegistryEntry {
homepage?: string;
tags?: string[];
type: string;
/** A lucide icon name for the store tile; absent means the client's default (Blocks). */
icon?: string;
reviewedAt?: string | null;
/** base64 minisign author public key — stable across versions, TOFU-pinned. */
authorPublicKey?: string;
@@ -176,7 +178,7 @@ export class PluginRegistryService {
const latest = p.versions[0] ?? null;
return {
id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo,
homepage: p.homepage, tags: p.tags, type: p.type, reviewedAt: p.reviewedAt ?? null,
homepage: p.homepage, tags: p.tags, type: p.type, icon: p.icon, reviewedAt: p.reviewedAt ?? null,
downloadCount: p.downloadCount ?? null,
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
@@ -68,7 +68,7 @@ const REGISTRY = {
plugins: [
{
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight',
type: 'widget', reviewedAt: '2026-06-20',
type: 'widget', icon: 'Plane', reviewedAt: '2026-06-20',
versions: [{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/acme/trek-flight/tar.gz/aaaa', sha256: '', minTrekVersion: '3.2.0' }],
},
],
@@ -124,6 +124,13 @@ describe('PluginRegistryService', () => {
]);
});
// The store tile has no manifest to read (the aggregated index carries no manifests), so
// the entry's icon is the only thing standing between it and a generic Blocks glyph.
it('browse carries the entry icon through to the store tile', async () => {
const [entry] = await svc.browse();
expect(entry.icon).toBe('Plane');
});
it('browse exposes a screenshot url pinned at the reviewed commit', async () => {
const list = await svc.browse();
expect(list[0].screenshotUrl).toBe(`https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/docs/screenshot.png`);