Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/registry-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@emdash-cms/admin": minor
"emdash": minor
---

Plugins installed from the experimental registry can now be uninstalled and updated from the admin, the same way marketplace plugins always could. The "uninstall is not yet available for registry plugins" placeholder is gone — registry plugin rows now show the same Uninstall and Update buttons.

The Plugins page's "updates available" indicator now covers registry plugins too. If the registry aggregator is unreachable, marketplace updates still load (and vice versa).

Updates that need newly-declared permissions, or that newly expose a public (unauthenticated) route, prompt for re-consent before installing the new version — matching the gate that marketplace updates already have.
27 changes: 14 additions & 13 deletions packages/admin/src/components/PluginManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
uninstallMarketplacePlugin,
type PluginUpdateInfo,
} from "../lib/api/marketplace.js";
import { uninstallRegistryPlugin, updateRegistryPlugin } from "../lib/api/registry.js";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BUG: "Check for updates" button is gated on hasMarketplacePlugins only

Category: Logic Errors
Severity: MEDIUM

Referencing unchanged code at line 122 (hasMarketplacePlugins = plugins?.some((p) => p.source === "marketplace")) and line 147 ({hasMarketplacePlugins && <Button ... onClick={refetchUpdates}>{t\Check for updates`}}`).

This PR teaches /plugins/updates to return registry plugins, but the UI button that triggers it is still rendered only when at least one marketplace plugin is installed. A site whose only plugins are registry-source -- exactly the scenario the registry rollout targets -- has no way to trigger the update check from the Plugins page.

Trigger: install a registry plugin, no marketplace plugins. Open Plugins page. "Check for updates" is never shown, so registry update availability never gets surfaced.

Fix: update the gate alongside this PR -- plugins?.some((p) => p.source === "marketplace" || p.source === "registry"), or rename the derived flag to hasUpdatableSources.

import { safeIconUrl } from "../lib/url.js";
import { cn } from "../lib/utils";
import { CaretNext } from "./ArrowIcons.js";
Expand Down Expand Up @@ -233,7 +234,13 @@ function PluginCard({
const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest;

const updateMutation = useMutation({
mutationFn: () => updateMarketplacePlugin(plugin.id, { confirmCapabilities: true }),
mutationFn: () =>
isRegistry
? updateRegistryPlugin(plugin.id, {
confirmCapabilityChanges: true,
confirmRouteVisibilityChanges: true,
})
: updateMarketplacePlugin(plugin.id, { confirmCapabilities: true }),
Comment on lines 245 to +249
onSuccess: () => {
setShowUpdateConsent(false);
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
Expand All @@ -247,7 +254,10 @@ function PluginCard({
});

const uninstallMutation = useMutation({
mutationFn: (deleteData: boolean) => uninstallMarketplacePlugin(plugin.id, { deleteData }),
mutationFn: (deleteData: boolean) =>
isRegistry
? uninstallRegistryPlugin(plugin.id, { deleteData })
: uninstallMarketplacePlugin(plugin.id, { deleteData }),
onSuccess: () => {
setShowUninstallConfirm(false);
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
Expand Down Expand Up @@ -482,8 +492,8 @@ function PluginCard({
)}
</div>

{/* Uninstall button for marketplace plugins */}
{isMarketplace && (
{/* Uninstall button for any sandboxed source (marketplace + registry). */}
{(isMarketplace || isRegistry) && (
<div className="pt-2 border-t">
<Button
variant="ghost"
Expand All @@ -496,15 +506,6 @@ function PluginCard({
</Button>
</div>
)}

{/* Registry plugins have an install path but no uninstall
handler yet. Tell the admin so they don't think the
plugin is permanent or fall back to editing the DB. */}
{isRegistry && (
<div className="pt-2 border-t text-xs text-kumo-subtle">
{t`Uninstall is not yet available for registry plugins. Disable the plugin to stop it from running; full uninstall will land in a follow-up.`}
</div>
)}
</div>
)}
</div>
Expand Down
64 changes: 64 additions & 0 deletions packages/admin/src/lib/api/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,3 +440,67 @@ export async function installRegistryPlugin(
const json = (await response.json()) as { data: RegistryInstallResult };
return json.data;
}

// ---------------------------------------------------------------------------
// Lifecycle: update + uninstall
// ---------------------------------------------------------------------------

export interface RegistryUpdateOpts {
/** Optional explicit target version; defaults to the aggregator's latest. */
version?: string;
/** Set when the user has consented to widened capabilities. */
confirmCapabilityChanges?: boolean;
/** Set when the user has consented to newly-public routes. */
confirmRouteVisibilityChanges?: boolean;
}

export interface RegistryUninstallOpts {
/** Also drop the plugin's `_plugin_storage` rows. */
deleteData?: boolean;
}

/**
* Update a registry-source plugin to a newer version.
* `POST /_emdash/api/admin/plugins/registry/:id/update`
*
* Server returns `CAPABILITY_ESCALATION` / `ROUTE_VISIBILITY_ESCALATION`
* carrying a diff when the new version widens permissions; re-call with
* the corresponding `confirm*` flag after the user has consented.
*/
export async function updateRegistryPlugin(
pluginId: string,
opts: RegistryUpdateOpts = {},
): Promise<void> {
const response = await apiFetch(
`${API_BASE}/admin/plugins/registry/${encodeURIComponent(pluginId)}/update`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
},
);
if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to update plugin`));
}

/**
* Uninstall a registry-source plugin.
* `POST /_emdash/api/admin/plugins/registry/:id/uninstall`
*
* The server refuses to uninstall non-registry sources, so calling this
* with a marketplace or config plugin id is a no-op error rather than a
* destructive cross-source action.
*/
export async function uninstallRegistryPlugin(
pluginId: string,
opts: RegistryUninstallOpts = {},
): Promise<void> {
const response = await apiFetch(
`${API_BASE}/admin/plugins/registry/${encodeURIComponent(pluginId)}/uninstall`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
},
);
if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to uninstall plugin`));
}
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,8 @@
"sax": "^1.4.1",
"ulidx": "^2.4.1",
"upng-js": "^2.1.0",
"zod": "catalog:"
"zod": "catalog:",
"@atcute/client": "catalog:"
},
"optionalDependencies": {
"@libsql/kysely-libsql": "^0.4.0",
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ export {
// Registry handlers (experimental)
export {
handleRegistryInstall,
handleRegistryUninstall,
handleRegistryUpdate,
handleRegistryUpdateCheck,
type RegistryInstallInput,
type RegistryInstallResult,
type RegistryUninstallResult,
type RegistryUpdateCheck,
type RegistryUpdateResult,
} from "./registry.js";
4 changes: 2 additions & 2 deletions packages/core/src/api/handlers/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ function getClient(
return createMarketplaceClient(marketplaceUrl, siteOrigin);
}

function diffCapabilities(
export function diffCapabilities(
oldCaps: string[],
newCaps: string[],
): { added: string[]; removed: string[] } {
Expand All @@ -114,7 +114,7 @@ function diffCapabilities(
* Diff route visibility between two manifests.
* Returns routes that changed from private to public (newly exposed).
*/
function diffRouteVisibility(
export function diffRouteVisibility(
oldManifest: PluginManifest | undefined,
newManifest: PluginManifest,
): { newlyPublic: string[] } {
Expand Down
Loading
Loading