Files
platform/scripts/plugins-check.ts
T
pastilhasandClaude Opus 5 c14b686782 pm2 save when the process list changes, and a check that does not lie about symlinks
the owner asked whether anything runs `pm2 save` when a sidecar is added or
removed. nothing did.

PM2 resurrects from ~/.pm2/dump.pm2, written only by `pm2 save`, so the dump was
whatever a human last happened to save. today it was FIVE DAYS old: 13 processes
saved against 26 running. a reboot would have silently lost every sidecar
installed since — bitcoin-wallet, desktop, gitea, invoices, iris, jellyfin,
minesweeper, music, photos, soulseek, transcribe, transmission, vault. thirteen
features gone, machine healthy, nothing saying why.

saved after start AND after delete. removal matters as much: a dump that keeps
resurrecting a deleted sidecar brings it back after a reboot as a crash loop for
code no longer on the machine. a RESTART does not save — the same processes are
running and the dump is already right.

best effort and never fatal: a failed save leaves the estate correct and only
the dump stale, which is where things already were. failing an install over
bookkeeping would be the worse trade.

separately, plugins:check filtered directories on the DIRENT's isDirectory(),
which answers false for a symlink to a directory. platform/plugins is symlinked
now, so the check would have reported "0 plugin repositories, all well-formed" —
a green check over nothing, which is worse than a red one. it stats the path
instead, the way discover.ts already does.

dump saved once by hand as well, since five days of drift was already there:
26 saved, 26 running, no difference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 07:52:46 +00:00

162 lines
7.6 KiB
TypeScript

/*
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, statSync } from 'node:fs';
import { join } from 'node:path';
/*
Which plugins directory to check.
There are two as of 2026-08-27: `platform/plugins/` — what this machine has INSTALLED — and
`officer-dev/plugins/`, every repo in the org cloned side by side for working on. They hold different
sets, so the check has to be pointable at either rather than assuming the one it happens to sit next to.
*/
const PLUGINS_DIR = process.env.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 })
// `statSync().isDirectory()`, not the dirent's — a Dirent for a SYMLINK to a directory answers false to
// `isDirectory()` and true to `isSymbolicLink()`, so filtering on the dirent silently skips every
// symlinked plugin. `platform/plugins/` is symlinks into the workspace clones since 2026-08-27, which
// would have made this report "0 plugin repositories, all well-formed" — a green check over nothing.
// `discover.ts` already does it this way, at line 239, for the same reason.
.filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')
.filter((e) => {
try {
return statSync(join(PLUGINS_DIR, e.name)).isDirectory();
} catch {
return false; // a broken symlink, which the checks below have nothing useful to say about
}
})
.map((e) => e.name)
.filter((name) => existsSync(join(PLUGINS_DIR, name, '.git')))
.sort();
/*
A repo with no `manifest.ts` is not a plugin, and that is not drift.
`av-club` is the case: a standalone media service living in the plugins org, intended to become a
plugin in time. Checking it against a plugin's conventions would report its branch name as a defect
forever, and a check that cries wolf is one people stop reading.
Presence is the declaration here exactly as it is in `plugins/discover.ts` — the manifest is what makes
something a plugin, so its absence is what makes something not one yet.
*/
const notYet = dirs.filter((name) => !existsSync(join(PLUGINS_DIR, name, 'manifest.ts')));
const checkable = dirs.filter((name) => !notYet.includes(name));
for (const name of checkable) {
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` });
}
}
const skipped = notYet.length ? ` (${notYet.length} not a plugin yet: ${notYet.join(', ')})` : '';
if (!problems.length) {
console.log(`plugins:check — ${checkable.length} plugin repositories, all well-formed${skipped}`);
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);