#!/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 register a new one, then sync it * bun --env-file=.env scripts/marketplace-sync.ts --force re-publish releases already published * bun --env-file=.env scripts/marketplace-sync.ts --quiet print only what changed * * 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 argv = process.argv.slice(2); /* `--quiet` is what the timer runs: one line per plugin that actually moved, and a summary. Not silence — the summary is always printed, because a log whose last line is old is the only way to tell "nothing to publish" from "the timer died", and that question does come up. */ const quiet = argv.includes('--quiet'); const force = argv.includes('--force'); const args = argv.filter((a) => a !== '--quiet' && a !== '--force'); /** * A clone URL the whole internet can use. * * Several plugin repos have `git@gitea.officer.dev:plugins/.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 '); 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, force); report([one]); process.exit(one.status === 'failed' ? 1 : 0); } const results = args[0] ? [await syncPlugin(args[0], force)] : await syncAll(force); report(results); process.exit(results.some((r) => r.status === 'failed') ? 1 : 0); function report(results: Awaited>): void { const stamp = new Date().toISOString(); for (const r of results) { if (r.status === 'synced') console.log(`[marketplace-sync] ${r.appName} ← ${r.tag}`); else if (r.status === 'failed') console.error(`[marketplace-sync] ${r.appName} failed: ${r.error}`); else if (quiet) continue; else if (r.status === 'unchanged') console.log(`[marketplace-sync] ${r.appName} = ${r.tag}`); else console.warn(`[marketplace-sync] ${r.appName}: no tag — nothing published`); } const synced = results.filter((r) => r.status === 'synced').length; const unchanged = results.filter((r) => r.status === 'unchanged').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] ${stamp} — ${synced} published, ${unchanged} unchanged, ${unreleased.length} untagged`); // Named rather than counted, and not under `--quiet`: "4 untagged" is a number nobody acts on, but a // timer that reprints the same four names every minute is a log nobody reads. The names belong in a // run somebody asked for. if (unreleased.length && !quiet) { console.warn(`[marketplace-sync] never tagged, so not updated: ${unreleased.join(', ')}`); } if (failed.length) console.error(`[marketplace-sync] failed: ${failed.join(', ')}`); }