plugins say what they are for, and their checkouts stop lying about themselves

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 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-27 06:55:03 +00:00
co-authored by Claude Opus 5
parent 2360a3ef60
commit d381140d54
6 changed files with 215 additions and 1 deletions
+62
View File
@@ -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<PluginManife
for (const field of ['version', 'platform', 'label', 'summary', 'color'] as const) {
if (typeof manifest[field] !== 'string' || !manifest[field]) problems.push(`${field} is required`);
}
if (manifest.tags !== undefined) {
if (!Array.isArray(manifest.tags)) {
problems.push('tags must be an array');
} else {
for (const tag of manifest.tags) {
// Named rather than counted: "tags[2] is invalid" sends the author back to count array entries.
if (!(PLUGIN_TAGS as readonly string[]).includes(tag)) {
problems.push(`tag ${JSON.stringify(tag)} is not one of: ${PLUGIN_TAGS.join(', ')}`);
}
}
}
}
if (manifest.unauthenticatedRootPaths !== undefined) {
if (!Array.isArray(manifest.unauthenticatedRootPaths)) {
problems.push('unauthenticatedRootPaths must be an array of paths');