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:
@@ -15,6 +15,7 @@ import {
|
|||||||
uninstallPlugin,
|
uninstallPlugin,
|
||||||
type PluginActionResult,
|
type PluginActionResult,
|
||||||
} from '../../plugins/install';
|
} from '../../plugins/install';
|
||||||
|
import { updatePlugin } from '../../plugins/update';
|
||||||
|
|
||||||
// /api/plugins — what is on this machine, what is installed, and the four verbs that change it.
|
// /api/plugins — what is on this machine, what is installed, and the four verbs that change it.
|
||||||
//
|
//
|
||||||
@@ -176,6 +177,31 @@ pluginsRouter.get('/', async (ctx) => {
|
|||||||
* "installed" and "installed but the sidecar would not start" are different outcomes and the second is
|
* "installed" and "installed but the sidecar would not start" are different outcomes and the second is
|
||||||
* the one worth reading. See `plugins/install.ts` for why the order inside each is what it is.
|
* the one worth reading. See `plugins/install.ts` for why the order inside each is what it is.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Update every installed plugin, in sequence.
|
||||||
|
*
|
||||||
|
* SEQUENTIAL, not parallel: each update runs a schema push, republishes assets, rewrites the ecosystem
|
||||||
|
* file and rebuilds the frontend. Two of those doing it at once is a race over one file each.
|
||||||
|
*
|
||||||
|
* One plugin failing does NOT stop the rest — a dirty checkout is the expected failure here (every
|
||||||
|
* plugin directory on a developer's machine is a live one), and it should not prevent the other eleven
|
||||||
|
* from moving. The per-plugin result says which, and why.
|
||||||
|
*/
|
||||||
|
pluginsRouter.post('/update-all', async (ctx) => {
|
||||||
|
const userId = ctx.get('user').id;
|
||||||
|
const { states } = await snapshotPlugins();
|
||||||
|
const installed = states.filter((st) => st.install).map((st) => st.plugin.appName);
|
||||||
|
const results: PluginActionResult[] = [];
|
||||||
|
for (const appName of installed) {
|
||||||
|
results.push(await updatePlugin(appName, undefined, userId));
|
||||||
|
}
|
||||||
|
return ctx.json({
|
||||||
|
results,
|
||||||
|
updated: results.filter((r) => r.ok).length,
|
||||||
|
failed: results.filter((r) => !r.ok).map((r) => ({ appName: r.appName, error: r.error })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
pluginsRouter.post('/:appName/install', async (ctx) => {
|
pluginsRouter.post('/:appName/install', async (ctx) => {
|
||||||
const result = await installPlugin(ctx.req.param('appName'), undefined, ctx.get('user').id);
|
const result = await installPlugin(ctx.req.param('appName'), undefined, ctx.get('user').id);
|
||||||
return ctx.json(result, result.ok ? 200 : 400);
|
return ctx.json(result, result.ok ? 200 : 400);
|
||||||
@@ -228,6 +254,9 @@ const VERBS = {
|
|||||||
enable: (appName, onStep) => setPluginRunning(appName, true, onStep),
|
enable: (appName, onStep) => setPluginRunning(appName, true, onStep),
|
||||||
disable: (appName, onStep) => setPluginRunning(appName, false, onStep),
|
disable: (appName, onStep) => setPluginRunning(appName, false, onStep),
|
||||||
purge: (appName, onStep, _userId, onLog) => purgePlugin(appName, onStep, onLog),
|
purge: (appName, onStep, _userId, onLog) => purgePlugin(appName, onStep, onLog),
|
||||||
|
// A pull and then the ordinary install path — see plugins/update.ts. It is a verb rather than its own
|
||||||
|
// route so it inherits the streaming door and the UI that already drives install.
|
||||||
|
update: (appName, onStep, userId, onLog) => updatePlugin(appName, onStep, userId, onLog),
|
||||||
// `satisfies` rather than an annotation: an annotation would widen the keys to `string`, which turns
|
// `satisfies` rather than an annotation: an annotation would widen the keys to `string`, which turns
|
||||||
// `isVerb` into a guard that proves nothing and lets an unknown verb index the map.
|
// `isVerb` into a guard that proves nothing and lets an unknown verb index the map.
|
||||||
} satisfies Record<string, VerbFn>;
|
} satisfies Record<string, VerbFn>;
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, test, afterAll } from 'bun:test';
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { PLUGINS_DIR } from './discover';
|
||||||
|
import { pullPlugin } from './update';
|
||||||
|
|
||||||
|
// The REFUSALS are what this test is for. A pull that works is easy to believe; a pull that quietly
|
||||||
|
// discarded somebody's uncommitted work is the failure that matters, and every plugin directory on a
|
||||||
|
// developer's machine is a live checkout — this repository's own plugins are edited in place.
|
||||||
|
|
||||||
|
const tmp = (name: string) => join(PLUGINS_DIR, name);
|
||||||
|
const made: string[] = [];
|
||||||
|
const scratch = (name: string) => {
|
||||||
|
const dir = tmp(name);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
made.push(dir);
|
||||||
|
return dir;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterAll(() => made.forEach((d) => rmSync(d, { recursive: true, force: true })));
|
||||||
|
|
||||||
|
describe('pullPlugin refuses rather than guesses', () => {
|
||||||
|
test('a plugin that is not on this machine', async () => {
|
||||||
|
const r = await pullPlugin('definitely-not-installed-xyz');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.error).toContain('not on this machine');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a directory that is not a git checkout — copied in by hand, or bundled', async () => {
|
||||||
|
scratch('test-update-nogit');
|
||||||
|
const r = await pullPlugin('test-update-nogit');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.error).toContain('not a git checkout');
|
||||||
|
// Not phrased as a git error: there is nothing wrong here, there is simply nothing to pull.
|
||||||
|
expect(r.updated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a checkout with uncommitted work — the one that would destroy something', async () => {
|
||||||
|
const dir = scratch('test-update-dirty');
|
||||||
|
Bun.spawnSync(['git', 'init', '-q'], { cwd: dir });
|
||||||
|
writeFileSync(join(dir, 'manifest.ts'), 'export const manifest = {};\n');
|
||||||
|
const r = await pullPlugin('test-update-dirty');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.error).toContain('uncommitted');
|
||||||
|
expect(r.updated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refusal never reports having updated anything', async () => {
|
||||||
|
for (const name of ['definitely-not-installed-xyz', 'test-update-nogit', 'test-update-dirty']) {
|
||||||
|
const r = await pullPlugin(name);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.updated).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { PLUGINS_DIR } from './discover';
|
||||||
|
import { installPlugin } from './install';
|
||||||
|
import type { OnStep, PluginActionResult } from './install';
|
||||||
|
import type { OnLog } from './run-logged';
|
||||||
|
|
||||||
|
// Pulling a plugin's repository and re-applying it.
|
||||||
|
//
|
||||||
|
// ── Why this did not exist ──
|
||||||
|
//
|
||||||
|
// `fetchPlugin` REFUSES a directory that already exists, deliberately: cloning over the top of one is
|
||||||
|
// how somebody's uncommitted work disappears. That made install a one-way door — a plugin could be
|
||||||
|
// installed and removed but never moved forward, and the answer to "my plugin changed" was to uninstall
|
||||||
|
// and reinstall, which drops its tables on the way past.
|
||||||
|
//
|
||||||
|
// ── What an update actually 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 one; there is a pull, and then that.
|
||||||
|
//
|
||||||
|
// ── The two refusals ──
|
||||||
|
//
|
||||||
|
// A DIRTY working tree is refused rather than stashed or force-pulled. Every plugin directory on a
|
||||||
|
// developer's machine is a live checkout — this repository's own plugins are edited in place — and
|
||||||
|
// silently discarding that is a worse outcome than an update that did not happen.
|
||||||
|
//
|
||||||
|
// Non-fast-forward is refused for the same reason: a local commit that diverged is somebody's work, and
|
||||||
|
// merging it automatically would be a decision the platform is not entitled to make.
|
||||||
|
|
||||||
|
export type PullResult = {
|
||||||
|
ok: boolean;
|
||||||
|
/** False when the pull succeeded and there was simply nothing new — the common case. */
|
||||||
|
updated: boolean;
|
||||||
|
before: string | null;
|
||||||
|
after: string | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const git = async (dir: string, args: string[]): Promise<{ code: number; out: string; err: string }> => {
|
||||||
|
const proc = Bun.spawn(['git', ...args], { cwd: dir, stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||||
|
return { code: await proc.exited, out: out.trim(), err: err.trim() };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fast-forward a plugin's checkout, refusing anything that would touch local work. */
|
||||||
|
export async function pullPlugin(appName: string, onLog?: OnLog): Promise<PullResult> {
|
||||||
|
const dir = join(PLUGINS_DIR, appName);
|
||||||
|
const none = { updated: false, before: null, after: null };
|
||||||
|
|
||||||
|
if (!existsSync(dir)) return { ok: false, ...none, error: `"${appName}" is not on this machine` };
|
||||||
|
if (!existsSync(join(dir, '.git'))) {
|
||||||
|
// A plugin can legitimately live here without a repository — copied in by hand, or bundled. Nothing
|
||||||
|
// to pull, and saying so is more useful than an error about git.
|
||||||
|
return { ok: false, ...none, error: `"${appName}" is not a git checkout — nothing to pull` };
|
||||||
|
}
|
||||||
|
if (!Bun.which('git')) return { ok: false, ...none, error: 'git is not installed on this machine' };
|
||||||
|
|
||||||
|
const dirty = await git(dir, ['status', '--porcelain']);
|
||||||
|
if (dirty.out) {
|
||||||
|
const files = dirty.out.split('\n').length;
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
...none,
|
||||||
|
error: `"${appName}" has ${files} uncommitted change(s) — commit or discard them first, refusing to overwrite local work`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = (await git(dir, ['rev-parse', 'HEAD'])).out;
|
||||||
|
const branch = (await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).out;
|
||||||
|
onLog?.(`[${appName}] pulling ${branch}`);
|
||||||
|
|
||||||
|
const pulled = await git(dir, ['pull', '--ff-only', 'origin', branch]);
|
||||||
|
if (pulled.code !== 0) {
|
||||||
|
return { ok: false, updated: false, before, after: before, error: pulled.err || pulled.out || 'git pull failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = (await git(dir, ['rev-parse', 'HEAD'])).out;
|
||||||
|
if (pulled.out) onLog?.(`[${appName}] ${pulled.out.split('\n').slice(-1)[0]}`);
|
||||||
|
return { ok: true, updated: before !== after, before, after };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull, then re-apply through the ordinary install path.
|
||||||
|
*
|
||||||
|
* A pull that brought nothing new still RE-APPLIES. That is deliberate: the reason to press update is
|
||||||
|
* usually that something on disk is not reflected in the running server — assets that were pulled by
|
||||||
|
* hand, a sidecar that needs restarting — and "already up to date, did nothing" would be the least
|
||||||
|
* useful possible answer to that.
|
||||||
|
*/
|
||||||
|
export async function updatePlugin(
|
||||||
|
appName: string,
|
||||||
|
onStep?: OnStep,
|
||||||
|
userId?: number,
|
||||||
|
onLog?: OnLog,
|
||||||
|
): Promise<PluginActionResult> {
|
||||||
|
const steps: string[] = [];
|
||||||
|
const pulled = await pullPlugin(appName, onLog);
|
||||||
|
if (!pulled.ok) return { ok: false, appName, steps, error: pulled.error };
|
||||||
|
|
||||||
|
const short = (sha: string | null) => sha?.slice(0, 7) ?? '?';
|
||||||
|
steps.push(
|
||||||
|
pulled.updated ? `pulled ${short(pulled.before)} → ${short(pulled.after)}` : 'already at the latest commit',
|
||||||
|
);
|
||||||
|
onStep?.(steps[steps.length - 1]!);
|
||||||
|
|
||||||
|
const result = await installPlugin(appName, onStep, userId, onLog);
|
||||||
|
return { ...result, steps: [...steps, ...result.steps] };
|
||||||
|
}
|
||||||
@@ -460,11 +460,14 @@ export const PluginDetail = () => {
|
|||||||
Enable
|
Enable
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{plugin.outdated ? (
|
{/* Always offered, not gated on `outdated`. That flag compares the manifest's version to the
|
||||||
<Button disabled={busy} onClick={() => run('install', plugin.appName)}>
|
recorded one, and nothing bumps a version today — so an artwork or code change lands with
|
||||||
Update to {plugin.version}
|
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>
|
||||||
) : null}
|
|
||||||
<Button tone="danger" disabled={busy} onClick={() => run('uninstall', plugin.appName)}>
|
<Button tone="danger" disabled={busy} onClick={() => run('uninstall', plugin.appName)}>
|
||||||
Uninstall
|
Uninstall
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const Status = ({ plugin }: { plugin: PluginItem }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PluginsList = () => {
|
export const PluginsList = () => {
|
||||||
const { plugins, available, broken, orphans, isLoading } = usePlugins();
|
const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll, updateAllResult } = usePlugins();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const selected = params.get('selected');
|
const selected = params.get('selected');
|
||||||
|
|
||||||
@@ -25,6 +25,42 @@ export const PluginsList = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto">
|
<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 ? (
|
{plugins.length === 0 && available.length === 0 && broken.length === 0 ? (
|
||||||
<div className="p-4 text-sm text-duck-dark/50">
|
<div className="p-4 text-sm text-duck-dark/50">
|
||||||
No plugins in <code>plugins/</code> yet.
|
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. */
|
/** 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 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.
|
* One line in the action log.
|
||||||
@@ -232,6 +239,27 @@ export function usePlugins() {
|
|||||||
[invalidate],
|
[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 {
|
return {
|
||||||
plugins: data?.plugins ?? [],
|
plugins: data?.plugins ?? [],
|
||||||
/** Offered by the marketplace, absent from this machine. Empty when the catalogue cannot be reached. */
|
/** 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. */
|
/** Which verb is in flight, or null. */
|
||||||
running,
|
running,
|
||||||
run,
|
run,
|
||||||
|
updateAll,
|
||||||
|
updatingAll,
|
||||||
|
updateAllResult,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user