bc2f310e73
* feat(plugins): add plugin marketplace with curated registry and updates Discover and install plugins from inside kandev instead of only by URL/upload. - Backend marketplace subpackage: SQLite-backed sources, catalog fetch/merge (dedup, install_state, degraded-source handling), and the /api/plugins/ marketplace[/sources] API. Install reuses the existing install-by-URL pipeline. - Frontend: Settings > Plugins gains a Browse tab (search/category/star sort), source management, one-click install, and an Update button on the Installed tab when the catalog has a newer version. Plugins may ship an icon (manifest `icon` -> catalog icon_url) with a letter-tile fallback. - Registry infra: plugin-registry/ (curated plugins.yaml + schema.json) and two GitHub Actions (zero-dependency Node build-index.mjs -> index.json on Pages, daily star refresh). Ships empty at launch; the empty catalog is valid. - Spec: docs/specs/plugins/marketplace.md. No download telemetry; stars only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): address marketplace PR review feedback - CodeQL: stub fetch routers keyed off a path segment, not a hostname substring. - Nullable stars end to end: a failed star lookup stays unknown (not 0) and sorts last, instead of decoding null -> 0 and corrupting ranking. - Fetch marketplace sources concurrently and dedupe identical in-flight downloads (singleflight), so a slow source can't serialize the Browse tab. - Register /marketplace routes before the /:id wildcard (gin tree ordering). - Duplicate source URL returns 409 ErrDuplicateSource, not a raw SQL error. - Browse: filter category client-side so the category dropdown never collapses. - marketplaceInstall swallows after toasting (no unhandled promise rejection). - Source badge keys off source_id, not a name substring. - build-index: read block-sequence manifest categories, not just inline. - Registry index workflow validates on pull_request; Pages deploy gated to push/schedule/dispatch. Spec: align package_sha256 (advisory in v1) + sort enum. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): guard catalog repo_url against javascript: href injection repo_url comes from an untrusted index.json, so a malicious/compromised source could set a javascript: URL that runs in the operator's session when the Repo link is clicked (rel/target don't block it). Only render the anchor for http(s) schemes. Adds regression tests for the scheme guard and null-star rendering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): require https for remote sources; test oversized download - Reject remote plain-http marketplace sources (MITM catalog-poisoning risk); loopback http stays allowed for local dev/e2e fixtures. - Add the missing oversized-response assertion to TestDownloadRejectsNon200AndOversized so the maxIndexBytes memory bound has coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): detach source download from singleflight leader's context Two concurrent Browse opens after a cache Refresh share one singleflight download; if the leader's request context is canceled (it navigates away), the waiters must not inherit that cancellation. Use context.WithoutCancel so values are preserved but the leader can't cancel everyone; the client's 20s timeout still bounds the download. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
208 lines
6.5 KiB
TypeScript
208 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { IconRefresh } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs";
|
|
import { SettingsPageTemplate } from "@/components/settings/settings-page-template";
|
|
import { usePlugins } from "@/hooks/domains/plugins/use-plugins";
|
|
import { usePluginUpdates } from "@/hooks/domains/plugins/use-plugin-updates";
|
|
import type { MarketplaceEntry } from "@/lib/types/plugins";
|
|
import { InstallPluginDialog } from "./install-plugin-dialog";
|
|
import { MarketplaceBrowser } from "./marketplace-browser";
|
|
import { PluginRow } from "./plugin-row";
|
|
import { UninstallPluginDialog } from "./uninstall-plugin-dialog";
|
|
import { usePluginActions } from "./use-plugin-actions";
|
|
|
|
/**
|
|
* Operator UI to browse, install, enable, disable, uninstall, and update kandev
|
|
* plugins (docs/specs/plugins/marketplace.md). Gated on the `plugins` feature
|
|
* flag by the page-level default export.
|
|
*/
|
|
export function PluginsSettings() {
|
|
const list = usePlugins();
|
|
const actions = usePluginActions();
|
|
const { updates, reload: reloadUpdates } = usePluginUpdates();
|
|
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
|
|
|
// Update = install the newer package over the current one (marketplaceInstall
|
|
// upserts the new record into the store, so the row's version refreshes),
|
|
// then re-check the catalog so the resolved update drops off the row.
|
|
const handleUpdate = async (entry: MarketplaceEntry) => {
|
|
setUpdatingId(entry.id);
|
|
try {
|
|
await actions.marketplaceInstall(entry.package_url);
|
|
reloadUpdates();
|
|
} finally {
|
|
setUpdatingId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<SettingsPageTemplate
|
|
title="Plugins"
|
|
description="Browse the marketplace, then install, enable, disable, and uninstall kandev plugins."
|
|
isDirty={false}
|
|
saveStatus="idle"
|
|
onSave={() => undefined}
|
|
showSaveButton={false}
|
|
>
|
|
<Tabs defaultValue="installed" className="space-y-6">
|
|
<TabsList>
|
|
<TabsTrigger
|
|
value="installed"
|
|
data-testid="plugins-tab-installed"
|
|
className="cursor-pointer"
|
|
>
|
|
Installed
|
|
</TabsTrigger>
|
|
<TabsTrigger value="browse" data-testid="plugins-tab-browse" className="cursor-pointer">
|
|
Browse
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="installed" className="space-y-6">
|
|
<InstalledTab
|
|
list={list}
|
|
actions={actions}
|
|
updates={updates}
|
|
updatingId={updatingId}
|
|
onUpdate={handleUpdate}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="browse">
|
|
<MarketplaceBrowser onInstallUrl={actions.marketplaceInstall} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<UninstallPluginDialog
|
|
target={actions.uninstallTarget}
|
|
busy={actions.uninstallBusy}
|
|
onClose={actions.closeUninstall}
|
|
onConfirm={actions.confirmUninstall}
|
|
/>
|
|
<InstallPluginDialog
|
|
open={actions.installOpen}
|
|
busy={actions.installBusy}
|
|
error={actions.installError}
|
|
onOpenChange={actions.setInstallOpen}
|
|
onSubmitUrl={actions.submitInstallUrl}
|
|
onSubmitFile={actions.submitInstallFile}
|
|
/>
|
|
</SettingsPageTemplate>
|
|
);
|
|
}
|
|
|
|
type InstalledTabProps = {
|
|
list: ReturnType<typeof usePlugins>;
|
|
actions: ReturnType<typeof usePluginActions>;
|
|
updates: Map<string, MarketplaceEntry>;
|
|
updatingId: string | null;
|
|
onUpdate: (entry: MarketplaceEntry) => void;
|
|
};
|
|
|
|
/** The Installed tab: sync/install toolbar, sync errors, and the plugin list. */
|
|
function InstalledTab({ list, actions, updates, updatingId, onUpdate }: InstalledTabProps) {
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-sm font-medium text-foreground">Installed plugins</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
data-testid="plugins-sync-button"
|
|
variant="secondary"
|
|
disabled={actions.syncBusy}
|
|
onClick={actions.handleSync}
|
|
className="cursor-pointer"
|
|
>
|
|
<IconRefresh className={`h-4 w-4 ${actions.syncBusy ? "animate-spin" : ""}`} />
|
|
Sync
|
|
</Button>
|
|
<Button
|
|
data-testid="install-plugin-trigger"
|
|
onClick={actions.openInstall}
|
|
className="cursor-pointer"
|
|
>
|
|
Install plugin
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{actions.syncErrors.length > 0 && (
|
|
<div
|
|
data-testid="plugins-sync-errors"
|
|
className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400 space-y-1"
|
|
>
|
|
{actions.syncErrors.map((err) => (
|
|
<div key={err.path} className="font-mono text-xs">
|
|
{err.path}: {err.reason}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<PluginList
|
|
list={list}
|
|
actions={actions}
|
|
updates={updates}
|
|
updatingId={updatingId}
|
|
onUpdate={onUpdate}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
type PluginListProps = {
|
|
list: ReturnType<typeof usePlugins>;
|
|
actions: ReturnType<typeof usePluginActions>;
|
|
updates: Map<string, MarketplaceEntry>;
|
|
updatingId: string | null;
|
|
onUpdate: (entry: MarketplaceEntry) => void;
|
|
};
|
|
|
|
function PluginList({ list, actions, updates, updatingId, onUpdate }: PluginListProps) {
|
|
const { items, loaded, loading, error } = list;
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-sm text-destructive">
|
|
{error}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!loaded && loading) {
|
|
return (
|
|
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
|
Loading plugins...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loaded && items.length === 0) {
|
|
return (
|
|
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
|
No plugins yet. Install one from the Browse tab, or upload a package.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{items.map((plugin) => (
|
|
<PluginRow
|
|
key={plugin.id}
|
|
plugin={plugin}
|
|
busy={actions.busyId === plugin.id || updatingId === plugin.id}
|
|
update={updates.get(plugin.id)}
|
|
onEnable={actions.handleEnable}
|
|
onDisable={actions.handleDisable}
|
|
onUninstall={actions.openUninstall}
|
|
onUpdate={onUpdate}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|