update a plugin, or all of them, from the marketplace
There was no update path. fetchPlugin REFUSES an existing directory — cloning over one is how uncommitted work disappears — so install was a one-way door, and the answer to "my plugin changed" was uninstall and reinstall, which drops its tables on the way past. An update is a git pull and then the ORDINARY install path. installPlugin already skips the clone when the directory is present and re-runs everything else: os dependencies, schema push, assets, the ecosystem entry, the sidecar, the remount and the frontend rebuild. So there is no second implementation of "apply a plugin" here to drift from the first. Two refusals, both about not destroying work. A dirty working tree is refused rather than stashed or force-pulled — every plugin directory on a developer's machine is a live checkout, and this repository's own are edited in place. Non-fast-forward is refused for the same reason: a diverged local commit is somebody's work and merging it automatically is not the platform's decision. The per-plugin button replaces one that was quietly broken: it appeared only when `outdated` and called `install`, which cannot fetch, so it re-applied stale code. Nothing bumps a version today either, so with everything pinned at 1.0.0 that flag never fires and the button never appeared. Now always offered, and it pulls. Update-all is a plain POST rather than the streaming door: twelve interleaved step streams is not a readable log, and the useful answer is the summary. It runs sequentially — each update pushes schema, republishes assets, rewrites the ecosystem file and rebuilds the frontend, and two of those at once is a race over one file each. One failure does not stop the rest, and failures are named with their reason, because "2 failed" sends someone to the terminal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -460,11 +460,14 @@ export const PluginDetail = () => {
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
{plugin.outdated ? (
|
||||
<Button disabled={busy} onClick={() => run('install', plugin.appName)}>
|
||||
Update to {plugin.version}
|
||||
</Button>
|
||||
) : null}
|
||||
{/* Always offered, not gated on `outdated`. That flag compares the manifest's version to the
|
||||
recorded one, and nothing bumps a version today — so an artwork or code change lands with
|
||||
the number unmoved and the button would never appear. It also used to run `install`,
|
||||
which cannot fetch: the directory exists, so the clone is skipped and only the apply
|
||||
steps re-ran. `update` pulls first. */}
|
||||
<Button disabled={busy} onClick={() => run('update', plugin.appName)}>
|
||||
{plugin.outdated ? `Update to ${plugin.version}` : 'Update'}
|
||||
</Button>
|
||||
<Button tone="danger" disabled={busy} onClick={() => run('uninstall', plugin.appName)}>
|
||||
Uninstall
|
||||
</Button>
|
||||
|
||||
@@ -17,7 +17,7 @@ const Status = ({ plugin }: { plugin: PluginItem }) => {
|
||||
};
|
||||
|
||||
export const PluginsList = () => {
|
||||
const { plugins, available, broken, orphans, isLoading } = usePlugins();
|
||||
const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll, updateAllResult } = usePlugins();
|
||||
const [params] = useSearchParams();
|
||||
const selected = params.get('selected');
|
||||
|
||||
@@ -25,6 +25,42 @@ export const PluginsList = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
{/* Update-all lives on the LIST, not in a plugin's detail: it is the one action here that is about
|
||||
all of them at once. Hidden when nothing is installed, because "update" of nothing reads as a
|
||||
broken button rather than a no-op.
|
||||
|
||||
There is no versioning yet, so this cannot know which plugins have anything to pull — it pulls
|
||||
each and re-applies. Saying "Update all" rather than showing a count is the honest label for
|
||||
that: a count would imply a check that has not happened. */}
|
||||
{plugins.length ? (
|
||||
<div className="flex items-center justify-between gap-2 border-b border-duck-dark/5 px-3 py-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">Installed</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void updateAll()}
|
||||
disabled={updatingAll}
|
||||
className="rounded-md border border-duck-dark/15 px-2 py-1 text-xs font-medium text-duck-dark transition-colors hover:bg-duck-dark/5 disabled:opacity-50"
|
||||
>
|
||||
{updatingAll ? 'Updating…' : 'Update all'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{updateAllResult ? (
|
||||
<div className="border-b border-duck-dark/5 px-3 py-2 text-xs">
|
||||
<span className="text-duck-dark/60">
|
||||
{updateAllResult.updated} updated
|
||||
{updateAllResult.failed.length ? `, ${updateAllResult.failed.length} failed` : ''}
|
||||
</span>
|
||||
{/* Named, with the reason. The expected failure is a dirty checkout, and "2 failed" without
|
||||
saying which is a message that sends someone to the terminal to find out. */}
|
||||
{updateAllResult.failed.map((f) => (
|
||||
<div key={f.appName} className="mt-0.5 text-destructive">
|
||||
{f.appName}: {f.error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{plugins.length === 0 && available.length === 0 && broken.length === 0 ? (
|
||||
<div className="p-4 text-sm text-duck-dark/50">
|
||||
No plugins in <code>plugins/</code> yet.
|
||||
|
||||
@@ -75,7 +75,14 @@ export type PluginItem = {
|
||||
/** What a verb actually did, in order. Shown rather than collapsed to a spinner. */
|
||||
export type PluginActionResult = { ok: boolean; appName: string; steps: string[]; error?: string };
|
||||
|
||||
export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable' | 'purge';
|
||||
/** What `POST /plugins/update-all` answers with. Per-plugin, because one failing must not hide the rest. */
|
||||
export type UpdateAllResult = {
|
||||
results: PluginActionResult[];
|
||||
updated: number;
|
||||
failed: { appName: string; error?: string }[];
|
||||
};
|
||||
|
||||
export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable' | 'purge' | 'update';
|
||||
|
||||
/**
|
||||
* One line in the action log.
|
||||
@@ -232,6 +239,27 @@ export function usePlugins() {
|
||||
[invalidate],
|
||||
);
|
||||
|
||||
// Update-all is a PLAIN POST, not the streaming door the single verbs use. Twelve interleaved step
|
||||
// streams is not a readable log, and the useful answer here is the summary: how many moved, and which
|
||||
// refused. A dirty checkout is the expected refusal — every plugin directory on a developer's machine
|
||||
// is a live one — and it must not stop the rest, which is why the server runs them in sequence and
|
||||
// collects failures rather than aborting.
|
||||
const [updatingAll, setUpdatingAll] = useState(false);
|
||||
const [updateAllResult, setUpdateAllResult] = useState<UpdateAllResult | null>(null);
|
||||
|
||||
const updateAll = useCallback(async () => {
|
||||
setUpdatingAll(true);
|
||||
setUpdateAllResult(null);
|
||||
try {
|
||||
const res = await client.post<UpdateAllResult>('/plugins/update-all', {});
|
||||
setUpdateAllResult(res);
|
||||
invalidate();
|
||||
return res;
|
||||
} finally {
|
||||
setUpdatingAll(false);
|
||||
}
|
||||
}, [invalidate]);
|
||||
|
||||
return {
|
||||
plugins: data?.plugins ?? [],
|
||||
/** Offered by the marketplace, absent from this machine. Empty when the catalogue cannot be reached. */
|
||||
@@ -247,5 +275,8 @@ export function usePlugins() {
|
||||
/** Which verb is in flight, or null. */
|
||||
running,
|
||||
run,
|
||||
updateAll,
|
||||
updatingAll,
|
||||
updateAllResult,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user