feat(plugins): install an older version of a plugin (#2035)

Adds a version picker to the plugin admin panel, on desktop and mobile: every
published version with its own server-computed compatibility verdict, so the
client never re-derives semver range logic.

A deliberate non-latest pick sets an update hold on the row, which keeps the
update banner and "Update all" from immediately nagging the rollback away. The
hold releases when an install lands back on the newest compatible version by any
path, or when the admin resumes updates explicitly. An unresolvable registry
never sets one: refusing to pin on missing information beats silently muting
future updates.
This commit is contained in:
jubnl
2026-08-25 17:30:27 +02:00
committed by GitHub
parent b5c8182410
commit 4ba407aead
37 changed files with 1383 additions and 42 deletions
+6 -1
View File
@@ -531,7 +531,12 @@ export const adminApi = {
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
// `version` pins the exact version to install (the rollback path); omitted, the server
// resolves the newest TREK-compatible version itself.
pluginUpdate: (id: string, version?: string) =>
apiClient.post(`/admin/plugins/${id}/update`, version ? { version } : {}).then(r => r.data),
// Release a per-plugin update hold (set by a deliberate non-latest install).
pluginResumeUpdates: (id: string) => apiClient.post(`/admin/plugins/${id}/resume-updates`).then(r => r.data),
// Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the
// full key the admin was shown (not a fingerprint): the server compares it exactly, so
// it can refuse if the registry entry was re-keyed again since the dialog rendered.
@@ -201,6 +201,9 @@ function registryEntry(over: Record<string, unknown> = {}) {
id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify',
type: 'integration', latest: '2.0.0', minTrekVersion: null, reviewedAt: null,
screenshotUrl: null, signed: true, authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
// The real browse response always carries latestCompatible (server-side hostCompat);
// mirror `latest` by default, exactly like a fully-compatible entry.
latestCompatible: (over.latest as string | undefined) ?? '2.0.0',
...over,
}
}
@@ -1087,6 +1090,235 @@ describe('AdminPluginsPanel — updates', () => {
})
})
/**
* Update availability must follow what this TREK can actually INSTALL (latestCompatible,
* computed server-side), never the absolute newest published version — a banner counting
* versions the update endpoint would refuse nags the admin toward a guaranteed 400.
*/
describe('AdminPluginsPanel — compatible updates only', () => {
const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false })
it('FE-COMP-PLUGINS-UPD-001: an update this TREK cannot install is neither offered nor counted', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
})
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument()
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-UPD-002: the update offer is the newest compatible version, not the absolute latest', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })],
})
render(<AdminPluginsPanel />)
expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-UPD-003: a newer version needing a newer TREK leaves a passive hint on the row', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
})
render(<AdminPluginsPanel />)
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-UPD-004: a legacy entry with only minTrekVersion still gets a hint range', async () => {
panelWith([outdated], {
registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: null, minTrekVersion: '4.0.0', hostVersion: '3.3.0' })],
})
render(<AdminPluginsPanel />)
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-UPD-005: no hint when the latest version is the one on offer', async () => {
panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] })
render(<AdminPluginsPanel />)
expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
expect(screen.queryByText(/needs TREK/i)).not.toBeInTheDocument()
})
})
/**
* The update hold (#plugins): a deliberate non-latest install sets updateHold on the row,
* which takes it out of the banner count and "Update all" — a rollback the admin just
* made must not be nagged straight back. The row says so and offers to resume.
*/
describe('AdminPluginsPanel — update hold', () => {
const held = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false, updateHold: true })
it('FE-COMP-PLUGINS-HOLD-001: a held plugin leaves the banner and loses its update button', async () => {
panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] })
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument()
expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-HOLD-002: Resume updates releases the hold on the server', async () => {
let resumed = false
panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] })
server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => {
resumed = true
return HttpResponse.json({ updateHold: false })
}))
withToast()
fireEvent.click(await screen.findByRole('button', { name: /resume updates/i }))
await waitFor(() => expect(resumed).toBe(true))
expect(await screen.findByText('Updates resumed')).toBeInTheDocument()
})
it('FE-COMP-PLUGINS-HOLD-003: Update all skips held plugins', async () => {
const updated: string[] = []
panelWith(
[held, plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false })],
{ registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] },
)
server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => {
updated.push(String(params.id))
return HttpResponse.json({ version: '3.0.0', activated: true, newPermissions: [], newEgress: [] })
}))
withToast()
expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /update all/i }))
await waitFor(() => expect(updated).toEqual(['trek-ntfy']))
})
})
/**
* The version picker (#plugins): install any published version from the Discover detail
* modal, and roll an installed plugin back through the update pipeline (which owns the
* stop-child + re-consent machinery). Compat verdicts are SERVER-computed per version —
* the picker only renders them, it never re-derives range logic.
*/
describe('AdminPluginsPanel — version picker', () => {
const VERSIONS = [
{ version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
{ version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
{ version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
]
const detailWithVersions = () => ({
...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }),
size: 1024, publishedAt: null, manifest: null, versions: VERSIONS,
})
function mockDiscoverDetail() {
server.use(
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })),
http.get('*/api/admin/plugins/registry', () =>
HttpResponse.json([registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })])),
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
)
}
it('FE-COMP-PLUGINS-VPICK-001: the detail modal lists every version and installs the picked one', async () => {
let body: unknown = null
mockDiscoverDetail()
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' })
}))
withToast()
const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
fireEvent.click(tabs[0])
fireEvent.click(await screen.findByText('Gotify'))
fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i }))
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' }))
})
it('FE-COMP-PLUGINS-VPICK-002: an incompatible version is greyed with its TREK requirement, not installable', async () => {
mockDiscoverDetail()
render(<AdminPluginsPanel />)
const tabs = await screen.findAllByRole('tab', { name: /^discover/i })
fireEvent.click(tabs[0])
fireEvent.click(await screen.findByText('Gotify'))
expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-VPICK-003: Change version rolls back through update, after an explicit data warning', async () => {
let body: unknown = null
panelWith([plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false })], {
registry: [registryEntry({ latest: '2.0.0' })],
})
server.use(
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] })
}),
)
withToast()
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i }))
// The rollback keeps the plugin's data — the older code may not read it. Informed consent, no snapshot.
expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /roll back/i }))
await waitFor(() => expect(body).toEqual({ version: '1.5.0' }))
})
it('FE-COMP-PLUGINS-VPICK-004: picking a NEWER version in the picker updates without the downgrade warning', async () => {
let body: unknown = null
panelWith([plugin({ source_repo: 'acme/gotify', version: '1.5.0', operatorEgress: false })], {
registry: [registryEntry({ latest: '2.0.0' })],
})
server.use(
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())),
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] })
}),
)
withToast()
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
fireEvent.click(await screen.findByRole('button', { name: /^switch to 2\.0\.0$/i }))
await waitFor(() => expect(body).toEqual({ version: '2.0.0' }))
expect(screen.queryByText(/data written by the newer version stays in place/i)).not.toBeInTheDocument()
})
it('FE-COMP-PLUGINS-VPICK-005: the installed version is marked and not switchable; sideloads get no picker', async () => {
panelWith([
plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false }),
plugin({ id: 'side-load', name: 'Sideload', source_repo: 'local:upload', version: '1.0.0', operatorEgress: false }),
], { registry: [registryEntry({ latest: '2.0.0' })] })
server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())))
withToast()
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-side-load'))
expect(screen.queryByRole('button', { name: /change version/i })).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('plugin-row-menu-btn-side-load')) // close
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
fireEvent.click(await screen.findByRole('button', { name: /change version/i }))
const current = await screen.findByTestId('version-row-2.0.0')
expect(within(current).getByText('Installed')).toBeInTheDocument()
expect(within(current).queryByRole('button', { name: /switch/i })).not.toBeInTheDocument()
})
})
describe('AdminPluginsPanel — capability and dependency chips', () => {
it('FE-COMP-PLUGINS-PANEL-035: a permission-rich plugin renders one chip per capability', async () => {
panelWith([plugin({
@@ -1405,7 +1637,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
it('FE-W5PLG-005: the update filter and the updates-first sort both use the registry version', async () => {
const user = userEvent.setup()
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }])
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
@@ -1423,7 +1655,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
it('FE-W5PLG-006: switching tabs snaps a tab-only sort key back to name', async () => {
const user = userEvent.setup()
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }])
mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }])
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
@@ -1880,8 +2112,8 @@ describe('AdminPluginsPanel — update all', () => {
plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })],
})),
http.get('*/api/admin/plugins/registry', () => HttpResponse.json([
{ id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' },
{ id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' },
{ id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' },
{ id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' },
])),
http.post('*/api/admin/plugins/:id/update', async ({ params }) => {
started.push(String(params.id))
@@ -5,7 +5,7 @@ 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, Globe, Image, CalendarDays, Bell,
Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle,
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette,
} from 'lucide-react'
import PluginIcon from '../shared/PluginIcon'
@@ -63,6 +63,8 @@ interface PluginRow {
keyFingerprint?: string | null
/** Why an update was refused, if one was. `version` is the version that was refused. */
updateBlock?: { code: string; detail: string | null; version: string | null } | null
/** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */
updateHold?: boolean
}
interface RegistryItem {
id: string
@@ -93,9 +95,21 @@ interface RegistryItem {
/** The full key — public, and carried in full because re-trust compares it exactly. */
authorPublicKey?: string | null
}
/** One published version, with its SERVER-computed compat verdict (the client has no semver). */
interface VersionInfo {
version: string
publishedAt: string | null
size: number | null
signed: boolean
/** The declared TREK requirement, or null when the entry carries no bounds at all. */
trek: string | null
compatible: boolean
}
interface RegistryDetail extends RegistryItem {
size: number | null
publishedAt: string | null
/** Every published version, newest first — the version picker's data. */
versions?: VersionInfo[]
manifest: {
// Optional because a registry may omit an empty list — the detail view has to
// degrade rather than throw halfway through its render.
@@ -392,6 +406,12 @@ export default function AdminPluginsPanel() {
const [egressSaving, setEgressSaving] = useState(false)
const [egressError, setEgressError] = useState('')
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
// The version picker for an INSTALLED plugin ("Change version…"); versions land async
// from the registry detail endpoint, which computes each version's compat verdict.
const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null)
// A picked version OLDER than the installed one — held here until the admin consents
// to the data risk (the plugin's data dir stays; older code may not understand it).
const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null)
// A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
// each must be shown, not silently overwritten by the last one.
const [consentQueue, setConsentQueue] = useState<Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }>>([])
@@ -420,10 +440,13 @@ export default function AdminPluginsPanel() {
// Index the registry once per fetch: the version map the update badges read, plus the
// whole entry (author key + signed flag) the trust badges and re-trust dialog need.
// The map holds latestCompatible — the newest version THIS TREK can install (computed
// server-side) — not the absolute latest, so the banner never counts an update the
// update endpoint would refuse.
const indexRegistry = (items: RegistryItem[]) => {
const vers: Record<string, string> = {}
const byId: Record<string, RegistryItem> = {}
items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest })
items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible })
setLatest(vers)
setRegById(byId)
}
@@ -551,7 +574,21 @@ export default function AdminPluginsPanel() {
.catch(() => setErrorsFor({ id, rows: [] }))
}
const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version))
// A held plugin (deliberate non-latest install) is deliberately NOT an update candidate:
// the admin just rolled it back, and the banner nagging them straight back would make
// the rollback fight the UI. The row carries its own "paused" marker + resume instead.
const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version))
const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed'))
// A newer version exists but this TREK can't install it (and it isn't the one on offer):
// said passively on the row, so the admin learns a TREK upgrade unlocks it instead of
// wondering why no update shows. Null when the registry gives no range to point at.
const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => {
const reg = regById[p.id]
if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null
if (latest[p.id] === reg.latest) return null
const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null)
return range ? { version: reg.latest, range } : null
}
const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
@@ -639,9 +676,11 @@ export default function AdminPluginsPanel() {
.finally(() => { setBusy(null); refresh() })
}
const runUpdate = (p: PluginRow) => {
// `version` pins the exact version to install (rollback / explicit switch); omitted,
// the server resolves the newest TREK-compatible version itself.
const runUpdate = (p: PluginRow, version?: string) => {
setBusy(p.id); setMenu(null)
return adminApi.pluginUpdate(p.id)
return adminApi.pluginUpdate(p.id, version)
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
@@ -673,6 +712,27 @@ export default function AdminPluginsPanel() {
.finally(() => { setRetrusting(false); refresh() })
}
// "Change version…" on an installed row: fetch the per-version list (with the server's
// compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale
// response can't populate a picker that was reopened for another row.
const openVersionPicker = (p: PluginRow) => {
setMenu(null)
setVersionPicker({ plugin: p, versions: null, failed: false })
adminApi.pluginDetail(p.id)
.then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur)))
.catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur)))
}
// Switching DOWN keeps the plugin's data dir, which the older code may not understand —
// that asks for explicit consent first. Any other switch is the ordinary update path.
const pickVersion = (version: string) => {
if (!versionPicker) return
const p = versionPicker.plugin
setVersionPicker(null)
if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version })
else void runUpdate(p, version)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
@@ -885,9 +945,12 @@ export default function AdminPluginsPanel() {
) : shownInstalled.map(p => (
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
newerIncompatible={newerIncompatible(p)}
blocked={blockIsCurrent(p, latest[p.id])}
onToggle={() => toggle(p)}
onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)}
onChangeVersion={() => openVersionPicker(p)}
onResume={() => void resumeUpdates(p)}
onReviewBlock={() => setSignatureBlock({
subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null },
code: p.updateBlock!.code, detail: p.updateBlock!.detail,
@@ -990,6 +1053,20 @@ export default function AdminPluginsPanel() {
message={t('admin.plugins.uninstallBody')}
/>
{versionPicker && (
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
)}
<ConfirmDialog
isOpen={!!confirmDowngrade}
onClose={() => setConfirmDowngrade(null)}
onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); void runUpdate(d.plugin, d.version) }}
title={t('admin.plugins.downgradeTitle')}
message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })}
confirmLabel={t('admin.plugins.downgradeConfirm')}
/>
{signatureBlock && (
<SignatureBlockDialog
data={signatureBlock} entry={regById[signatureBlock.subject.id]} busy={retrusting} t={t}
@@ -1102,10 +1179,10 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
)
}
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked, onToggle, onUpdate, onRestart, onReviewBlock, onErrors, onEgress, onUninstall }: {
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: {
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
hasUpdate: boolean; latestVer?: string; blocked: boolean
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onReviewBlock: () => void
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
onErrors: () => void; onEgress: () => void; onUninstall: () => void
}) {
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
@@ -1164,6 +1241,26 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
</button>
</div>
)}
{/* A newer version this TREK can't run — informational, never a button: the fix
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
{newerIncompatible && (
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-content-faint">
<Info size={13} className="shrink-0" />
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
</div>
)}
{/* Held: the admin deliberately installed a non-latest version, so updates are
paused rather than nagged — resuming is one click, right where the pause shows. */}
{p.updateHold && (
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-content-faint">
<PauseCircle size={13} className="shrink-0" />
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
<button type="button" onClick={onResume} disabled={busy === p.id}
className="shrink-0 font-semibold underline underline-offset-2 hover:opacity-80 transition-opacity disabled:opacity-50">
{t('admin.plugins.resumeUpdates')}
</button>
</div>
)}
{p.status === 'error' && p.last_error ? (
<div className="flex items-center gap-1.5 mt-1.5 text-[11.5px] text-danger">
<AlertTriangle size={13} className="shrink-0" /><span className="truncate">{p.last_error}</span>
@@ -1233,6 +1330,10 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
)}
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
{isRegistrySourced(p.source_repo) && (
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
)}
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
<>
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
@@ -1521,6 +1622,34 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
)}
</div>
</div>
{/* Every published version, installable individually. The compat verdict per
version is SERVER-computed — an incompatible one is explained, never offered. */}
{(detail?.versions?.length ?? 0) > 1 && (
<div className="mt-5">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.versionsTitle')}</h4>
<div className="mt-2 space-y-0.5">
{detail!.versions!.map(v => (
<div key={v.version} className="flex items-center gap-2.5 py-1">
<span className={`text-[12.5px] font-medium tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}>v{v.version}</span>
{v.publishedAt && <span className="text-[11.5px] text-content-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
{v.signed && <ShieldCheck size={13} className="text-success shrink-0" />}
<span className="ml-auto" />
{v.compatible ? (
!installed && (
<button type="button" onClick={() => onInstall(item.id, v.version)} disabled={busy === item.id}
className="text-[11.5px] font-semibold px-2.5 py-1 rounded-full border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors disabled:opacity-50">
{t('admin.plugins.installCompatible', { version: v.version })}
</button>
)
) : (
<span className="text-[11.5px] text-content-faint">{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
)}
</div>
))}
</div>
</div>
)}
</div>
<div className="flex items-center gap-2 px-4 sm:px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary">
@@ -1548,6 +1677,55 @@ function Meta({ k, v }: { k: string; v: string }) {
return <div><div className="text-[12px] text-content-faint">{k}</div><div className="text-[12.5px] font-medium text-content mt-0.5">{v}</div></div>
}
/**
* "Change version…" for an INSTALLED plugin. Rows render the server's per-version compat
* verdict: the installed version is marked, a compatible one gets a Switch action (the
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
* explained and never offered.
*/
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[80vh] overflow-auto shadow-modal" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3.5 border-b border-edge-secondary">
<h3 className="text-[15px] font-semibold text-content truncate">{t('admin.plugins.versionPickerTitle', { name: plugin.name })}</h3>
<button type="button" onClick={onClose} className="w-8 h-8 grid place-items-center rounded-lg text-content-faint hover:text-content hover:bg-surface-tertiary transition-colors shrink-0"><X size={16} /></button>
</div>
<div className="p-2">
{failed && <p className="text-xs text-danger px-2.5 py-2">{t('admin.plugins.detailError')}</p>}
{!failed && !versions && <p className="text-xs text-content-faint px-2.5 py-2">{t('common.loading')}</p>}
{versions?.length === 0 && <p className="text-xs text-content-faint px-2.5 py-2">{t('admin.plugins.noVersions')}</p>}
{versions?.map(v => {
const current = v.version === plugin.version
return (
<div key={v.version} data-testid={`version-row-${v.version}`}
className="flex items-center gap-2.5 px-2.5 py-2 rounded-xl hover:bg-surface-secondary transition-colors">
<span className={`text-[13px] font-medium tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}>v{v.version}</span>
{v.publishedAt && <span className="text-[11.5px] text-content-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
{v.signed && <ShieldCheck size={13} className="text-success shrink-0" />}
<span className="ml-auto" />
{current ? (
<span className="text-[11.5px] font-medium text-content-faint">{t('admin.plugins.installed')}</span>
) : v.compatible ? (
<button type="button" onClick={() => onPick(v.version)} disabled={busy === plugin.id}
className="text-[11.5px] font-semibold px-2.5 py-1 rounded-full border border-edge bg-surface-card text-content-secondary hover:text-content hover:border-content-faint transition-colors disabled:opacity-50">
{t('admin.plugins.versionSwitch', { version: v.version })}
</button>
) : (
<span className="text-[11.5px] text-content-faint">{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
)}
</div>
)
})}
</div>
</div>
</div>
)
}
/**
* A signature check refused an install/update.
*
@@ -4,7 +4,7 @@ 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, Globe, Image, CalendarDays, Bell,
Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle,
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette,
} from 'lucide-react'
import PluginIcon from '../../../components/shared/PluginIcon'
@@ -65,6 +65,8 @@ interface PluginRow {
keyFingerprint?: string | null
/** Why an update was refused, if one was. `version` is the version that was refused. */
updateBlock?: { code: string; detail: string | null; version: string | null } | null
/** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */
updateHold?: boolean
}
interface RegistryItem {
id: string
@@ -95,9 +97,21 @@ interface RegistryItem {
/** The full key — public, and carried in full because re-trust compares it exactly. */
authorPublicKey?: string | null
}
/** One published version, with its SERVER-computed compat verdict (the client has no semver). */
interface VersionInfo {
version: string
publishedAt: string | null
size: number | null
signed: boolean
/** The declared TREK requirement, or null when the entry carries no bounds at all. */
trek: string | null
compatible: boolean
}
interface RegistryDetail extends RegistryItem {
size: number | null
publishedAt: string | null
/** Every published version, newest first — the version picker's data. */
versions?: VersionInfo[]
manifest: {
permissions: string[]
egress: string[]
@@ -399,6 +413,12 @@ export default function MAdminPluginsPanel() {
const [egressSaving, setEgressSaving] = useState(false)
const [egressError, setEgressError] = useState('')
const [confirmUninstall, setConfirmUninstall] = useState<PluginRow | null>(null)
// The version picker for an INSTALLED plugin ("Change version…"); versions land async
// from the registry detail endpoint, which computes each version's compat verdict.
const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null)
// A picked version OLDER than the installed one — held here until the admin consents
// to the data risk (the plugin's data dir stays; older code may not understand it).
const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null)
// A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
// each must be shown, not silently overwritten by the last one.
const [consentQueue, setConsentQueue] = useState<Array<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }>>([])
@@ -427,10 +447,13 @@ export default function MAdminPluginsPanel() {
// Index the registry once per fetch: the version map the update badges read, plus the
// whole entry (author key + signed flag) the trust badges and re-trust dialog need.
// The map holds latestCompatible — the newest version THIS TREK can install (computed
// server-side) — not the absolute latest, so the banner never counts an update the
// update endpoint would refuse.
const indexRegistry = (items: RegistryItem[]) => {
const vers: Record<string, string> = {}
const byId: Record<string, RegistryItem> = {}
items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest })
items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible })
setLatest(vers)
setRegById(byId)
}
@@ -563,7 +586,21 @@ export default function MAdminPluginsPanel() {
.catch(() => setErrorsFor({ id, rows: [] }))
}
const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version))
// A held plugin (deliberate non-latest install) is deliberately NOT an update candidate:
// the admin just rolled it back, and the banner nagging them straight back would make
// the rollback fight the UI. The row carries its own "paused" marker + resume instead.
const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version))
const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed'))
// A newer version exists but this TREK can't install it (and it isn't the one on offer):
// said passively on the row, so the admin learns a TREK upgrade unlocks it instead of
// wondering why no update shows. Null when the registry gives no range to point at.
const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => {
const reg = regById[p.id]
if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null
if (latest[p.id] === reg.latest) return null
const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null)
return range ? { version: reg.latest, range } : null
}
const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
@@ -651,9 +688,11 @@ export default function MAdminPluginsPanel() {
.finally(() => { setBusy(null); refresh() })
}
const runUpdate = (p: PluginRow) => {
// `version` pins the exact version to install (rollback / explicit switch); omitted,
// the server resolves the newest TREK-compatible version itself.
const runUpdate = (p: PluginRow, version?: string) => {
setBusy(p.id); setMenu(null)
adminApi.pluginUpdate(p.id)
adminApi.pluginUpdate(p.id, version)
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
@@ -685,6 +724,27 @@ export default function MAdminPluginsPanel() {
.finally(() => { setRetrusting(false); refresh() })
}
// "Change version…" on an installed row: fetch the per-version list (with the server's
// compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale
// response can't populate a picker that was reopened for another row.
const openVersionPicker = (p: PluginRow) => {
setMenu(null)
setVersionPicker({ plugin: p, versions: null, failed: false })
adminApi.pluginDetail(p.id)
.then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur)))
.catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur)))
}
// Switching DOWN keeps the plugin's data dir, which the older code may not understand —
// that asks for explicit consent first. Any other switch is the ordinary update path.
const pickVersion = (version: string) => {
if (!versionPicker) return
const p = versionPicker.plugin
setVersionPicker(null)
if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version })
else runUpdate(p, version)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
@@ -850,7 +910,9 @@ export default function MAdminPluginsPanel() {
<div className={`flex items-center gap-2.5 rounded-[18px] border px-3.5 py-2.5 ${PENDING_CARD}`}>
<ArrowUpCircle size={16} className="shrink-0 text-[color:var(--m-st-pending)]" />
<span className="text-[0.6875rem] text-m-muted">{t('admin.plugins.updatesAvailable', { count: updatable.length })}</span>
<button type="button" onClick={() => updatable.forEach(runUpdate)}
{/* p => runUpdate(p), NOT forEach(runUpdate): forEach's index would land in
runUpdate's `version` parameter and pin the update to a number. */}
<button type="button" onClick={() => updatable.forEach(p => runUpdate(p))}
className="ml-auto rounded-full bg-[color:var(--m-st-pending)] px-3 py-1.5 text-[0.6875rem] font-bold text-white">
{t('admin.plugins.updateAll')}
</button>
@@ -864,9 +926,11 @@ export default function MAdminPluginsPanel() {
) : shownInstalled.map(p => (
<InstalledRow key={p.id} p={p} t={t} busy={busy}
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
newerIncompatible={newerIncompatible(p)}
blocked={blockIsCurrent(p, latest[p.id])}
onToggle={() => toggle(p)}
onUpdate={() => runUpdate(p)}
onResume={() => void resumeUpdates(p)}
onReviewBlock={() => setSignatureBlock({
subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null },
code: p.updateBlock!.code, detail: p.updateBlock!.detail,
@@ -891,9 +955,28 @@ export default function MAdminPluginsPanel() {
onRestart={() => restart(rowMenuPlugin.id)}
onErrors={() => openErrors(rowMenuPlugin.id)}
onEgress={() => openEgress(rowMenuPlugin.id)}
onChangeVersion={() => openVersionPicker(rowMenuPlugin)}
onUninstall={() => { setMenu(null); setConfirmUninstall(rowMenuPlugin) }} />
)}
{/* Version picker sheet */}
{versionPicker && (
<VersionPickerSheet plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
)}
{/* Downgrade confirm */}
<MConfirmSheet
open={!!confirmDowngrade}
onClose={() => setConfirmDowngrade(null)}
onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); runUpdate(d.plugin, d.version) }}
title={t('admin.plugins.downgradeTitle')}
message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })}
confirmLabel={t('admin.plugins.downgradeConfirm')}
cancelLabel={t('common.cancel')}
danger
/>
{/* Filter picker sheets */}
<PickerSheet
open={menu === 'type'} onClose={() => setMenu(null)} title={t('admin.plugins.filterType')} value={typeFilter}
@@ -1113,10 +1196,10 @@ function PickerSheet({ open, onClose, title, options, value, onPick }: {
// ── Installed row ──────────────────────────────────────────────────────────
function InstalledRow({ p, t, busy, hasUpdate, latestVer, blocked, onToggle, onUpdate, onReviewBlock, onEgress, onMenu }: {
function InstalledRow({ p, t, busy, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onResume, onReviewBlock, onEgress, onMenu }: {
p: PluginRow; t: T; busy: string | null
hasUpdate: boolean; latestVer?: string; blocked: boolean
onToggle: () => void; onUpdate: () => void; onReviewBlock: () => void
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
onToggle: () => void; onUpdate: () => void; onResume: () => void; onReviewBlock: () => void
onEgress: () => void; onMenu: () => void
}) {
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
@@ -1170,6 +1253,28 @@ function InstalledRow({ p, t, busy, hasUpdate, latestVer, blocked, onToggle, onU
</div>
)}
{/* A newer version this TREK can't run — informational, never a button: the fix
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
{newerIncompatible && (
<div className="mt-2 flex items-center gap-1.5 text-[11.5px] text-m-faint">
<Info size={13} className="shrink-0" />
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
</div>
)}
{/* Held: the admin deliberately installed a non-latest version, so updates are
paused rather than nagged — resuming is one click, right where the pause shows. */}
{p.updateHold && (
<div className="mt-2 flex items-center gap-1.5 text-[11.5px] text-m-faint">
<PauseCircle size={13} className="shrink-0" />
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
<button type="button" onClick={onResume} disabled={busy === p.id}
className="shrink-0 font-semibold underline underline-offset-2 disabled:opacity-50">
{t('admin.plugins.resumeUpdates')}
</button>
</div>
)}
{p.status === 'error' && p.last_error ? (
<div className="mt-2 flex items-center gap-1.5 text-[11.5px] text-[color:var(--m-st-danger)]">
<AlertTriangle size={13} className="shrink-0" /><span className="truncate">{p.last_error}</span>
@@ -1223,9 +1328,9 @@ function InstalledRow({ p, t, busy, hasUpdate, latestVer, blocked, onToggle, onU
}
// Row ⋯ action sheet — the desktop portal menu re-expressed as a bottom sheet.
function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onUninstall }: {
function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onChangeVersion, onUninstall }: {
p: PluginRow; t: T; onClose: () => void
onRestart: () => void; onErrors: () => void; onEgress: () => void; onUninstall: () => void
onRestart: () => void; onErrors: () => void; onEgress: () => void; onChangeVersion: () => void; onUninstall: () => void
}) {
const linkable = p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link'
const rowClass = 'flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-left text-[0.8125rem] font-semibold text-m-ink'
@@ -1238,6 +1343,10 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onUnins
)}
<button type="button" className={rowClass} onClick={onErrors}><Bug size={16} /> {t('admin.plugins.viewErrors')}</button>
<button type="button" className={rowClass} onClick={onEgress}><Globe size={16} /> {t('admin.plugins.allowedHosts')}</button>
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
{isRegistrySourced(p.source_repo) && (
<button type="button" className={rowClass} onClick={onChangeVersion}><History size={16} /> {t('admin.plugins.changeVersion')}</button>
)}
{linkable && (
<>
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={onClose} className={rowClass}>
@@ -1258,6 +1367,51 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onUnins
)
}
/**
* "Change version…" for an INSTALLED plugin. Rows render the server's per-version compat
* verdict: the installed version is marked, a compatible one gets a Switch action (the
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
* explained and never offered.
*/
function VersionPickerSheet({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
}) {
return (
<MSheet open onClose={onClose} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.versionPickerTitle', { name: plugin.name })}>
<MAdminSheetFrame title={t('admin.plugins.versionPickerTitle', { name: plugin.name })} onClose={onClose}>
<div className="space-y-0.5">
{failed && <p className="px-2.5 py-2 text-xs text-[color:var(--m-st-danger)]">{t('admin.plugins.detailError')}</p>}
{!failed && !versions && <p className="px-2.5 py-2 text-xs text-m-faint">{t('common.loading')}</p>}
{versions?.length === 0 && <p className="px-2.5 py-2 text-xs text-m-faint">{t('admin.plugins.noVersions')}</p>}
{versions?.map(v => {
const current = v.version === plugin.version
return (
<div key={v.version} data-testid={`version-row-${v.version}`}
className="flex items-center gap-2.5 rounded-xl px-2.5 py-2.5">
<span className={`text-[13px] font-semibold tabular-nums ${v.compatible ? 'text-m-ink' : 'text-m-faint'}`}>v{v.version}</span>
{v.publishedAt && <span className="text-[11.5px] text-m-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
{v.signed && <ShieldCheck size={13} className="shrink-0 text-[color:var(--m-st-confirmed)]" />}
<span className="ml-auto" />
{current ? (
<span className="text-[11.5px] font-medium text-m-faint">{t('admin.plugins.installed')}</span>
) : v.compatible ? (
<button type="button" onClick={() => onPick(v.version)} disabled={busy === plugin.id}
className="rounded-full border border-[color:var(--m-rowbr)] px-2.5 py-1 text-[11.5px] font-bold text-m-muted disabled:opacity-50">
{t('admin.plugins.versionSwitch', { version: v.version })}
</button>
) : (
<span className="text-[11.5px] text-m-faint">{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
)}
</div>
)
})}
</div>
</MAdminSheetFrame>
</MSheet>
)
}
// ── Registry (Discover) ────────────────────────────────────────────────────
function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
@@ -1511,6 +1665,34 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
)}
</div>
</div>
{/* Every published version, installable individually. The compat verdict per
version is SERVER-computed — an incompatible one is explained, never offered. */}
{(detail?.versions?.length ?? 0) > 1 && (
<div className="mt-5">
<h4 className={sectionH}>{t('admin.plugins.versionsTitle')}</h4>
<div className="mt-2 space-y-0.5">
{detail!.versions!.map(v => (
<div key={v.version} className="flex items-center gap-2.5 py-1">
<span className={`text-[12.5px] font-medium tabular-nums ${v.compatible ? 'text-m-ink' : 'text-m-faint'}`}>v{v.version}</span>
{v.publishedAt && <span className="text-[11.5px] text-m-faint">{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
{v.signed && <ShieldCheck size={13} className="shrink-0 text-[color:var(--m-st-confirmed)]" />}
<span className="ml-auto" />
{v.compatible ? (
!installed && (
<button type="button" onClick={() => onInstall(item.id, v.version)} disabled={busy === item.id}
className="rounded-full border border-[color:var(--m-rowbr)] px-2.5 py-1 text-[11.5px] font-bold text-m-muted disabled:opacity-50">
{t('admin.plugins.installCompatible', { version: v.version })}
</button>
)
) : (
<span className="text-[11.5px] text-m-faint">{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
@@ -40,6 +40,9 @@ function registryEntry(over: Row = {}): Row {
repo: 'acme/gotify',
type: 'integration',
latest: '2.0.0',
// The real browse response always carries latestCompatible (server-side hostCompat);
// mirror `latest` by default, exactly like a fully-compatible entry.
latestCompatible: (over.latest as string | undefined) ?? '2.0.0',
minTrekVersion: null,
reviewedAt: null,
screenshotUrl: null,
@@ -742,6 +745,124 @@ describe('MAdminPluginsPanel — updates and consent', () => {
await waitFor(() => expect(toastMessages()).toContain('Plugin updated'));
});
it('FE-MOB-PLUGP-046a: an update this TREK cannot install is neither offered nor counted', async () => {
mockPanel(
[plugin({ source_repo: 'acme/gotify', version: '1.0.0' })],
[registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
);
render(<MAdminPluginsPanel />);
await screen.findByText('Gotify');
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument();
});
it('FE-MOB-PLUGP-046b: a newer version needing a newer TREK leaves a passive hint on the row', async () => {
mockPanel(
[plugin({ source_repo: 'acme/gotify', version: '1.0.0' })],
[registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })],
);
render(<MAdminPluginsPanel />);
expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument();
});
it('FE-MOB-PLUGP-046f: a held plugin leaves the banner and offers Resume updates instead', async () => {
let resumed = false;
mockPanel(
[plugin({ source_repo: 'acme/gotify', version: '1.0.0', updateHold: true })],
[registryEntry({ latest: '2.0.0' })],
);
server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => {
resumed = true;
return HttpResponse.json({ updateHold: false });
}));
render(<MAdminPluginsPanel />);
await screen.findByText('Gotify');
expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument();
expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /resume updates/i }));
await waitFor(() => expect(resumed).toBe(true));
});
it('FE-MOB-PLUGP-046d: the detail sheet lists every version and installs the picked one', async () => {
const bodies: unknown[] = [];
mockPanel([], [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })]);
server.use(
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json({
...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }),
size: 1024, publishedAt: null, manifest: null,
versions: [
{ version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
{ version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
{ version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
],
})),
http.post('*/api/admin/plugins/install', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' });
}),
);
render(<MAdminPluginsPanel />);
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
fireEvent.click(await screen.findByText('Acme'));
// The incompatible latest is explained, never offered.
expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i }));
await waitFor(() => expect(bodies).toEqual([{ id: 'trek-gotify', version: '1.5.0' }]));
});
it('FE-MOB-PLUGP-046e: Change version rolls back through update, after an explicit data warning', async () => {
let body: unknown = null;
mockPanel(
[plugin({ source_repo: 'acme/gotify', version: '2.0.0' })],
[registryEntry()],
);
server.use(
http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json({
...registryEntry(), size: 1024, publishedAt: null, manifest: null,
versions: [
{ version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
{ version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true },
],
})),
http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] });
}),
);
render(<MAdminPluginsPanel />);
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'));
fireEvent.click(await screen.findByRole('button', { name: /change version/i }));
// The installed version is marked, not switchable.
const current = await screen.findByTestId('version-row-2.0.0');
expect(within(current).getByText('Installed')).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i }));
expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /roll back/i }));
await waitFor(() => expect(body).toEqual({ version: '1.5.0' }));
});
it('FE-MOB-PLUGP-046c: the update offer is the newest compatible version, not the absolute latest', async () => {
mockPanel(
[plugin({ source_repo: 'acme/gotify', version: '1.0.0' })],
[registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })],
);
render(<MAdminPluginsPanel />);
expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument();
});
it('FE-MOB-PLUGP-047: a per-row update that widens rights opens the consent sheet and approving activates with consent', async () => {
let consentBody: unknown = null;
mockPanel([plugin({ source_repo: 'acme/gotify', signed: true, version: '1.0.0' })], [registryEntry()]);
+13
View File
@@ -4123,6 +4123,19 @@ function runMigrations(db: Database.Database): void {
}
}
},
// A deliberate non-latest plugin install sets `update_hold`: the row leaves the
// update banner and "Update all" until the admin resumes updates, or until an
// install lands back on the newest compatible version. Only an EXPLICIT version
// pick ever sets it — dependency resolution pins versions too, but never
// deliberately. Appended LAST — the array is index-addressed against schema_version.
() => {
try {
db.exec('ALTER TABLE plugins ADD COLUMN update_hold INTEGER NOT NULL DEFAULT 0;');
} catch (err) {
console.warn('[migrations] Non-fatal migration step failed:', err);
}
},
];
if (currentVersion < migrations.length) {
+24 -4
View File
@@ -11,7 +11,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../audit/client-ip';
import { pluginsEnabled } from './kill-switch';
import { devLinkEnabled } from './dev-link';
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto } from './plugins.dto';
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto, PluginUpdateDto } from './plugins.dto';
import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
@@ -81,8 +81,12 @@ export class PluginsController {
// withDependencies (used by the "resolve missing dependency" admin flow) pulls
// the target + its transitive plugin deps, resolving each to its latest
// compatible version and reporting addons the admin still has to enable.
// Dependency resolution pins versions internally but never DELIBERATELY, so
// only the plain path below recomputes the update hold.
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint);
return await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
const res = await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
await this.registry.recomputeUpdateHold(res.id, res.version, !!body.version);
return res;
} catch (e) {
throw registryFailure(e, 'install failed');
}
@@ -221,15 +225,31 @@ export class PluginsController {
@Post(':id/update')
@HttpCode(200)
async update(@Param('id') id: string) {
async update(@Param('id') id: string, @Body() body?: PluginUpdateDto) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
try {
return await this.runtime.update(id);
// An explicit version is the rollback path: install exactly what the admin picked
// (the TREK-compat gate still refuses in selectVersion). Absent, the runtime
// resolves the newest compatible version itself.
const res = await this.runtime.update(id, { version: body?.version });
// A deliberate non-latest pick holds future updates; landing on the newest
// (any path) releases a stale hold. Only after success — a failed update
// changed nothing and must not touch the flag.
await this.registry.recomputeUpdateHold(id, res.version, !!body?.version);
return res;
} catch (e) {
throw registryFailure(e, 'update failed');
}
}
/** Release a per-plugin update hold (set by a deliberate non-latest install). */
@Post(':id/resume-updates')
@HttpCode(200)
resumeUpdates(@Param('id') id: string) {
if (!this.plugins.resumeUpdates(id)) throw new HttpException({ error: `plugin ${id} not found` }, 404);
return { updateHold: false };
}
/**
* Re-trust a plugin whose author signing key ROTATED, and update it in the same call.
*
+8
View File
@@ -41,6 +41,14 @@ export class PluginRetrustDto extends createZodDto(
z.looseObject({ version: z.string().optional(), publicKey: z.string().optional() }),
) {}
/**
* `version` pins the exact version to install — the rollback path. Omitted, the
* runtime resolves the newest TREK-compatible version itself (the classic update).
*/
export class PluginUpdateDto extends createZodDto(
z.looseObject({ version: z.string().optional() }),
) {}
/**
* `hosts` is deliberately unknown, not `string[]`.
*
+15 -1
View File
@@ -43,6 +43,7 @@ interface PluginRawRow {
update_block_code: string | null;
update_block_detail: string | null;
update_block_version: string | null;
update_hold: number;
}
export interface PluginListItem {
@@ -87,6 +88,9 @@ export interface PluginListItem {
/** Why an update was refused, if one was. `version` is the registry version that was
* refused, so a caller can treat the block as stale once a newer one is on offer. */
updateBlock: { code: string; detail: string | null; version: string | null } | null;
/** A deliberate non-latest install paused updates: excluded from the banner/Update all
* until the admin resumes, or an install lands back on the newest compatible version. */
updateHold: boolean;
}
@Injectable()
@@ -114,7 +118,7 @@ export class PluginsService {
.prepare(
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
permissions, capabilities, dependencies, operator_egress, trek_range,
author_pubkey, update_block_code, update_block_detail, update_block_version
author_pubkey, update_block_code, update_block_detail, update_block_version, update_hold
FROM plugins
ORDER BY sort_order, name`,
)
@@ -143,6 +147,7 @@ export class PluginsService {
update_block_code,
update_block_detail,
update_block_version,
update_hold,
...rest
} = r as PluginRawRow & { operator_egress?: number };
return {
@@ -159,11 +164,20 @@ export class PluginsService {
updateBlock: update_block_code
? { code: update_block_code, detail: update_block_detail, version: update_block_version }
: null,
updateHold: update_hold === 1,
};
});
return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), plugins };
}
/**
* Release a per-plugin update hold (set by a deliberate non-latest install).
* Returns whether the plugin row existed — the controller answers 404 otherwise.
*/
resumeUpdates(id: string): boolean {
return this.db.prepare('UPDATE plugins SET update_hold = 0 WHERE id = ?').run(id).changes > 0;
}
/**
* Merge instance-scope settings into the plugin's config, encrypting fields
* declared secret (unless the value is the unchanged mask sentinel). Only keys
@@ -304,6 +304,17 @@ export class PluginRegistryService {
screenshotUrl: entry.screenshotUrl ?? (latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null),
signed: !!entry.authorPublicKey && !!latest?.signature,
authorPublicKey: entry.authorPublicKey ?? null,
// The version picker's data: every published version with its OWN server-computed
// compat verdict. The client has no semver and must never re-derive range logic —
// a picker that disagreed with the install gate would offer a version that 400s.
versions: entry.versions.map((v) => ({
version: v.version,
publishedAt: v.publishedAt ?? null,
size: v.size ?? null,
signed: !!entry.authorPublicKey && !!v.signature,
trek: trekRequirementOrNull(v),
compatible: hostCompatible(v, normalizedHost()),
})),
...this.hostCompat(entry),
manifest,
};
@@ -508,6 +519,30 @@ export class PluginRegistryService {
return { installed, requiredAddons: [...requiredAddons] };
}
/**
* Recompute the per-plugin update hold after a successful install/update.
*
* The hold exists so a DELIBERATE rollback isn't immediately nagged away by the
* update banner. It is set only when `explicit` (the admin picked this exact
* version) AND a newer TREK-compatible version exists; every other outcome writes
* 0, so landing back on the newest compatible version — by any path — releases a
* stale hold. An unresolvable registry never sets a hold: refusing to pin on
* missing information beats silently muting future updates.
*/
async recomputeUpdateHold(id: string, installedVersion: string, explicit: boolean): Promise<boolean> {
let hold = false;
if (explicit) {
try {
const newest = await this.resolveVersion(id);
hold = semver.lt(installedVersion, newest.version);
} catch {
hold = false;
}
}
this.db.prepare('UPDATE plugins SET update_hold = ? WHERE id = ?').run(hold ? 1 : 0, id);
return hold;
}
/**
* Sideload step 1: extract + validate an uploaded archive into a staging dir,
* WITHOUT touching the live plugin. Returns the manifest id/version + the staged
@@ -710,17 +745,23 @@ export function assertHostCompatible(range: string | null, id: string): void {
}
/**
* How a version's TREK requirement reads in an error/UI string. Each bound is optional —
* an entry may declare only a ceiling, or (once `trek` is absent too) nothing at all — so
* this composes whatever bounds exist rather than interpolating a missing one as "null".
* A version's declared TREK requirement as a range string, or null when the entry
* carries no bounds at all. Each bound is optional — an entry may declare only a
* ceiling, or (once `trek` is absent too) nothing — so this composes whatever
* bounds exist rather than interpolating a missing one as "null".
*/
function trekRequirement(v: RegistryVersion): string {
function trekRequirementOrNull(v: RegistryVersion): string | null {
if (v.trek) return v.trek;
const bounds = [
v.minTrekVersion ? `>=${v.minTrekVersion}` : null,
v.maxTrekVersion ? `<=${v.maxTrekVersion}` : null,
].filter(Boolean);
return bounds.length ? bounds.join(' ') : 'any version';
return bounds.length ? bounds.join(' ') : null;
}
/** How a version's TREK requirement reads in an error/UI string. */
function trekRequirement(v: RegistryVersion): string {
return trekRequirementOrNull(v) ?? 'any version';
}
/**
@@ -23,7 +23,7 @@ const { testDb } = vi.hoisted(() => {
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT,
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT,
update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT, update_hold INTEGER NOT NULL DEFAULT 0);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
return { testDb: db };
@@ -257,6 +257,123 @@ describe('PluginRegistryService', () => {
await expect(svc.detail('ghost')).rejects.toThrow(RegistryError);
});
// The per-plugin update hold: a DELIBERATE non-latest install excludes the plugin from
// the update banner until the admin resumes updates or lands back on the newest
// compatible version. Dependency resolution pins versions too, but never deliberately —
// the `explicit` flag is what separates the two.
describe('recomputeUpdateHold', () => {
const twoVersions = {
schemaVersion: 1,
plugins: [
{
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight', type: 'widget',
versions: [
{ version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/2', sha256: '2'.repeat(64) },
{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'd'.repeat(40), downloadUrl: 'https://x/1', sha256: '0'.repeat(64) },
],
},
],
};
const seedRow = (hold = 0) =>
testDb.prepare("INSERT INTO plugins (id, status, enabled, update_hold) VALUES ('flight-tracker','inactive',0,?)").run(hold);
const holdInDb = () =>
(testDb.prepare("SELECT update_hold FROM plugins WHERE id='flight-tracker'").get() as { update_hold: number }).update_hold;
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => twoVersions }) as unknown as Response));
__clearRegistryCacheForTests();
});
it('holds when the admin explicitly picked an older version', async () => {
seedRow();
await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(true);
expect(holdInDb()).toBe(1);
});
it('does not hold when the explicit pick IS the newest compatible version', async () => {
seedRow(1);
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false);
expect(holdInDb()).toBe(0);
});
// The comparison is against the newest version THIS TREK can run, not the newest
// published one. Both fixtures above leave `trek` unset, so every version is
// compatible and the two readings agree; only a version this host must skip tells
// them apart. Getting it wrong stamps a hold on an admin who picked the best
// available version, and the row silently leaves the banner and "Update all".
it('does not hold when a newer version exists but this TREK cannot run it', async () => {
const withIncompatibleNewest = {
schemaVersion: 1,
plugins: [
{
...twoVersions.plugins[0],
versions: [
{ version: '3.0.0', gitTag: 'v3.0.0', commitSha: 'c'.repeat(40), downloadUrl: 'https://x/3', sha256: '3'.repeat(64), trek: '>=4.0.0' },
...twoVersions.plugins[0].versions,
],
},
],
};
process.env.APP_VERSION = '3.3.0';
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => withIncompatibleNewest }) as unknown as Response));
__clearRegistryCacheForTests();
try {
seedRow(1);
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false);
expect(holdInDb()).toBe(0);
} finally {
delete process.env.APP_VERSION;
}
});
it('a non-deliberate update clears a stale hold', async () => {
seedRow(1);
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', false)).resolves.toBe(false);
expect(holdInDb()).toBe(0);
});
it('an unresolvable registry never sets a hold', async () => {
seedRow();
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
__clearRegistryCacheForTests();
await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(false);
expect(holdInDb()).toBe(0);
});
});
// The version picker's data: every published version, each with its OWN server-computed
// compat verdict — the client has no semver and must never re-derive range logic.
it('detail lists every published version with its own compat verdict', async () => {
process.env.APP_VERSION = '3.3.0';
const multi = {
schemaVersion: 1,
plugins: [
{
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight',
type: 'widget', authorPublicKey: 'K'.repeat(44),
versions: [
{ version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/2', sha256: '2'.repeat(64), trek: '>=4.0.0', publishedAt: '2026-08-01', size: 2048, signature: 'sig2' },
{ version: '1.5.0', gitTag: 'v1.5.0', commitSha: 'c'.repeat(40), downloadUrl: 'https://x/15', sha256: '1'.repeat(64), trek: '>=3.0.0 <4.0.0', publishedAt: '2026-07-01', size: 1024 },
{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'd'.repeat(40), downloadUrl: 'https://x/1', sha256: '0'.repeat(64), minTrekVersion: '3.0.0' },
],
},
],
};
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => multi }) as unknown as Response));
__clearRegistryCacheForTests();
try {
const d = (await svc.detail('flight-tracker')) as { versions: unknown };
expect(d.versions).toEqual([
{ version: '2.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
{ version: '1.5.0', publishedAt: '2026-07-01', size: 1024, signed: false, trek: '>=3.0.0 <4.0.0', compatible: true },
// A legacy entry composes its requirement from the min/max bounds it has.
{ version: '1.0.0', publishedAt: null, size: null, signed: false, trek: '>=3.0.0', compatible: true },
]);
} finally {
delete process.env.APP_VERSION;
}
});
it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => {
__clearRegistryCacheForTests();
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
@@ -63,8 +63,11 @@ describe('budget_items ticket_json migration', () => {
describe('recovering what the case-insensitive match destroyed', () => {
/**
* A database that already ran the buggy step: migrated to the tip, then wound
* back one version so only the recovery step is left to run — which is exactly
* what an existing install does on the next boot.
* back to just before the recovery step so it runs again — which is exactly
* what an existing install does on the next boot. The wind-back target is the
* recovery step's ABSOLUTE version (the array is append-only and
* index-addressed, so it never moves); anything appended after it replays too,
* which every later migration must tolerate anyway.
*/
function makeDamagedDb(): Database.Database {
const db = new Database(':memory:');
@@ -85,8 +88,8 @@ describe('recovering what the case-insensitive match destroyed', () => {
db.prepare('INSERT INTO budget_items (id, trip_id, name, note, ticket_json) VALUES (?, ?, ?, ?, ?)')
.run(3, 1, 'Taxi', 'split at the hotel', '{"items":[]}');
const version = (db.prepare('SELECT version FROM schema_version LIMIT 1').get() as { version: number }).version;
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
// 196 = the version just before the recovery step (migration #197).
db.prepare('UPDATE schema_version SET version = ?').run(196);
return db;
}
@@ -49,11 +49,14 @@ let runtime: PluginRuntimeService;
let registry: PluginRegistryService;
let controller: PluginsController;
let plugins: PluginsService;
beforeEach(() => {
process.env.TREK_PLUGINS_ENABLED = 'true';
runtime = { update: vi.fn(), retrust: vi.fn() } as unknown as PluginRuntimeService;
registry = { install: vi.fn() } as unknown as PluginRegistryService;
controller = new PluginsController({} as PluginsService, runtime, registry, { isManaged: () => false } as unknown as RuntimeEnvService);
registry = { install: vi.fn(), installWithDependencies: vi.fn(), recomputeUpdateHold: vi.fn() } as unknown as PluginRegistryService;
plugins = { resumeUpdates: vi.fn(() => true) } as unknown as PluginsService;
controller = new PluginsController(plugins, runtime, registry, { isManaged: () => false } as unknown as RuntimeEnvService);
});
describe('signature refusal codes survive to the client', () => {
@@ -98,6 +101,87 @@ describe('signature refusal codes survive to the client', () => {
});
});
// The rollback path: an admin picks a specific (possibly older) version, and the runtime
// must install exactly that one — the compat gate stays server-side in selectVersion.
describe('POST :id/update — explicit version', () => {
it('forwards the requested version to the runtime', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
const res = await controller.update('flight-tracker', { version: '1.2.0' });
expect(vi.mocked(runtime.update).mock.calls[0]).toEqual(['flight-tracker', { version: '1.2.0' }]);
expect(res).toEqual({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
});
it('an omitted body keeps the default newest-compatible resolution', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
await controller.update('flight-tracker');
expect(vi.mocked(runtime.update).mock.calls[0]).toEqual(['flight-tracker', { version: undefined }]);
});
});
// The update hold: only a DELIBERATE version pick may set it, every successful
// install/update recomputes it (so landing back on the newest clears a stale one),
// and dependency resolution — which pins versions too, but never deliberately —
// must not touch it.
describe('update hold wiring', () => {
it('an explicit-version update recomputes the hold as deliberate', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
await controller.update('flight-tracker', { version: '1.2.0' });
expect(registry.recomputeUpdateHold).toHaveBeenCalledWith('flight-tracker', '1.2.0', true);
});
it('a default update recomputes as non-deliberate (clears a stale hold)', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
await controller.update('flight-tracker');
expect(registry.recomputeUpdateHold).toHaveBeenCalledWith('flight-tracker', '2.0.0', false);
});
it('an explicit-version install recomputes the hold as deliberate', async () => {
vi.mocked(registry.install).mockResolvedValue({ id: 'flight-tracker', version: '1.2.0' });
await controller.install({ id: 'flight-tracker', version: '1.2.0' });
expect(registry.recomputeUpdateHold).toHaveBeenCalledWith('flight-tracker', '1.2.0', true);
});
it('a dependency-resolution install does NOT touch the hold', async () => {
vi.mocked(registry.installWithDependencies).mockResolvedValue({ installed: ['flight-tracker'], requiredAddons: [] });
await controller.install({ id: 'flight-tracker', withDependencies: true });
expect(registry.recomputeUpdateHold).not.toHaveBeenCalled();
});
it('a failed update leaves the hold untouched', async () => {
vi.mocked(runtime.update).mockRejectedValue(new Error('network unreachable'));
await expect(controller.update('flight-tracker', { version: '1.2.0' })).rejects.toThrow(HttpException);
expect(registry.recomputeUpdateHold).not.toHaveBeenCalled();
});
it('POST :id/resume-updates clears the hold', async () => {
const res = await controller.resumeUpdates('flight-tracker');
expect(plugins.resumeUpdates).toHaveBeenCalledWith('flight-tracker');
expect(res).toEqual({ updateHold: false });
});
it('POST :id/resume-updates is a 404 for an unknown plugin', async () => {
vi.mocked(plugins.resumeUpdates).mockReturnValue(false);
const { status } = await wireFailure(async () => controller.resumeUpdates('ghost'));
expect(status).toBe(404);
});
});
describe('POST :id/retrust', () => {
it('re-pins AND installs in one call, returning the update result', async () => {
vi.mocked(runtime.retrust).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
@@ -11,7 +11,7 @@ const { testDb } = vi.hoisted(() => {
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT,
author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT,
trek_range TEXT, sort_order INTEGER DEFAULT 0);
trek_range TEXT, sort_order INTEGER DEFAULT 0, update_hold INTEGER NOT NULL DEFAULT 0);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
return { testDb: db };
@@ -48,6 +48,30 @@ describe('PluginsService.list', () => {
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
});
it('surfaces updateHold as a boolean (held plugins leave the update banner)', () => {
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
.run();
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version) VALUES ('free','Free','widget','inactive','1.0.0')")
.run();
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
expect(out.plugins.find((p) => p.id === 'held')).toMatchObject({ updateHold: true });
expect(out.plugins.find((p) => p.id === 'free')).toMatchObject({ updateHold: false });
});
it('resumeUpdates clears the hold and reports whether the plugin existed', () => {
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
.run();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
expect(svc.resumeUpdates('held')).toBe(true);
expect(testDb.prepare("SELECT update_hold FROM plugins WHERE id='held'").get()).toMatchObject({ update_hold: 0 });
expect(svc.resumeUpdates('ghost')).toBe(false);
});
it('reports enabled by default (no kill switch set)', () => {
testDb
.prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)')
+13
View File
@@ -428,6 +428,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'التحديثات أولًا',
'admin.plugins.sortDownloads': 'الأكثر تنزيلًا',
'admin.plugins.updatesAvailable': '{count} تحديثات متاحة لإضافاتك.',
'admin.plugins.newerNeedsTrek': 'الإصدار v{version} متوفر — يتطلب TREK {range}',
'admin.plugins.versionsTitle': 'الإصدارات',
'admin.plugins.versionPickerTitle': 'تغيير الإصدار — {name}',
'admin.plugins.versionSwitch': 'التبديل إلى {version}',
'admin.plugins.versionNeedsTrek': 'يتطلب TREK {range}',
'admin.plugins.changeVersion': 'تغيير الإصدار…',
'admin.plugins.noVersions': 'لم يتم العثور على إصدارات منشورة في السجل.',
'admin.plugins.downgradeTitle': 'التراجع عن هذه الإضافة؟',
'admin.plugins.downgradeBody': 'التبديل من v{from} إلى v{to}: تبقى البيانات المكتوبة بواسطة الإصدار الأحدث في مكانها، وقد لا يفهمها الإصدار الأقدم.',
'admin.plugins.downgradeConfirm': 'تراجع',
'admin.plugins.updatesHeld': 'التحديثات متوقفة عند v{version}',
'admin.plugins.resumeUpdates': 'استئناف التحديثات',
'admin.plugins.updatesResumed': 'تم استئناف التحديثات',
'admin.plugins.updateAll': 'تحديث الكل',
'admin.plugins.noMatchInstalled': 'لا توجد إضافات مثبّتة تطابق بحثك.',
'admin.plugins.noMatchRegistry': 'لا توجد إضافات في السجل تطابق بحثك.',
+13
View File
@@ -417,6 +417,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Atualizações primeiro',
'admin.plugins.sortDownloads': 'Mais baixados',
'admin.plugins.updatesAvailable': '{count} atualizações disponíveis para seus plugins.',
'admin.plugins.newerNeedsTrek': 'v{version} disponível — requer TREK {range}',
'admin.plugins.versionsTitle': 'Versões',
'admin.plugins.versionPickerTitle': 'Alterar versão — {name}',
'admin.plugins.versionSwitch': 'Mudar para {version}',
'admin.plugins.versionNeedsTrek': 'requer TREK {range}',
'admin.plugins.changeVersion': 'Alterar versão…',
'admin.plugins.noVersions': 'Nenhuma versão publicada encontrada no registro.',
'admin.plugins.downgradeTitle': 'Reverter este plugin?',
'admin.plugins.downgradeBody': 'Mudando de v{from} para v{to}: os dados gravados pela versão mais recente permanecem no lugar, e a versão mais antiga pode não entendê-los.',
'admin.plugins.downgradeConfirm': 'Reverter',
'admin.plugins.updatesHeld': 'Atualizações pausadas em v{version}',
'admin.plugins.resumeUpdates': 'Retomar atualizações',
'admin.plugins.updatesResumed': 'Atualizações retomadas',
'admin.plugins.updateAll': 'Atualizar todos',
'admin.plugins.noMatchInstalled': 'Nenhum plugin instalado corresponde à sua busca.',
'admin.plugins.noMatchRegistry': 'Nenhum plugin no registro corresponde à sua busca.',
+13
View File
@@ -542,6 +542,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Primer les actualitzacions',
'admin.plugins.sortDownloads': 'Més baixades',
'admin.plugins.updatesAvailable': '{count} actualitzacions disponibles per als teus connectors.',
'admin.plugins.newerNeedsTrek': 'v{version} disponible — necessita TREK {range}',
'admin.plugins.versionsTitle': 'Versions',
'admin.plugins.versionPickerTitle': 'Canvia la versió — {name}',
'admin.plugins.versionSwitch': 'Canvia a {version}',
'admin.plugins.versionNeedsTrek': 'necessita TREK {range}',
'admin.plugins.changeVersion': 'Canvia la versió…',
'admin.plugins.noVersions': 'No s\'han trobat versions publicades al registre.',
'admin.plugins.downgradeTitle': 'Vols revertir aquest connector?',
'admin.plugins.downgradeBody': 'Canvi de v{from} a v{to}: les dades escrites per la versió més nova es mantenen, i la versió antiga pot no entendre-les.',
'admin.plugins.downgradeConfirm': 'Reverteix',
'admin.plugins.updatesHeld': 'Actualitzacions en pausa a la v{version}',
'admin.plugins.resumeUpdates': 'Reprèn les actualitzacions',
'admin.plugins.updatesResumed': 'Actualitzacions represes',
'admin.plugins.updateAll': 'Actualitza-ho tot',
'admin.plugins.noMatchInstalled': 'Cap connector instal·lat coincideix amb la teva cerca.',
'admin.plugins.noMatchRegistry': 'Cap connector del registre coincideix amb la teva cerca.',
+13
View File
@@ -415,6 +415,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Nejdřív aktualizace',
'admin.plugins.sortDownloads': 'Nejstahovanější',
'admin.plugins.updatesAvailable': 'Pro vaše pluginy je k dispozici {count} aktualizací.',
'admin.plugins.newerNeedsTrek': 'v{version} k dispozici — vyžaduje TREK {range}',
'admin.plugins.versionsTitle': 'Verze',
'admin.plugins.versionPickerTitle': 'Změnit verzi — {name}',
'admin.plugins.versionSwitch': 'Přepnout na {version}',
'admin.plugins.versionNeedsTrek': 'vyžaduje TREK {range}',
'admin.plugins.changeVersion': 'Změnit verzi…',
'admin.plugins.noVersions': 'V registru nebyly nalezeny žádné publikované verze.',
'admin.plugins.downgradeTitle': 'Vrátit tento plugin na starší verzi?',
'admin.plugins.downgradeBody': 'Přechod z v{from} na v{to}: data zapsaná novější verzí zůstanou na místě a starší verze jim nemusí rozumět.',
'admin.plugins.downgradeConfirm': 'Vrátit zpět',
'admin.plugins.updatesHeld': 'Aktualizace pozastaveny na v{version}',
'admin.plugins.resumeUpdates': 'Obnovit aktualizace',
'admin.plugins.updatesResumed': 'Aktualizace obnoveny',
'admin.plugins.updateAll': 'Aktualizovat vše',
'admin.plugins.noMatchInstalled': 'Žádné nainstalované pluginy neodpovídají vašemu hledání.',
'admin.plugins.noMatchRegistry': 'Žádné pluginy v registru neodpovídají vašemu hledání.',
+13
View File
@@ -424,6 +424,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Updates zuerst',
'admin.plugins.sortDownloads': 'Meiste Downloads',
'admin.plugins.updatesAvailable': '{count} Updates für deine Plugins verfügbar.',
'admin.plugins.newerNeedsTrek': 'v{version} verfügbar — benötigt TREK {range}',
'admin.plugins.versionsTitle': 'Versionen',
'admin.plugins.versionPickerTitle': 'Version wechseln — {name}',
'admin.plugins.versionSwitch': 'Zu {version} wechseln',
'admin.plugins.versionNeedsTrek': 'benötigt TREK {range}',
'admin.plugins.changeVersion': 'Version wechseln…',
'admin.plugins.noVersions': 'Keine veröffentlichten Versionen in der Registry gefunden.',
'admin.plugins.downgradeTitle': 'Dieses Plugin zurücksetzen?',
'admin.plugins.downgradeBody': 'Wechsel von v{from} zu v{to}: Von der neueren Version geschriebene Daten bleiben erhalten, und die ältere Version versteht sie möglicherweise nicht.',
'admin.plugins.downgradeConfirm': 'Zurücksetzen',
'admin.plugins.updatesHeld': 'Updates pausiert bei v{version}',
'admin.plugins.resumeUpdates': 'Updates fortsetzen',
'admin.plugins.updatesResumed': 'Updates fortgesetzt',
'admin.plugins.updateAll': 'Alle aktualisieren',
'admin.plugins.noMatchInstalled': 'Keine installierten Plugins passen zu deiner Suche.',
'admin.plugins.noMatchRegistry': 'Keine Plugins in der Registry passen zu deiner Suche.',
+13
View File
@@ -459,7 +459,20 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Updates first',
'admin.plugins.sortDownloads': 'Most downloads',
'admin.plugins.updatesAvailable': '{count} updates available for your plugins.',
'admin.plugins.newerNeedsTrek': 'v{version} available — needs TREK {range}',
'admin.plugins.updateAll': 'Update all',
'admin.plugins.versionsTitle': 'Versions',
'admin.plugins.versionPickerTitle': 'Change version — {name}',
'admin.plugins.versionSwitch': 'Switch to {version}',
'admin.plugins.versionNeedsTrek': 'needs TREK {range}',
'admin.plugins.changeVersion': 'Change version…',
'admin.plugins.noVersions': 'No published versions found in the registry.',
'admin.plugins.downgradeTitle': 'Roll back this plugin?',
'admin.plugins.downgradeBody': 'Switching from v{from} to v{to}: data written by the newer version stays in place, and the older version may not understand it.',
'admin.plugins.downgradeConfirm': 'Roll back',
'admin.plugins.updatesHeld': 'Updates paused at v{version}',
'admin.plugins.resumeUpdates': 'Resume updates',
'admin.plugins.updatesResumed': 'Updates resumed',
'admin.plugins.noMatchInstalled': 'No installed plugins match your search.',
'admin.plugins.noMatchRegistry': 'No plugins in the registry match your search.',
'admin.plugins.restart': 'Restart',
+13
View File
@@ -426,6 +426,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Actualizaciones primero',
'admin.plugins.sortDownloads': 'Más descargados',
'admin.plugins.updatesAvailable': '{count} actualizaciones disponibles para tus plugins.',
'admin.plugins.newerNeedsTrek': 'v{version} disponible — necesita TREK {range}',
'admin.plugins.versionsTitle': 'Versiones',
'admin.plugins.versionPickerTitle': 'Cambiar versión — {name}',
'admin.plugins.versionSwitch': 'Cambiar a {version}',
'admin.plugins.versionNeedsTrek': 'necesita TREK {range}',
'admin.plugins.changeVersion': 'Cambiar versión…',
'admin.plugins.noVersions': 'No se encontraron versiones publicadas en el registro.',
'admin.plugins.downgradeTitle': '¿Revertir este plugin?',
'admin.plugins.downgradeBody': 'Cambio de v{from} a v{to}: los datos escritos por la versión más reciente se conservan, y la versión anterior podría no entenderlos.',
'admin.plugins.downgradeConfirm': 'Revertir',
'admin.plugins.updatesHeld': 'Actualizaciones en pausa en v{version}',
'admin.plugins.resumeUpdates': 'Reanudar actualizaciones',
'admin.plugins.updatesResumed': 'Actualizaciones reanudadas',
'admin.plugins.updateAll': 'Actualizar todo',
'admin.plugins.noMatchInstalled': 'Ningún plugin instalado coincide con tu búsqueda.',
'admin.plugins.noMatchRegistry': 'Ningún plugin del registro coincide con tu búsqueda.',
+13
View File
@@ -429,6 +429,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Mises à jour en premier',
'admin.plugins.sortDownloads': 'Plus téléchargés',
'admin.plugins.updatesAvailable': '{count} mises à jour disponibles pour vos plugins.',
'admin.plugins.newerNeedsTrek': 'v{version} disponible — nécessite TREK {range}',
'admin.plugins.versionsTitle': 'Versions',
'admin.plugins.versionPickerTitle': 'Changer de version — {name}',
'admin.plugins.versionSwitch': 'Passer à {version}',
'admin.plugins.versionNeedsTrek': 'nécessite TREK {range}',
'admin.plugins.changeVersion': 'Changer de version…',
'admin.plugins.noVersions': 'Aucune version publiée trouvée dans le registre.',
'admin.plugins.downgradeTitle': 'Revenir à une version antérieure de ce plugin ?',
'admin.plugins.downgradeBody': 'Passage de v{from} à v{to} : les données écrites par la version plus récente restent en place, et la version plus ancienne pourrait ne pas les comprendre.',
'admin.plugins.downgradeConfirm': 'Rétrograder',
'admin.plugins.updatesHeld': 'Mises à jour en pause à la v{version}',
'admin.plugins.resumeUpdates': 'Reprendre les mises à jour',
'admin.plugins.updatesResumed': 'Mises à jour reprises',
'admin.plugins.updateAll': 'Tout mettre à jour',
'admin.plugins.noMatchInstalled': 'Aucun plugin installé ne correspond à votre recherche.',
'admin.plugins.noMatchRegistry': 'Aucun plugin du registre ne correspond à votre recherche.',
+13
View File
@@ -477,6 +477,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Ενημερώσεις πρώτα',
'admin.plugins.sortDownloads': 'Περισσότερες λήψεις',
'admin.plugins.updatesAvailable': 'Διαθέσιμες {count} ενημερώσεις για τα plugins σας.',
'admin.plugins.newerNeedsTrek': 'Διαθέσιμη η v{version} — απαιτεί TREK {range}',
'admin.plugins.versionsTitle': 'Εκδόσεις',
'admin.plugins.versionPickerTitle': 'Αλλαγή έκδοσης — {name}',
'admin.plugins.versionSwitch': 'Μετάβαση σε {version}',
'admin.plugins.versionNeedsTrek': 'απαιτεί TREK {range}',
'admin.plugins.changeVersion': 'Αλλαγή έκδοσης…',
'admin.plugins.noVersions': 'Δεν βρέθηκαν δημοσιευμένες εκδόσεις στο μητρώο.',
'admin.plugins.downgradeTitle': 'Επαναφορά αυτού του πρόσθετου;',
'admin.plugins.downgradeBody': 'Μετάβαση από v{from} σε v{to}: τα δεδομένα της νεότερης έκδοσης παραμένουν, και η παλαιότερη έκδοση ίσως να μην τα κατανοεί.',
'admin.plugins.downgradeConfirm': 'Επαναφορά',
'admin.plugins.updatesHeld': 'Οι ενημερώσεις είναι σε παύση στην v{version}',
'admin.plugins.resumeUpdates': 'Συνέχιση ενημερώσεων',
'admin.plugins.updatesResumed': 'Οι ενημερώσεις συνεχίστηκαν',
'admin.plugins.updateAll': 'Ενημέρωση όλων',
'admin.plugins.noMatchInstalled': 'Κανένα εγκατεστημένο plugin δεν ταιριάζει με την αναζήτησή σας.',
'admin.plugins.noMatchRegistry': 'Κανένα plugin στο μητρώο δεν ταιριάζει με την αναζήτησή σας.',
+13
View File
@@ -429,6 +429,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Frissítések elöl',
'admin.plugins.sortDownloads': 'Legtöbb letöltés',
'admin.plugins.updatesAvailable': '{count} frissítés érhető el a pluginjaidhoz.',
'admin.plugins.newerNeedsTrek': 'v{version} elérhető — TREK {range} szükséges',
'admin.plugins.versionsTitle': 'Verziók',
'admin.plugins.versionPickerTitle': 'Verzióváltás — {name}',
'admin.plugins.versionSwitch': 'Váltás erre: {version}',
'admin.plugins.versionNeedsTrek': 'TREK {range} szükséges',
'admin.plugins.changeVersion': 'Verzióváltás…',
'admin.plugins.noVersions': 'Nem található publikált verzió a jegyzékben.',
'admin.plugins.downgradeTitle': 'Visszaállítod ezt a bővítményt?',
'admin.plugins.downgradeBody': 'Váltás v{from} verzióról v{to} verzióra: az újabb verzió által írt adatok megmaradnak, és a régebbi verzió lehet, hogy nem érti őket.',
'admin.plugins.downgradeConfirm': 'Visszaállítás',
'admin.plugins.updatesHeld': 'Frissítések szüneteltetve a v{version} verzión',
'admin.plugins.resumeUpdates': 'Frissítések folytatása',
'admin.plugins.updatesResumed': 'Frissítések folytatva',
'admin.plugins.updateAll': 'Összes frissítése',
'admin.plugins.noMatchInstalled': 'Nincs a keresésnek megfelelő telepített plugin.',
'admin.plugins.noMatchRegistry': 'Nincs a keresésnek megfelelő plugin a tárban.',
+13
View File
@@ -471,6 +471,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Pembaruan dulu',
'admin.plugins.sortDownloads': 'Paling banyak diunduh',
'admin.plugins.updatesAvailable': '{count} pembaruan tersedia untuk plugin kamu.',
'admin.plugins.newerNeedsTrek': 'v{version} tersedia — membutuhkan TREK {range}',
'admin.plugins.versionsTitle': 'Versi',
'admin.plugins.versionPickerTitle': 'Ubah versi — {name}',
'admin.plugins.versionSwitch': 'Beralih ke {version}',
'admin.plugins.versionNeedsTrek': 'membutuhkan TREK {range}',
'admin.plugins.changeVersion': 'Ubah versi…',
'admin.plugins.noVersions': 'Tidak ada versi terpublikasi yang ditemukan di registri.',
'admin.plugins.downgradeTitle': 'Kembalikan plugin ini?',
'admin.plugins.downgradeBody': 'Beralih dari v{from} ke v{to}: data yang ditulis oleh versi yang lebih baru tetap ada, dan versi yang lebih lama mungkin tidak memahaminya.',
'admin.plugins.downgradeConfirm': 'Kembalikan',
'admin.plugins.updatesHeld': 'Pembaruan dijeda di v{version}',
'admin.plugins.resumeUpdates': 'Lanjutkan pembaruan',
'admin.plugins.updatesResumed': 'Pembaruan dilanjutkan',
'admin.plugins.updateAll': 'Perbarui semua',
'admin.plugins.noMatchInstalled': 'Tidak ada plugin terpasang yang cocok dengan pencarianmu.',
'admin.plugins.noMatchRegistry': 'Tidak ada plugin di registri yang cocok dengan pencarianmu.',
+13
View File
@@ -424,6 +424,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Prima gli aggiornamenti',
'admin.plugins.sortDownloads': 'Più scaricati',
'admin.plugins.updatesAvailable': '{count} aggiornamenti disponibili per i tuoi plugin.',
'admin.plugins.newerNeedsTrek': 'v{version} disponibile — richiede TREK {range}',
'admin.plugins.versionsTitle': 'Versioni',
'admin.plugins.versionPickerTitle': 'Cambia versione — {name}',
'admin.plugins.versionSwitch': 'Passa a {version}',
'admin.plugins.versionNeedsTrek': 'richiede TREK {range}',
'admin.plugins.changeVersion': 'Cambia versione…',
'admin.plugins.noVersions': 'Nessuna versione pubblicata trovata nel registro.',
'admin.plugins.downgradeTitle': 'Ripristinare questo plugin?',
'admin.plugins.downgradeBody': 'Passaggio da v{from} a v{to}: i dati scritti dalla versione più recente restano, e la versione precedente potrebbe non comprenderli.',
'admin.plugins.downgradeConfirm': 'Ripristina',
'admin.plugins.updatesHeld': 'Aggiornamenti in pausa alla v{version}',
'admin.plugins.resumeUpdates': 'Riprendi aggiornamenti',
'admin.plugins.updatesResumed': 'Aggiornamenti ripresi',
'admin.plugins.updateAll': 'Aggiorna tutti',
'admin.plugins.noMatchInstalled': 'Nessun plugin installato corrisponde alla tua ricerca.',
'admin.plugins.noMatchRegistry': 'Nessun plugin nel registro corrisponde alla tua ricerca.',
+13
View File
@@ -436,6 +436,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'アップデート優先',
'admin.plugins.sortDownloads': 'ダウンロード数順',
'admin.plugins.updatesAvailable': 'プラグインに{count}件のアップデートがあります。',
'admin.plugins.newerNeedsTrek': 'v{version} が利用可能 — TREK {range} が必要',
'admin.plugins.versionsTitle': 'バージョン',
'admin.plugins.versionPickerTitle': 'バージョン変更 — {name}',
'admin.plugins.versionSwitch': '{version} に切り替え',
'admin.plugins.versionNeedsTrek': 'TREK {range} が必要',
'admin.plugins.changeVersion': 'バージョン変更…',
'admin.plugins.noVersions': 'レジストリに公開バージョンが見つかりません。',
'admin.plugins.downgradeTitle': 'このプラグインをロールバックしますか?',
'admin.plugins.downgradeBody': 'v{from} から v{to} への切り替え:新しいバージョンが書き込んだデータはそのまま残り、古いバージョンはそれを認識できない可能性があります。',
'admin.plugins.downgradeConfirm': 'ロールバック',
'admin.plugins.updatesHeld': 'v{version} で更新を一時停止中',
'admin.plugins.resumeUpdates': '更新を再開',
'admin.plugins.updatesResumed': '更新を再開しました',
'admin.plugins.updateAll': 'すべて更新',
'admin.plugins.noMatchInstalled': '検索条件に一致するインストール済みプラグインはありません。',
'admin.plugins.noMatchRegistry': '検索条件に一致するプラグインはレジストリにありません。',
+13
View File
@@ -436,6 +436,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': '업데이트 우선',
'admin.plugins.sortDownloads': '다운로드순',
'admin.plugins.updatesAvailable': '플러그인 업데이트 {count}건이 있습니다.',
'admin.plugins.newerNeedsTrek': 'v{version} 사용 가능 — TREK {range} 필요',
'admin.plugins.versionsTitle': '버전',
'admin.plugins.versionPickerTitle': '버전 변경 — {name}',
'admin.plugins.versionSwitch': '{version}(으)로 전환',
'admin.plugins.versionNeedsTrek': 'TREK {range} 필요',
'admin.plugins.changeVersion': '버전 변경…',
'admin.plugins.noVersions': '레지스트리에서 게시된 버전을 찾을 수 없습니다.',
'admin.plugins.downgradeTitle': '이 플러그인을 롤백하시겠습니까?',
'admin.plugins.downgradeBody': 'v{from}에서 v{to}(으)로 전환: 최신 버전이 기록한 데이터는 그대로 유지되며, 이전 버전이 이를 인식하지 못할 수 있습니다.',
'admin.plugins.downgradeConfirm': '롤백',
'admin.plugins.updatesHeld': 'v{version}에서 업데이트 일시 중지됨',
'admin.plugins.resumeUpdates': '업데이트 재개',
'admin.plugins.updatesResumed': '업데이트가 재개되었습니다',
'admin.plugins.updateAll': '모두 업데이트',
'admin.plugins.noMatchInstalled': '검색과 일치하는 설치된 플러그인이 없습니다.',
'admin.plugins.noMatchRegistry': '레지스트리에 검색과 일치하는 플러그인이 없습니다.',
+13
View File
@@ -423,6 +423,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Updates eerst',
'admin.plugins.sortDownloads': 'Meest gedownload',
'admin.plugins.updatesAvailable': '{count} updates beschikbaar voor je plugins.',
'admin.plugins.newerNeedsTrek': 'v{version} beschikbaar — vereist TREK {range}',
'admin.plugins.versionsTitle': 'Versies',
'admin.plugins.versionPickerTitle': 'Versie wijzigen — {name}',
'admin.plugins.versionSwitch': 'Overschakelen naar {version}',
'admin.plugins.versionNeedsTrek': 'vereist TREK {range}',
'admin.plugins.changeVersion': 'Versie wijzigen…',
'admin.plugins.noVersions': 'Geen gepubliceerde versies gevonden in het register.',
'admin.plugins.downgradeTitle': 'Deze plugin terugdraaien?',
'admin.plugins.downgradeBody': 'Overschakelen van v{from} naar v{to}: gegevens die door de nieuwere versie zijn geschreven blijven staan, en de oudere versie begrijpt ze mogelijk niet.',
'admin.plugins.downgradeConfirm': 'Terugdraaien',
'admin.plugins.updatesHeld': 'Updates gepauzeerd op v{version}',
'admin.plugins.resumeUpdates': 'Updates hervatten',
'admin.plugins.updatesResumed': 'Updates hervat',
'admin.plugins.updateAll': 'Alles bijwerken',
'admin.plugins.noMatchInstalled': 'Geen geïnstalleerde plugins komen overeen met je zoekopdracht.',
'admin.plugins.noMatchRegistry': 'Geen plugins in het register komen overeen met je zoekopdracht.',
+13
View File
@@ -421,6 +421,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Najpierw aktualizacje',
'admin.plugins.sortDownloads': 'Najczęściej pobierane',
'admin.plugins.updatesAvailable': 'Dostępne aktualizacje dla Twoich wtyczek: {count}.',
'admin.plugins.newerNeedsTrek': 'v{version} dostępna — wymaga TREK {range}',
'admin.plugins.versionsTitle': 'Wersje',
'admin.plugins.versionPickerTitle': 'Zmień wersję — {name}',
'admin.plugins.versionSwitch': 'Przełącz na {version}',
'admin.plugins.versionNeedsTrek': 'wymaga TREK {range}',
'admin.plugins.changeVersion': 'Zmień wersję…',
'admin.plugins.noVersions': 'Nie znaleziono opublikowanych wersji w rejestrze.',
'admin.plugins.downgradeTitle': 'Przywrócić starszą wersję tej wtyczki?',
'admin.plugins.downgradeBody': 'Przejście z v{from} na v{to}: dane zapisane przez nowszą wersję pozostają, a starsza wersja może ich nie rozumieć.',
'admin.plugins.downgradeConfirm': 'Przywróć',
'admin.plugins.updatesHeld': 'Aktualizacje wstrzymane na v{version}',
'admin.plugins.resumeUpdates': 'Wznów aktualizacje',
'admin.plugins.updatesResumed': 'Wznowiono aktualizacje',
'admin.plugins.updateAll': 'Zaktualizuj wszystkie',
'admin.plugins.noMatchInstalled': 'Żadna zainstalowana wtyczka nie pasuje do wyszukiwania.',
'admin.plugins.noMatchRegistry': 'Żadna wtyczka w rejestrze nie pasuje do wyszukiwania.',
+13
View File
@@ -425,6 +425,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Сначала с обновлениями',
'admin.plugins.sortDownloads': 'По загрузкам',
'admin.plugins.updatesAvailable': 'Доступно обновлений для ваших плагинов: {count}.',
'admin.plugins.newerNeedsTrek': 'Доступна v{version} — требуется TREK {range}',
'admin.plugins.versionsTitle': 'Версии',
'admin.plugins.versionPickerTitle': 'Сменить версию — {name}',
'admin.plugins.versionSwitch': 'Перейти на {version}',
'admin.plugins.versionNeedsTrek': 'требуется TREK {range}',
'admin.plugins.changeVersion': 'Сменить версию…',
'admin.plugins.noVersions': 'В реестре не найдено опубликованных версий.',
'admin.plugins.downgradeTitle': 'Откатить этот плагин?',
'admin.plugins.downgradeBody': 'Переход с v{from} на v{to}: данные, записанные более новой версией, остаются на месте, и старая версия может их не понять.',
'admin.plugins.downgradeConfirm': 'Откатить',
'admin.plugins.updatesHeld': 'Обновления приостановлены на v{version}',
'admin.plugins.resumeUpdates': 'Возобновить обновления',
'admin.plugins.updatesResumed': 'Обновления возобновлены',
'admin.plugins.updateAll': 'Обновить все',
'admin.plugins.noMatchInstalled': 'Нет установленных плагинов по вашему запросу.',
'admin.plugins.noMatchRegistry': 'В реестре нет плагинов по вашему запросу.',
+13
View File
@@ -479,6 +479,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Uppdateringar först',
'admin.plugins.sortDownloads': 'Mest nedladdade',
'admin.plugins.updatesAvailable': '{count} uppdateringar tillgängliga för dina plugins.',
'admin.plugins.newerNeedsTrek': 'v{version} tillgänglig — kräver TREK {range}',
'admin.plugins.versionsTitle': 'Versioner',
'admin.plugins.versionPickerTitle': 'Byt version — {name}',
'admin.plugins.versionSwitch': 'Byt till {version}',
'admin.plugins.versionNeedsTrek': 'kräver TREK {range}',
'admin.plugins.changeVersion': 'Byt version…',
'admin.plugins.noVersions': 'Inga publicerade versioner hittades i registret.',
'admin.plugins.downgradeTitle': 'Rulla tillbaka detta tillägg?',
'admin.plugins.downgradeBody': 'Byte från v{from} till v{to}: data som skrivits av den nyare versionen finns kvar, och den äldre versionen kanske inte förstår dem.',
'admin.plugins.downgradeConfirm': 'Rulla tillbaka',
'admin.plugins.updatesHeld': 'Uppdateringar pausade vid v{version}',
'admin.plugins.resumeUpdates': 'Återuppta uppdateringar',
'admin.plugins.updatesResumed': 'Uppdateringar återupptagna',
'admin.plugins.updateAll': 'Uppdatera alla',
'admin.plugins.noMatchInstalled': 'Inga installerade plugins matchar din sökning.',
'admin.plugins.noMatchRegistry': 'Inga plugins i registret matchar din sökning.',
+13
View File
@@ -468,6 +468,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Önce güncellemeler',
'admin.plugins.sortDownloads': 'En çok indirilen',
'admin.plugins.updatesAvailable': 'Eklentileriniz için {count} güncelleme mevcut.',
'admin.plugins.newerNeedsTrek': 'v{version} mevcut — TREK {range} gerektirir',
'admin.plugins.versionsTitle': 'Sürümler',
'admin.plugins.versionPickerTitle': 'Sürüm değiştir — {name}',
'admin.plugins.versionSwitch': '{version} sürümüne geç',
'admin.plugins.versionNeedsTrek': 'TREK {range} gerektirir',
'admin.plugins.changeVersion': 'Sürüm değiştir…',
'admin.plugins.noVersions': 'Kayıt defterinde yayımlanmış sürüm bulunamadı.',
'admin.plugins.downgradeTitle': 'Bu eklenti geri alınsın mı?',
'admin.plugins.downgradeBody': 'v{from} sürümünden v{to} sürümüne geçiş: daha yeni sürümün yazdığı veriler yerinde kalır ve eski sürüm bunları anlamayabilir.',
'admin.plugins.downgradeConfirm': 'Geri al',
'admin.plugins.updatesHeld': 'Güncellemeler v{version} sürümünde duraklatıldı',
'admin.plugins.resumeUpdates': 'Güncellemeleri sürdür',
'admin.plugins.updatesResumed': 'Güncellemeler sürdürüldü',
'admin.plugins.updateAll': 'Tümünü güncelle',
'admin.plugins.noMatchInstalled': 'Aramanızla eşleşen yüklü eklenti yok.',
'admin.plugins.noMatchRegistry': 'Dizinde aramanızla eşleşen eklenti yok.',
+13
View File
@@ -422,6 +422,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Спочатку оновлення',
'admin.plugins.sortDownloads': 'За завантаженнями',
'admin.plugins.updatesAvailable': 'Для ваших плагінів доступно оновлень: {count}.',
'admin.plugins.newerNeedsTrek': 'Доступна v{version} — потрібен TREK {range}',
'admin.plugins.versionsTitle': 'Версії',
'admin.plugins.versionPickerTitle': 'Змінити версію — {name}',
'admin.plugins.versionSwitch': 'Перейти на {version}',
'admin.plugins.versionNeedsTrek': 'потрібен TREK {range}',
'admin.plugins.changeVersion': 'Змінити версію…',
'admin.plugins.noVersions': 'У реєстрі не знайдено опублікованих версій.',
'admin.plugins.downgradeTitle': 'Відкотити цей плагін?',
'admin.plugins.downgradeBody': 'Перехід з v{from} на v{to}: дані, записані новішою версією, залишаються, і старіша версія може їх не розуміти.',
'admin.plugins.downgradeConfirm': 'Відкотити',
'admin.plugins.updatesHeld': 'Оновлення призупинено на v{version}',
'admin.plugins.resumeUpdates': 'Відновити оновлення',
'admin.plugins.updatesResumed': 'Оновлення відновлено',
'admin.plugins.updateAll': 'Оновити все',
'admin.plugins.noMatchInstalled': 'Жоден встановлений плагін не відповідає вашому запиту.',
'admin.plugins.noMatchRegistry': 'Жоден плагін у реєстрі не відповідає вашому запиту.',
+13
View File
@@ -421,6 +421,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Ưu tiên bản cập nhật',
'admin.plugins.sortDownloads': 'Tải nhiều nhất',
'admin.plugins.updatesAvailable': 'Có {count} bản cập nhật cho các plugin của bạn.',
'admin.plugins.newerNeedsTrek': 'Đã có v{version} — cần TREK {range}',
'admin.plugins.versionsTitle': 'Phiên bản',
'admin.plugins.versionPickerTitle': 'Đổi phiên bản — {name}',
'admin.plugins.versionSwitch': 'Chuyển sang {version}',
'admin.plugins.versionNeedsTrek': 'cần TREK {range}',
'admin.plugins.changeVersion': 'Đổi phiên bản…',
'admin.plugins.noVersions': 'Không tìm thấy phiên bản đã xuất bản trong sổ đăng ký.',
'admin.plugins.downgradeTitle': 'Quay lại phiên bản cũ của plugin này?',
'admin.plugins.downgradeBody': 'Chuyển từ v{from} sang v{to}: dữ liệu do phiên bản mới hơn ghi vẫn còn nguyên, và phiên bản cũ hơn có thể không hiểu được.',
'admin.plugins.downgradeConfirm': 'Quay lại',
'admin.plugins.updatesHeld': 'Đã tạm dừng cập nhật ở v{version}',
'admin.plugins.resumeUpdates': 'Tiếp tục cập nhật',
'admin.plugins.updatesResumed': 'Đã tiếp tục cập nhật',
'admin.plugins.updateAll': 'Cập nhật tất cả',
'admin.plugins.noMatchInstalled': 'Không có plugin đã cài đặt nào khớp với tìm kiếm của bạn.',
'admin.plugins.noMatchRegistry': 'Không có plugin nào trong kho đăng ký khớp với tìm kiếm của bạn.',
+13
View File
@@ -418,6 +418,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': '更新優先',
'admin.plugins.sortDownloads': '下載最多',
'admin.plugins.updatesAvailable': '你的外掛有 {count} 項可用更新。',
'admin.plugins.newerNeedsTrek': 'v{version} 可用 — 需要 TREK {range}',
'admin.plugins.versionsTitle': '版本',
'admin.plugins.versionPickerTitle': '更換版本 — {name}',
'admin.plugins.versionSwitch': '切換到 {version}',
'admin.plugins.versionNeedsTrek': '需要 TREK {range}',
'admin.plugins.changeVersion': '更換版本…',
'admin.plugins.noVersions': '在登錄檔中未找到已發布的版本。',
'admin.plugins.downgradeTitle': '回滾此外掛?',
'admin.plugins.downgradeBody': '從 v{from} 切換到 v{to}:較新版本寫入的資料將保留,較舊版本可能無法識別這些資料。',
'admin.plugins.downgradeConfirm': '回滾',
'admin.plugins.updatesHeld': '更新已暫停在 v{version}',
'admin.plugins.resumeUpdates': '恢復更新',
'admin.plugins.updatesResumed': '已恢復更新',
'admin.plugins.updateAll': '全部更新',
'admin.plugins.noMatchInstalled': '沒有符合搜尋條件的已安裝外掛。',
'admin.plugins.noMatchRegistry': '外掛庫中沒有符合搜尋條件的外掛。',
+13
View File
@@ -378,6 +378,19 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': '优先显示有更新的',
'admin.plugins.sortDownloads': '下载最多',
'admin.plugins.updatesAvailable': '你的插件有 {count} 个可用更新。',
'admin.plugins.newerNeedsTrek': 'v{version} 可用 — 需要 TREK {range}',
'admin.plugins.versionsTitle': '版本',
'admin.plugins.versionPickerTitle': '更换版本 — {name}',
'admin.plugins.versionSwitch': '切换到 {version}',
'admin.plugins.versionNeedsTrek': '需要 TREK {range}',
'admin.plugins.changeVersion': '更换版本…',
'admin.plugins.noVersions': '在注册表中未找到已发布的版本。',
'admin.plugins.downgradeTitle': '回滚此插件?',
'admin.plugins.downgradeBody': '从 v{from} 切换到 v{to}:较新版本写入的数据将保留,较旧版本可能无法识别这些数据。',
'admin.plugins.downgradeConfirm': '回滚',
'admin.plugins.updatesHeld': '更新已暂停在 v{version}',
'admin.plugins.resumeUpdates': '恢复更新',
'admin.plugins.updatesResumed': '已恢复更新',
'admin.plugins.updateAll': '全部更新',
'admin.plugins.noMatchInstalled': '没有已安装的插件与你的搜索匹配。',
'admin.plugins.noMatchRegistry': '插件库中没有与你的搜索匹配的插件。',