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>
179 lines
5.0 KiB
TypeScript
179 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { IconTrash } from "@tabler/icons-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@kandev/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@kandev/ui/dialog";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { Switch } from "@kandev/ui/switch";
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import {
|
|
addMarketplaceSource,
|
|
deleteMarketplaceSource,
|
|
updateMarketplaceSource,
|
|
} from "@/lib/api/domains/marketplace-api";
|
|
import type { MarketplaceSource } from "@/lib/types/plugins";
|
|
|
|
type MarketplaceSourcesDialogProps = {
|
|
open: boolean;
|
|
sources: MarketplaceSource[];
|
|
onOpenChange: (open: boolean) => void;
|
|
onChanged: () => void;
|
|
};
|
|
|
|
/**
|
|
* Manage marketplace sources: the built-in kandev source (toggle only) plus
|
|
* operator-added team/corporate registries (add / toggle / delete). Every
|
|
* mutation calls onChanged so the catalog re-fetches.
|
|
*/
|
|
export function MarketplaceSourcesDialog({
|
|
open,
|
|
sources,
|
|
onOpenChange,
|
|
onChanged,
|
|
}: MarketplaceSourcesDialogProps) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Marketplace sources</DialogTitle>
|
|
<DialogDescription>
|
|
Add a team or corporate registry to browse its plugins alongside the official catalog.
|
|
Each source serves an index.json document.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-2">
|
|
{sources.map((source) => (
|
|
<SourceItem key={source.id} source={source} onChanged={onChanged} />
|
|
))}
|
|
</div>
|
|
|
|
<AddSourceForm onChanged={onChanged} />
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChanged: () => void }) {
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const toggle = async (enabled: boolean) => {
|
|
setBusy(true);
|
|
try {
|
|
await updateMarketplaceSource(source.id, { enabled });
|
|
onChanged();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed to update source");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const remove = async () => {
|
|
setBusy(true);
|
|
try {
|
|
await deleteMarketplaceSource(source.id);
|
|
onChanged();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed to remove source");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
data-testid={`marketplace-source-${source.id}`}
|
|
className="flex items-center justify-between gap-2 rounded-md border border-border/60 p-3"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium truncate">{source.name}</span>
|
|
{source.builtin && (
|
|
<Badge variant="outline" className="text-[10px]">
|
|
Official
|
|
</Badge>
|
|
)}
|
|
{source.healthy === false && (
|
|
<Badge variant="destructive" className="text-[10px]">
|
|
Unreachable
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground truncate block">{source.url}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Switch checked={source.enabled} disabled={busy} onCheckedChange={toggle} />
|
|
{!source.builtin && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
disabled={busy}
|
|
onClick={remove}
|
|
className="cursor-pointer"
|
|
aria-label={`Remove ${source.name}`}
|
|
>
|
|
<IconTrash className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AddSourceForm({ onChanged }: { onChanged: () => void }) {
|
|
const [name, setName] = useState("");
|
|
const [url, setUrl] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const submit = async () => {
|
|
if (!url.trim()) return;
|
|
setBusy(true);
|
|
try {
|
|
await addMarketplaceSource(name.trim(), url.trim());
|
|
setName("");
|
|
setUrl("");
|
|
onChanged();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed to add source");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2 border-t border-border/60 pt-3">
|
|
<Input
|
|
placeholder="Source name (e.g. Acme Internal)"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
data-testid="marketplace-add-source-name"
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
placeholder="https://.../index.json"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
data-testid="marketplace-add-source-url"
|
|
/>
|
|
<Button
|
|
disabled={busy || !url.trim()}
|
|
onClick={submit}
|
|
className="cursor-pointer shrink-0"
|
|
data-testid="marketplace-add-source-submit"
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|