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
162 lines
7.6 KiB
TypeScript
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);
|