the marketplace publishes releases, not working trees
The sync read `platform/plugins/` — the directories this machine has installed — and imported each manifest off the working tree. So publishing meant "get officer-dev's checkout to the release, then run the script", and nothing showed when that had not happened. Live proof at the time of writing: av-club 2026.08.29.01, kanban 2026.08.29.02, openseo 0.1.0 and fofos-admin 2026.08.29.26 were all in the store, and none of the four has ever been tagged. It is also why the job could not go on a timer. A cron over it published whatever was in a working tree, on a schedule. Now every registered plugin is mirrored bare into ~/.cache/officer-marketplace/mirrors and read AT ITS NEWEST TAG — manifest, README and file list all out of `git cat-file`. No working tree in the path, so publishing something uncommitted stops being a mistake anyone can make. docs/marketplace.md proposed reading manifests over Gitea's API, which needed a sandbox or a committed manifest.json. Git gives the same guarantee with neither, and the trust model is unchanged: we import our own repositories' code. Third-party submissions still need one of those two. The list of plugins now comes from the `plugins` registry table, which the docs always called the authoritative half and which was in fact a by-product of what happened to be installed. That is what lets `finances` be published without being installed here, and it spans orgs — fofos-admin lives under fofos/, which an org listing would have missed. Adding one is `--add <clone url>`. A plugin with no tag is left alone and named in the report, not deleted. It has made no release, so there is nothing true to say about it; dropping its row would be a decision about somebody's plugin dressed up as a refresh. Also: `release_tag` on plugin_metadata, because the tag is the fact when a manifest version disagrees with it; the sync warns on a mismatch. And icon URLs pin to the tag — `/raw/HEAD/` served whatever was on the default branch, via a 303, so a plugin could show one icon and ship another. Verified: 21/25 published from a release, 4 named as untagged, all 14 tag-pinned icons answer 200. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,14 @@ export type CatalogueEntry = {
|
||||
appName: string;
|
||||
publisher: string;
|
||||
version: string;
|
||||
/**
|
||||
* The git tag this entry was published from.
|
||||
*
|
||||
* Optional on the wire, not in the database, and the two mean different things. Here it is absent for
|
||||
* a row that predates release-based publishing OR for a catalogue too old to send the field at all —
|
||||
* so a consumer must treat "no tag" as "cannot tell" and fall back to whatever it knew before.
|
||||
*/
|
||||
releaseTag?: string;
|
||||
platform: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
@@ -52,6 +60,7 @@ function toCatalogueEntry(row: Row): CatalogueEntry | null {
|
||||
appName: row.appName,
|
||||
publisher: row.developer,
|
||||
version: m.version,
|
||||
...(m.releaseTag ? { releaseTag: m.releaseTag } : {}),
|
||||
platform: m.platform,
|
||||
label: m.label,
|
||||
summary: m.summary,
|
||||
@@ -106,6 +115,22 @@ export async function catalogueEntry(appName: string): Promise<CatalogueEntry |
|
||||
return { ...entry, ...(row.metadata.readme ? { readme: row.metadata.readme } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every registered plugin — identity and clone URL, no metadata.
|
||||
*
|
||||
* This is what the sync iterates, and it is the reason the registry is called authoritative: the
|
||||
* catalogue lists what somebody deliberately registered, not what happens to be checked out on the
|
||||
* machine that runs the job. Unlisted rows are included; hiding a plugin from the public API is not the
|
||||
* same as no longer tracking it, and one taken down for a bad release has to be able to come back
|
||||
* current.
|
||||
*/
|
||||
export async function listRegistry(): Promise<{ id: number; appName: string; repoUrl: string }[]> {
|
||||
return marketplaceDb
|
||||
.select({ id: plugins.id, appName: plugins.appName, repoUrl: plugins.repoUrl })
|
||||
.from(plugins)
|
||||
.orderBy(plugins.appName);
|
||||
}
|
||||
|
||||
// ── Writes. Used by the sync script, never by the public API ──
|
||||
|
||||
type RegisterParams = { appName: string; developer: string; repoUrl: string };
|
||||
|
||||
@@ -73,6 +73,19 @@ export const pluginMetadata = pgTable(
|
||||
.references(() => plugins.id, { onDelete: 'cascade' }),
|
||||
|
||||
version: text('version').notNull(),
|
||||
/**
|
||||
* The git tag this row was read from — what "published" actually means.
|
||||
*
|
||||
* `version` above is the manifest's own claim, and the two are the same string every time somebody
|
||||
* tags correctly. They are kept apart because when they DISAGREE the tag is the fact: it is what was
|
||||
* pushed, and a manifest whose version nobody bumped would otherwise make a real release invisible
|
||||
* to anything comparing versions.
|
||||
*
|
||||
* Nullable because rows existed before releases were the unit of publishing. A null one is a
|
||||
* snapshot taken from a working tree by the old sync, and every consumer should read it as "unknown"
|
||||
* rather than "no release".
|
||||
*/
|
||||
releaseTag: text('release_tag'),
|
||||
/** The platform range it declares support for, e.g. `>=1.0.0`. */
|
||||
platform: text('platform').notNull(),
|
||||
label: text('label').notNull(),
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
listRegistry,
|
||||
upsertMetadata,
|
||||
upsertPlugin,
|
||||
} from '../../databases/marketplace_db/src/plugins/queries';
|
||||
|
||||
/**
|
||||
* Fill the catalogue from what each plugin has RELEASED.
|
||||
*
|
||||
* ── The bug this file exists to remove ──
|
||||
*
|
||||
* Until 2026-09-03 the sync read `platform/plugins/` — the directories this machine has INSTALLED — and
|
||||
* imported each `manifest.ts` off the working tree. So "publish" meant "make sure officer-dev's checkout
|
||||
* happens to be at the release, then run the script", and nobody could see when it was not. On the day
|
||||
* this was rewritten the live catalogue was advertising four plugins from untagged working copies:
|
||||
* av-club 2026.08.29.01, kanban 2026.08.29.02, openseo 0.1.0 and fofos-admin 2026.08.29.26, none of
|
||||
* which had ever been tagged. The store was describing somebody's afternoon.
|
||||
*
|
||||
* That is also why the old script could not be put on a timer: a cron over it would have published
|
||||
* whatever was sitting in a working tree, on a schedule.
|
||||
*
|
||||
* ── What replaces it: a bare mirror, read at a tag ──
|
||||
*
|
||||
* Every plugin in the registry is mirrored (`git clone --mirror`) into a cache directory, refreshed, and
|
||||
* read AT ITS NEWEST TAG — the manifest, the README, the file list, all out of `git cat-file`. There is
|
||||
* no working tree anywhere in this path, so publishing something uncommitted is not a mistake that can
|
||||
* be made. A mirror is cheap: the whole set clones in a couple of seconds and refreshes in well under
|
||||
* one.
|
||||
*
|
||||
* `docs/marketplace.md` proposed instead reading manifests over Gitea's API, which needed either a
|
||||
* sandbox to evaluate a fetched string or a committed `manifest.json` for every plugin. Git gives the
|
||||
* same guarantee — content at a named release, independent of any checkout — with neither, and the trust
|
||||
* model is unchanged from today: we import our own repositories' code.
|
||||
*
|
||||
* `[still a blocker for third-party submissions]` It is unchanged BECAUSE it is unchanged. Importing a
|
||||
* stranger's manifest is code execution no matter which ref it came from. Submissions still need a
|
||||
* sandbox or a published `manifest.json`.
|
||||
*
|
||||
* ── Which plugins, and where from: the registry, and only the registry ──
|
||||
*
|
||||
* The list comes from the `plugins` table, which `docs/marketplace.md` has always called the
|
||||
* authoritative, hand-curated half. It was not actually being used that way — the old sync discovered
|
||||
* plugins by listing a directory, so the registry was a by-product of whatever officer-dev had
|
||||
* installed. Now it decides, which means:
|
||||
*
|
||||
* · a plugin nobody has installed here can still be published (`finances` is in exactly that state),
|
||||
* · a plugin lives wherever its registry row says, across orgs — `fofos-admin` is under `fofos/`,
|
||||
* not `plugins/`, so an org listing would have missed it,
|
||||
* · and adding one is a deliberate act: `marketplace-sync.ts --add <https clone url>`.
|
||||
*
|
||||
* ── A plugin with no tag is left ALONE, not removed ──
|
||||
*
|
||||
* It has never made a release, so this file has nothing true to say about it and says nothing: the
|
||||
* existing metadata row stays, and the run reports it. Deleting the row would drop it out of the store
|
||||
* on the day the rule changed, which is a decision about somebody's plugin dressed up as a refresh.
|
||||
* Tagging it is the fix, and the report is there to ask for one.
|
||||
*/
|
||||
|
||||
export type SyncOutcome =
|
||||
| { appName: string; status: 'synced'; tag: string; version: string }
|
||||
| { appName: string; status: 'unreleased' }
|
||||
| { appName: string; status: 'failed'; error: string };
|
||||
|
||||
/*
|
||||
Where the mirrors live.
|
||||
|
||||
A fixed path under the user's cache directory, NOT one derived from the environment. This runs three
|
||||
ways — by hand with `--env-file=.env`, from the webhook inside `officer-marketplace`, and from cron —
|
||||
and those three do not have the same environment. A path that moved with `DATA_PATH` would have each
|
||||
of them re-cloning twenty-five repositories into its own copy, and the "is it already mirrored" check
|
||||
would answer differently depending on who asked.
|
||||
|
||||
Not in the source tree, because it is a cache of remote repositories and a `.gitignore` entry is a
|
||||
worse way to keep several hundred megabytes out of a repo than not putting them there.
|
||||
*/
|
||||
const MIRROR_DIR =
|
||||
process.env.MARKETPLACE_MIRROR_DIR ?? join(homedir(), '.cache', 'officer-marketplace', 'mirrors');
|
||||
|
||||
const GIT_TIMEOUT_MS = 60_000;
|
||||
|
||||
type Git = { ok: true; out: string } | { ok: false; err: string };
|
||||
|
||||
async function git(args: string[], cwd?: string): Promise<Git> {
|
||||
// A missing `cwd` makes posix_spawn fail with ENOENT, which reads as "git is not installed" and sent
|
||||
// a first run of this looking for git for ten minutes. It means the mirror is not there yet.
|
||||
if (cwd && !existsSync(cwd)) return { ok: false, err: `${cwd} does not exist` };
|
||||
|
||||
const proc = Bun.spawn(['git', ...args], {
|
||||
cwd,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
// Never inherited: a mirror fetch that hits a host asking for a password would otherwise sit on a
|
||||
// terminal prompt forever, and in the webhook server there is no terminal to sit on.
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '/bin/true' },
|
||||
signal: AbortSignal.timeout(GIT_TIMEOUT_MS),
|
||||
});
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
return (await proc.exited) === 0 ? { ok: true, out: out.trim() } : { ok: false, err: err.trim() || 'git failed' };
|
||||
}
|
||||
|
||||
/** The mirror for a plugin, cloned if this is the first time and refreshed if it is not. */
|
||||
async function mirror(appName: string, repoUrl: string): Promise<string> {
|
||||
mkdirSync(MIRROR_DIR, { recursive: true });
|
||||
const dir = join(MIRROR_DIR, `${appName}.git`);
|
||||
|
||||
// Re-cloned rather than repaired when it is not a healthy bare repo: an interrupted clone leaves a
|
||||
// directory that every later command fails against, and there is nothing in it worth keeping.
|
||||
const existing = await git(['rev-parse', '--is-bare-repository'], dir);
|
||||
if (!existing.ok || existing.out !== 'true') {
|
||||
if (existsSync(dir)) await Bun.$`rm -rf ${dir}`.quiet().nothrow();
|
||||
const cloned = await git(['clone', '--mirror', '--quiet', repoUrl, dir]);
|
||||
if (!cloned.ok) throw new Error(`clone failed: ${cloned.err}`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
// The registry is allowed to move a plugin. Setting the URL every time is one command and removes the
|
||||
// failure where a repo is relocated and the mirror keeps refreshing from where it used to be.
|
||||
await git(['remote', 'set-url', 'origin', repoUrl], dir);
|
||||
|
||||
// `--prune --prune-tags`: a deleted or MOVED tag has to disappear here too. Without it, a tag that was
|
||||
// pushed by mistake and deleted would stay in the mirror forever and could go on being published as
|
||||
// the newest release — a store advertising a version its repository no longer has.
|
||||
const fetched = await git(['fetch', '--quiet', '--prune', '--prune-tags', '--tags', 'origin'], dir);
|
||||
if (!fetched.ok) throw new Error(`fetch failed: ${fetched.err}`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest release, or null for a plugin that has never made one.
|
||||
*
|
||||
* Asked of git rather than by sorting version strings, which is the same rule `servers/plugins/release.ts`
|
||||
* follows and for the same reason: parsing `2026.08.27.01` works right up until somebody tags `v2`, and
|
||||
* then fails silently. `--abbrev=0` gives the closest tag reachable from HEAD, and a mirror's HEAD is the
|
||||
* remote's default branch, so this is "the newest release on the branch the repository points at".
|
||||
*/
|
||||
async function newestTag(dir: string): Promise<string | null> {
|
||||
const described = await git(['describe', '--tags', '--abbrev=0', 'HEAD'], dir);
|
||||
return described.ok ? described.out : null;
|
||||
}
|
||||
|
||||
/** A file at a ref, or null when it is not in that tree. */
|
||||
async function fileAt(dir: string, ref: string, path: string): Promise<string | null> {
|
||||
const shown = await git(['cat-file', '-p', `${ref}:${path}`], dir);
|
||||
return shown.ok ? shown.out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The manifest at a release, as an object.
|
||||
*
|
||||
* Written to a temp file and imported, because a manifest is TypeScript. Keyed by the COMMIT, so the
|
||||
* module cache is correct rather than merely convenient: the same commit is the same file, and a tag
|
||||
* moved to a different commit gets a different path and a fresh import. That matters in the webhook
|
||||
* server, which is long-lived and would otherwise serve the first version of a manifest it ever read.
|
||||
*
|
||||
* The `import type { PluginManifest }` at the top of every manifest is erased by the transpiler, so the
|
||||
* file has no runtime dependency on the platform's path aliases and imports fine from anywhere.
|
||||
*/
|
||||
async function manifestAt(dir: string, ref: string, appName: string, commit: string): Promise<Record<string, unknown>> {
|
||||
const source = await fileAt(dir, ref, 'manifest.ts');
|
||||
if (source === null) throw new Error(`no manifest.ts at ${ref}`);
|
||||
|
||||
const scratch = join(tmpdir(), 'officer-marketplace-manifests');
|
||||
mkdirSync(scratch, { recursive: true });
|
||||
const path = join(scratch, `${appName}-${commit}.ts`);
|
||||
await Bun.write(path, source);
|
||||
|
||||
const mod = (await import(path)) as { manifest?: Record<string, unknown> };
|
||||
if (!mod.manifest) throw new Error(`manifest.ts at ${ref} exports no \`manifest\``);
|
||||
return mod.manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* A raw-file URL for a Gitea repository, PINNED TO THE RELEASE.
|
||||
*
|
||||
* It used to be `/raw/HEAD/…`, which works only because Gitea 303s it to `/raw/branch/HEAD/…`; the
|
||||
* artwork it served was whatever was on the default branch, so a plugin could show one icon in the store
|
||||
* and ship another. `/raw/tag/<tag>/…` answers 200 directly and shows the icon that belongs to the
|
||||
* version being advertised.
|
||||
*
|
||||
* `[open]` Still Gitea-shaped. A GitHub-hosted plugin needs the other form, and that wants the forge to
|
||||
* be part of the registry rather than inferred from the URL.
|
||||
*/
|
||||
function rawUrl(repoUrl: string, tag: string, path: string): string {
|
||||
return `${repoUrl.replace(/\.git$/, '')}/raw/tag/${encodeURIComponent(tag)}/${path}`;
|
||||
}
|
||||
|
||||
async function syncRegistered(row: { id: number; appName: string; repoUrl: string }): Promise<SyncOutcome> {
|
||||
const { appName, repoUrl } = row;
|
||||
const dir = await mirror(appName, repoUrl);
|
||||
|
||||
const tag = await newestTag(dir);
|
||||
if (!tag) return { appName, status: 'unreleased' };
|
||||
|
||||
const commitAt = await git(['rev-list', '-n', '1', tag], dir);
|
||||
if (!commitAt.ok) throw new Error(`cannot resolve ${tag}`);
|
||||
const commit = commitAt.out;
|
||||
|
||||
const manifest = await manifestAt(dir, tag, appName, commit);
|
||||
|
||||
// What the plugin SHIPS, read from the tree at the release rather than from a declaration — the same
|
||||
// rule discovery follows on disk, so a catalogue entry cannot claim a sidecar the release does not
|
||||
// have.
|
||||
const listed = await git(['ls-tree', '-r', '--name-only', tag], dir);
|
||||
const files = new Set(listed.ok ? listed.out.split('\n') : []);
|
||||
const has = {
|
||||
api: files.has('api/router.ts'),
|
||||
schema: files.has('db/schema.ts'),
|
||||
sidecar: files.has('sidecar/index.ts') || files.has('sidecar/index.mjs'),
|
||||
web: [...files].some((f) => f.startsWith('web/')),
|
||||
};
|
||||
|
||||
const readme = (await fileAt(dir, tag, 'README.md')) ?? (await fileAt(dir, tag, 'readme.md'));
|
||||
|
||||
const version = String(manifest.version ?? tag);
|
||||
if (version !== tag) {
|
||||
// Not an error and not corrected here. The tag is what was released and the manifest is what the
|
||||
// plugin will report about itself once installed; when they disagree, an update badge that compares
|
||||
// versions goes quiet. Saying so is the only way anyone finds out.
|
||||
console.warn(`[marketplace-sync] ${appName}: tag ${tag} but manifest says ${version} — they should match`);
|
||||
}
|
||||
|
||||
const developer = String(manifest.publisher ?? 'unknown');
|
||||
const pluginId = await upsertPlugin({ appName, developer, repoUrl });
|
||||
|
||||
await upsertMetadata(pluginId, {
|
||||
version,
|
||||
releaseTag: tag,
|
||||
platform: String(manifest.platform ?? '>=1.0.0'),
|
||||
label: String(manifest.label ?? appName),
|
||||
summary: String(manifest.summary ?? ''),
|
||||
// Taken verbatim, NOT validated here. `manifestProblems` already refuses a tag outside the
|
||||
// vocabulary at discovery, so anything reaching this point is either valid or belongs to a plugin
|
||||
// the platform would not load anyway — and a sync that silently dropped a tag would be a catalogue
|
||||
// quietly disagreeing with the repository it claims to mirror.
|
||||
tags: Array.isArray(manifest.tags) ? manifest.tags : [],
|
||||
color: (manifest.color as string) ?? null,
|
||||
tile: (manifest.tile as string) ?? null,
|
||||
iconUrl: files.has('assets/icon.png') ? rawUrl(repoUrl, tag, 'assets/icon.png') : null,
|
||||
icon: (manifest.icon as string) ?? null,
|
||||
permissions: manifest.permissions ?? [],
|
||||
osDependencies: manifest.osDependencies ?? null,
|
||||
has,
|
||||
readme,
|
||||
sourceCommit: commit,
|
||||
});
|
||||
|
||||
return { appName, status: 'synced', tag, version };
|
||||
}
|
||||
|
||||
/** One plugin by app name. Unknown to the registry is a failure, not a silent no-op. */
|
||||
export async function syncPlugin(appName: string): Promise<SyncOutcome> {
|
||||
const row = (await listRegistry()).find((r) => r.appName === appName);
|
||||
if (!row) return { appName, status: 'failed', error: 'not in the registry' };
|
||||
return syncRegistered(row).catch((err) => ({
|
||||
appName,
|
||||
status: 'failed' as const,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every registered plugin, one at a time.
|
||||
*
|
||||
* Sequential on purpose. Twenty-four concurrent fetches against one Gitea is a thundering herd for a job
|
||||
* nothing is waiting on, and the whole set finishes in about a second anyway.
|
||||
*
|
||||
* Unlisted rows are included: `listed` hides a plugin from the public API, usually after a bad release,
|
||||
* and it has to be able to come back with current metadata rather than with whatever was true when it
|
||||
* was pulled.
|
||||
*/
|
||||
export async function syncAll(): Promise<SyncOutcome[]> {
|
||||
const rows = await listRegistry();
|
||||
const out: SyncOutcome[] = [];
|
||||
for (const row of rows) {
|
||||
out.push(
|
||||
await syncRegistered(row).catch((err) => ({
|
||||
appName: row.appName,
|
||||
status: 'failed' as const,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user