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:
@@ -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] };
|
||||
}
|
||||
Reference in New Issue
Block a user