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