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>
98 lines
4.2 KiB
TypeScript
98 lines
4.2 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { upsertPlugin } from '../src/databases/marketplace_db/src/plugins/queries';
|
|
import { syncAll, syncPlugin } from '../src/servers/marketplace/sync';
|
|
|
|
/**
|
|
* Fill the marketplace catalogue from the plugins' releases.
|
|
*
|
|
* bun --env-file=.env scripts/marketplace-sync.ts every registered plugin
|
|
* bun --env-file=.env scripts/marketplace-sync.ts photos just one
|
|
* bun --env-file=.env scripts/marketplace-sync.ts --add <url> register a new one, then sync it
|
|
*
|
|
* All the reasoning is in `src/servers/marketplace/sync.ts` — this file is the command line around it.
|
|
* It is a thin wrapper on purpose: the same functions are what the Gitea webhook calls, so a plugin
|
|
* published by hand and one published by pushing a tag go through exactly the same code.
|
|
*
|
|
* Re-running is always safe. It overwrites and never merges.
|
|
*/
|
|
|
|
const args = process.argv.slice(2);
|
|
|
|
/**
|
|
* A clone URL the whole internet can use.
|
|
*
|
|
* Several plugin repos have `git@gitea.officer.dev:plugins/<name>.git` remotes, because whoever set them
|
|
* up had a key on that host. Registering one of those would hand every installer a URL that only works
|
|
* on the machine it was published from — the install fails at authentication, which reads as "the plugin
|
|
* is broken" rather than "the catalogue is wrong".
|
|
*/
|
|
export function toHttpsRemote(remote: string): string | null {
|
|
const trimmed = remote.trim();
|
|
if (!trimmed) return null;
|
|
if (trimmed.startsWith('https://')) return trimmed;
|
|
|
|
// git@host:path/to/repo.git → https://host/path/to/repo.git
|
|
const scp = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
|
|
if (scp) return `https://${scp[1]}/${scp[2]}`;
|
|
|
|
// ssh://git@host/path.git → https://host/path.git
|
|
if (trimmed.startsWith('ssh://')) {
|
|
try {
|
|
const url = new URL(trimmed);
|
|
return `https://${url.host}${url.pathname}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (args[0] === '--add') {
|
|
const raw = args[1];
|
|
if (!raw) {
|
|
console.error('usage: marketplace-sync.ts --add <clone url>');
|
|
process.exit(1);
|
|
}
|
|
const repoUrl = toHttpsRemote(raw);
|
|
if (!repoUrl) {
|
|
console.error(`[marketplace-sync] ${raw} is not a clone URL this can turn into https`);
|
|
process.exit(1);
|
|
}
|
|
const appName = repoUrl.replace(/\.git$/, '').split('/').pop() ?? '';
|
|
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(appName)) {
|
|
console.error(`[marketplace-sync] "${appName}" is not a usable app name`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Registered with a placeholder developer, then immediately synced — which reads the real publisher
|
|
// out of the released manifest and overwrites it. A repository with no tag stays registered and
|
|
// unpublished, which is the correct state for one that has not made a release yet.
|
|
await upsertPlugin({ appName, developer: 'unknown', repoUrl });
|
|
console.log(`[marketplace-sync] registered ${appName} → ${repoUrl}`);
|
|
const one = await syncPlugin(appName);
|
|
report([one]);
|
|
process.exit(one.status === 'failed' ? 1 : 0);
|
|
}
|
|
|
|
const results = args[0] ? [await syncPlugin(args[0])] : await syncAll();
|
|
report(results);
|
|
process.exit(results.some((r) => r.status === 'failed') ? 1 : 0);
|
|
|
|
function report(results: Awaited<ReturnType<typeof syncAll>>): void {
|
|
for (const r of results) {
|
|
if (r.status === 'synced') console.log(`[marketplace-sync] ${r.appName} ← ${r.tag}`);
|
|
else if (r.status === 'unreleased') console.warn(`[marketplace-sync] ${r.appName}: no tag — nothing published`);
|
|
else console.error(`[marketplace-sync] ${r.appName} failed: ${r.error}`);
|
|
}
|
|
|
|
const synced = results.filter((r) => r.status === 'synced').length;
|
|
const unreleased = results.filter((r) => r.status === 'unreleased').map((r) => r.appName);
|
|
const failed = results.filter((r) => r.status === 'failed').map((r) => r.appName);
|
|
|
|
console.log(`[marketplace-sync] ${synced}/${results.length} published from a release`);
|
|
// Named rather than counted. "3 unreleased" is a number nobody acts on; the names are a to-do list,
|
|
// and every one of them is a plugin whose store entry is frozen until somebody tags it.
|
|
if (unreleased.length) console.warn(`[marketplace-sync] never tagged, so not updated: ${unreleased.join(', ')}`);
|
|
if (failed.length) console.error(`[marketplace-sync] failed: ${failed.join(', ')}`);
|
|
}
|