From d381140d54042a4156e7b50592ef6204278e8579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 27 Aug 2026 06:55:03 +0000 Subject: [PATCH] plugins say what they are for, and their checkouts stop lying about themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two things, and the second is why the first was painful. TAGS. the marketplace is going to be browsed rather than scrolled: tags on the left, the plugins carrying one on the right. a plugin declares its own in the manifest and the catalogue caches it on sync, exactly as it already does for label and summary — the alternative is editing a table every time a plugin ships, and a plugin that cannot say what it is. the vocabulary is CLOSED (PLUGIN_TAGS) because free text becomes homelab, home-lab, Home Lab and selfhosted inside a month, and a filter list of synonyms is worse than no filter. an unknown tag is refused at discovery BY NAME, so the author is told which tag rather than which array index. all 21 are tagged, each with a one-line note saying why, so a later disagreement is with a stated reason rather than a mystery. every tag has at least three plugins behind it — no filter opens onto an empty page. PLUGINS:CHECK. getting one commit into 21 repositories failed seven times and none of the reasons were about the commit: six remotes were HTTPS and cannot authenticate unattended, three checkouts were behind their remote, one URL had no .git suffix so the catalogue published a clone URL unlike every other, and offscale's fetch refspec named a branch that does not exist. that last one is why this is a script and not a habit. every command reported on a phantom — `git push` said "Everything up-to-date" while the branch looked diverged — and the repository was fine the whole time. twenty minutes spent in a repo with nothing wrong with it. none of it is visible until you try to push, which is the worst moment to learn it: mid-task, across 21 repositories, and every failure looks like it is about your change. so it checks the facts with one right answer — remote URL, fetch refspec, branch, upstream, dirty tree, ahead/behind — and exits non-zero. `--fix` is a separate flag so running the check can never be what changes your tree, and divergence is never auto-repaired because it needs a decision about content. verified by breaking each case on purpose and watching it catch them. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- package.json | 3 +- scripts/marketplace-sync.ts | 5 + scripts/plugins-check.ts | 127 ++++++++++++++++++ .../marketplace_db/src/plugins/queries.ts | 5 + .../marketplace_db/src/plugins/schema.ts | 14 ++ src/servers/plugins/manifest.ts | 62 +++++++++ 6 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 scripts/plugins-check.ts diff --git a/package.json b/package.json index f9945869..5b2676f8 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "setup": "bash scripts/install.sh", "check:theme": "bun scripts/check-theme-tokens.ts", "docs:check": "bun run scripts/docs-check.ts", - "skills:platform": "bun run scripts/gen-platform-skill.ts" + "skills:platform": "bun run scripts/gen-platform-skill.ts", + "plugins:check": "bun run scripts/plugins-check.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.41", diff --git a/scripts/marketplace-sync.ts b/scripts/marketplace-sync.ts index 5e198b6d..97d340b9 100644 --- a/scripts/marketplace-sync.ts +++ b/scripts/marketplace-sync.ts @@ -109,6 +109,11 @@ async function syncOne(dir: string): Promise { 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, // Artwork lives in the repo, so the URL is built from the remote rather than stored by hand. Only diff --git a/scripts/plugins-check.ts b/scripts/plugins-check.ts new file mode 100644 index 00000000..2dce48b2 --- /dev/null +++ b/scripts/plugins-check.ts @@ -0,0 +1,127 @@ +/* + Is every plugin checkout still a well-formed clone of its own repository? + + ── Why a script and not care ── + + On 2026-08-27 one commit had to go into all twenty-one plugin repos. Seven pushes failed, each for a + different reason, and none of the reasons had anything to do with the commit: + + · six remotes were HTTPS, which cannot authenticate non-interactively — they simply hung and failed + · three checkouts were behind their published remote, so the push was rejected as non-fast-forward + · one (`offscale`) had a fetch refspec and an upstream naming a branch called `main` that does not + exist on the remote. That one is the reason this file exists: every command reported on a phantom. + `git push` said "Everything up-to-date" while `git status` said the branch had diverged, and the + repository was actually fine. Twenty minutes went into a repo with nothing wrong with it. + · one remote URL had no `.git` suffix, so the catalogue published a clone URL unlike every other + + None of that is visible until you try to push, which is the worst possible moment to find out — you are + mid-task, across twenty-one repositories, and the failures look like they are about your change. + + ── What it checks, and what it deliberately does not ── + + Only mechanical facts with one right answer: the remote's URL form, the fetch refspec, the branch, its + upstream, and whether the checkout has diverged. It does not judge content, does not fetch by default + (that would make a local check depend on the network), and never writes — `--fix` is a separate flag so + that running the check can never be the thing that changes your tree. +*/ + +import { readdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const PLUGINS_DIR = join(import.meta.dir, '..', 'plugins'); +const HOST = 'git@gitea.officer.dev'; +const BRANCH = 'master'; +const REFSPEC = '+refs/heads/*:refs/remotes/origin/*'; + +const FIX = process.argv.includes('--fix'); +const FETCH = process.argv.includes('--fetch'); + +const git = (dir: string, args: string[]): string | null => { + const p = Bun.spawnSync(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'pipe' }); + return p.exitCode === 0 ? p.stdout.toString().trim() : null; +}; + +type Problem = { plugin: string; what: string; fix?: () => void }; +const problems: Problem[] = []; + +const dirs = readdirSync(PLUGINS_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') + .map((e) => e.name) + .filter((name) => existsSync(join(PLUGINS_DIR, name, '.git'))) + .sort(); + +for (const name of dirs) { + const dir = join(PLUGINS_DIR, name); + const expected = `${HOST}:plugins/${name}.git`; + + // 1. The remote, exactly. SSH because that is what can push unattended, and `.git` because a URL unlike + // every other is the one an installer trips over. + const url = git(dir, ['remote', 'get-url', 'origin']); + if (url !== expected) { + problems.push({ + plugin: name, + what: `remote is ${url ?? 'missing'}, expected ${expected}`, + fix: () => void git(dir, ['remote', 'set-url', 'origin', expected]), + }); + } + + // 2. The fetch refspec. A narrowed one (a --single-branch clone) hides every other branch; a WRONG one + // makes git report on a branch that does not exist, which is the failure that cost the most time. + const refspecs = (git(dir, ['config', '--get-all', 'remote.origin.fetch']) ?? '').split('\n').filter(Boolean); + if (refspecs.length !== 1 || refspecs[0] !== REFSPEC) { + problems.push({ + plugin: name, + what: `fetch refspec is ${refspecs.join(' + ') || 'unset'}, expected ${REFSPEC}`, + fix: () => { + git(dir, ['config', '--unset-all', 'remote.origin.fetch']); + git(dir, ['config', '--add', 'remote.origin.fetch', REFSPEC]); + }, + }); + } + + // 3. The branch, and what it tracks. + const branch = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']); + if (branch !== BRANCH) { + problems.push({ plugin: name, what: `on branch ${branch ?? '?'}, expected ${BRANCH}` }); + continue; // the checks below are all about this branch + } + const upstream = git(dir, ['config', '--get', `branch.${BRANCH}.merge`]); + if (upstream !== `refs/heads/${BRANCH}`) { + problems.push({ + plugin: name, + what: `${BRANCH} tracks ${upstream ?? 'nothing'}, expected refs/heads/${BRANCH}`, + fix: () => void git(dir, ['branch', `--set-upstream-to=origin/${BRANCH}`, BRANCH]), + }); + } + + // 4. Uncommitted work, and divergence. Not fixable here — both need a human decision about the content. + const dirty = git(dir, ['status', '--porcelain']); + if (dirty) problems.push({ plugin: name, what: `${dirty.split('\n').length} uncommitted file(s)` }); + + if (FETCH) git(dir, ['fetch', '-q', 'origin']); + const counts = git(dir, ['rev-list', '--left-right', '--count', `origin/${BRANCH}...HEAD`]); + if (counts) { + const [behind, ahead] = counts.split(/\s+/).map(Number); + if (behind) problems.push({ plugin: name, what: `${behind} commit(s) behind origin/${BRANCH}` }); + if (ahead) problems.push({ plugin: name, what: `${ahead} commit(s) NOT PUSHED` }); + } +} + +if (!problems.length) { + console.log(`plugins:check — ${dirs.length} plugin repositories, all well-formed`); + process.exit(0); +} + +for (const p of problems) console.error(` ${p.plugin}: ${p.what}`); + +if (FIX) { + const fixable = problems.filter((p) => p.fix); + for (const p of fixable) p.fix!(); + console.error(`\nplugins:check --fix — repaired ${fixable.length} of ${problems.length}; re-run to confirm.`); + console.error('Divergence and uncommitted work are never repaired here: both need a decision about content.'); +} else { + console.error( + `\nplugins:check — ${problems.length} problem(s). \`bun run plugins:check --fix\` repairs the mechanical ones.`, + ); +} +process.exit(1); diff --git a/src/databases/marketplace_db/src/plugins/queries.ts b/src/databases/marketplace_db/src/plugins/queries.ts index 4b411ff6..f8b64758 100644 --- a/src/databases/marketplace_db/src/plugins/queries.ts +++ b/src/databases/marketplace_db/src/plugins/queries.ts @@ -17,6 +17,8 @@ export type CatalogueEntry = { platform: string; label: string; summary: string; + /** What it is for. Always present, `[]` for a plugin that declares none, so callers need no guard. */ + tags: string[]; color: string; tile?: 'badge' | 'bare'; iconUrl?: string; @@ -53,6 +55,9 @@ function toCatalogueEntry(row: Row): CatalogueEntry | null { platform: m.platform, label: m.label, summary: m.summary, + // Normalised to an array here rather than at every call site: the column is nullable because it + // arrived after the rows did, and null vs [] is a distinction no consumer of this wants to carry. + tags: Array.isArray(m.tags) ? (m.tags as string[]) : [], color: m.color ?? '#94a3b8', ...(m.tile ? { tile: m.tile as 'badge' | 'bare' } : {}), ...(m.iconUrl ? { iconUrl: m.iconUrl } : {}), diff --git a/src/databases/marketplace_db/src/plugins/schema.ts b/src/databases/marketplace_db/src/plugins/schema.ts index 757aad56..18739cb0 100644 --- a/src/databases/marketplace_db/src/plugins/schema.ts +++ b/src/databases/marketplace_db/src/plugins/schema.ts @@ -85,6 +85,20 @@ export const pluginMetadata = pgTable( /** A lucide glyph NAME, for a plugin that ships no artwork. `iconUrl` wins where both are given. */ icon: text('icon'), + /** + * What the plugin is for — the store's filter, and the only field here that exists for BROWSING + * rather than for installing. + * + * jsonb like the shapes below it, and for the same reason: it is read as a unit and rendered as a + * unit. A join table would buy filtering in SQL, which matters at a scale a hand-curated catalogue of + * twenty-one is nowhere near — and it would cost a migration every time the vocabulary changes, which + * is exactly the thing expected to change. + * + * Nullable because it arrived after the rows did. Null and `[]` mean the same thing here: no filter + * shows it. + */ + tags: jsonb('tags'), + permissions: jsonb('permissions').notNull(), osDependencies: jsonb('os_dependencies'), /** `{ api, schema, sidecar, web }` — what the plugin ships. */ diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts index 37b71806..c190f348 100644 --- a/src/servers/plugins/manifest.ts +++ b/src/servers/plugins/manifest.ts @@ -81,6 +81,44 @@ export type PluginPermission = { ownerOnly?: string[]; }; +/** + * What a plugin is FOR, in words someone browsing a store already understands. + * + * ── Why tags and not a category ── + * + * Almost every plugin is more than one thing. Contacts and Calendar is Personal and Degoogling; a + * torrent client is Homelab and Media; the proxy manager is Server Management and Security. Forcing one + * category means picking the least wrong, and a store organised by least-wrong answers is one nobody + * browses twice. + * + * ── Why a CLOSED set ── + * + * Free-text tags produce `homelab`, `home-lab`, `Home Lab` and `selfhosted` within a month, and a filter + * list that is mostly synonyms is worse than no filter. Adding a tag is a deliberate edit here, which is + * the same trade `WELL_KNOWN_CLAIMABLE` makes for a different reason. + * + * ── Naming ── + * + * These are read by someone who does not know what CardDAV is. `Personal`, not `Personal Assistance`, + * which sounds like the agent rather than the subject. `Development`, not `Development / Programming`, + * because a slash reads as two tags and this ends up in a URL. `Degoogling` earns its place by being the + * term people actually search for. + * + * Order is the order they are offered in: what a newcomer recognises first, down to what only some + * people want. + */ +export const PLUGIN_TAGS = [ + 'Personal', + 'Homelab', + 'Server Management', + 'Development', + 'Security', + 'Degoogling', + 'Fun', +] as const; + +export type PluginTag = (typeof PLUGIN_TAGS)[number]; + /** The package managers the platform knows how to drive. Mirrors `PM` in machine-setup's lib/base.sh. */ export type PackageManager = 'apt' | 'pacman' | 'dnf' | 'brew'; @@ -224,6 +262,18 @@ export type PluginManifest = { label: string; summary: string; + /** + * What this plugin is for — one or more of `PLUGIN_TAGS`, in the store's own words. + * + * The plugin declares it and the marketplace caches it, exactly as `label` and `summary` are. The + * alternative — curating tags in the registry — means editing a table every time a plugin ships, and a + * plugin that cannot say what it is. A curated OVERRIDE on top will be wanted when third parties + * arrive, and that is a different field. + * + * Optional so no existing plugin breaks; an untagged plugin simply appears under no filter, which is + * visible in the store rather than silent. + */ + tags?: PluginTag[]; /** * A lucide icon name, resolved at render — the FALLBACK, used only when the plugin ships no * `assets/icon.png`. Optional for that reason: a plugin with its own artwork has nothing to say here. @@ -801,6 +851,18 @@ export function manifestProblems(appName: string, manifest: Partial