photos, extracted into plugins/photos — the platform half

Fifth extraction, and the clean case: no reverse dependency, configuration
already server-side, frontend already panels. Everything photos now lives at
gitea.officer.dev/plugins/photos.

Deleted, not commented: the sidecar, the api router, the db directory, the
Photos app (25 files), the screen, the App.tsx route pair, the Dashboard
barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev
barrel re-exports, the permission registry entry, both database barrels, the
hono.ts import comment, and the app-store catalogue entry.

The catalogue deletion is required rather than tidying — the trap the runbook
records as having bitten twice. permissionAvailability() derives from
sidecar_installs, which a plugin never gets a row in, so a leftover entry makes
the permission permanently 'unavailable' and withholds the route and dock tile
on a server where the plugin is installed and working.

── Test fixtures, repointed rather than deleted ──

Photos was the canonical app-store fixture across four test files, chosen for
being existing|provisioned with members: 'accounts'. Jellyfin has the identical
shape, so the assertions moved there — they are about the installer, not about
photos, and deleting them would have quietly dropped coverage of the provisioned
install path along with the plugin.

One loss worth naming: Immich's `provisioned` install mode went with the
catalogue entry. Docker provisioning for plugins does not exist yet, so photos
is point-at-your-own-instance until it does.

bunx tsgo clean. 1080 pass / 0 fail, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:10:52 +00:00
co-authored by Claude Opus 5
parent 7819eff6a1
commit a327dfc82c
53 changed files with 47 additions and 5274 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ describe('permissions it claims to back', () => {
describe('byId', () => {
it('finds a known entry and returns undefined for anything else', () => {
expect(byId('photos')?.process).toBe('officer-photos');
expect(byId('jellyfin')?.process).toBe('officer-jellyfin');
expect(byId('not-a-sidecar')).toBeUndefined();
});
});
+6 -21
View File
@@ -163,27 +163,12 @@ export type CatalogueEntry = {
export const CATALOGUE: CatalogueEntry[] = [
// ── Point at something you already run, or let us start one ────────────────────────────────────────
{
id: 'photos',
ui: { name: 'Photos', icon: 'Images', color: '#10b981', rootRoute: '/photos', routes: ['/photos'] },
process: 'officer-photos',
label: 'Photos',
summary: 'Your Immich library — browse, search, upload from the phone',
members: 'accounts',
modes: ['existing', 'provisioned'],
permission: 'photos',
composeTemplate: 'immich',
existingFields: [
{ key: 'url', label: 'Immich URL', type: 'url', required: true, placeholder: 'https://photos.example.com' },
{
key: 'secret',
label: 'API key',
type: 'secret',
required: true,
help: 'Immich → Account Settings → API Keys. Create it with all permissions: a scoped key returns 403 per route, which reads as a broken feature.',
},
],
},
// Photos was here until 2026-08-18, when it became `plugins/photos/`.
//
// Deleting the entry is REQUIRED, not tidying — the trap `EXTRACTING-A-PLUGIN.md` records as having
// bitten twice. `permissionAvailability()` derives from `sidecar_installs`, which a plugin never gets a
// row in, so a leftover entry would make the permission permanently `unavailable`, put `/photos` into
// `deniedRoutes`, and withhold the dock tile — on a server where the plugin is installed and working.
{
id: 'jellyfin',
ui: { name: 'Video', icon: 'Clapperboard', color: '#a855f7', rootRoute: '/jellyfin', routes: ['/jellyfin'] },
+15 -15
View File
@@ -6,7 +6,7 @@ import { byId } from './catalogue';
// the reason the effects are injected. What is being pinned here is the behaviour that only shows up
// when something goes wrong: resume, blocking, and not doing work twice.
const photos = byId('photos')!; // existing|provisioned, members: 'accounts'
const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts'
const transmission = byId('transmission')!; // existing|provisioned, members: 'none'
const configOnly = byId('notify')!; // config only
@@ -39,15 +39,15 @@ describe('planSteps', () => {
it('never provisions when pointing at an instance the user already runs', () => {
// The guarantee that matters: choosing 'existing' cannot start a container. Enforced here rather
// than remembered at each call site.
expect(planSteps(photos, 'existing')).not.toContain('provision');
expect(planSteps(photos, 'provisioned')).toContain('provision');
expect(planSteps(jellyfin, 'existing')).not.toContain('provision');
expect(planSteps(jellyfin, 'provisioned')).toContain('provision');
});
it('omits the members step for a service with no user concept', () => {
// Otherwise a Transmission install reports a members step that did nothing, which reads as a
// silent failure to anyone debugging why a member has no access.
expect(planSteps(transmission, 'provisioned')).not.toContain('members');
expect(planSteps(photos, 'provisioned')).toContain('members');
expect(planSteps(jellyfin, 'provisioned')).toContain('members');
});
it('has nothing to connect for a config-only install', () => {
@@ -57,7 +57,7 @@ describe('planSteps', () => {
it('always starts with preflight', () => {
// Checking the host before anything is written is the whole reason a half-install is avoidable.
for (const mode of ['existing', 'provisioned', 'config'] as const) {
expect(planSteps(photos, mode)[0]).toBe('preflight');
expect(planSteps(jellyfin, mode)[0]).toBe('preflight');
}
});
});
@@ -65,7 +65,7 @@ describe('planSteps', () => {
describe('a clean run', () => {
it('executes the plan in order and reports installed', async () => {
const { effects, calls } = spyEffects();
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('installed');
expect(calls).toEqual(['preflight', 'provision', 'connect', 'schema', 'assets', 'process', 'members']);
@@ -82,7 +82,7 @@ describe('a clean run', () => {
},
});
await runInstall({ entry: photos, mode: 'provisioned', values: { given: 'yes' }, effects });
await runInstall({ entry: jellyfin, mode: 'provisioned', values: { given: 'yes' }, effects });
expect(seen.url).toBe('http://127.0.0.1:18091');
expect(seen.composeDir).toBe('/root/dockers/x');
@@ -92,7 +92,7 @@ describe('a clean run', () => {
it('does not mutate the caller’s values', async () => {
const values = { given: 'yes' };
const { effects } = spyEffects();
await runInstall({ entry: photos, mode: 'provisioned', values, effects });
await runInstall({ entry: jellyfin, mode: 'provisioned', values, effects });
expect(values).toEqual({ given: 'yes' });
});
});
@@ -103,7 +103,7 @@ describe('resuming', () => {
const { effects, calls } = spyEffects();
const done: StepName[] = ['preflight', 'provision', 'connect'];
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, completed: done, effects });
const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, completed: done, effects });
expect(out.status).toBe('installed');
expect(calls).toEqual(['schema', 'assets', 'process', 'members']);
@@ -113,7 +113,7 @@ describe('resuming', () => {
it('reports every completed step, including the ones it skipped', async () => {
const { effects } = spyEffects();
const out = await runInstall({
entry: photos,
entry: jellyfin,
mode: 'provisioned',
values: {},
completed: ['preflight'],
@@ -131,7 +131,7 @@ describe('blocking on a human', () => {
connect: async () => ({ status: 'blocked', reason: 'Needs an API key', completeAt: 'http://x/keys' }),
});
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('blocked');
if (out.status !== 'blocked') throw new Error('unreachable');
@@ -145,7 +145,7 @@ describe('blocking on a human', () => {
it('re-runs the blocking step on resume, once the human has answered', async () => {
const { effects, calls } = spyEffects();
const out = await runInstall({
entry: photos,
entry: jellyfin,
mode: 'provisioned',
values: { secret: 'now-provided' },
completed: ['preflight', 'provision'],
@@ -165,7 +165,7 @@ describe('failing', () => {
},
});
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('failed');
if (out.status !== 'failed') throw new Error('unreachable');
@@ -184,7 +184,7 @@ describe('failing', () => {
},
});
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('failed');
if (out.status !== 'failed') throw new Error('unreachable');
@@ -203,7 +203,7 @@ describe('failing', () => {
throw new Error('pm2 not found');
},
});
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
const out = await runInstall({ entry: jellyfin, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('failed');
if (out.status !== 'failed') throw new Error('unreachable');
expect(out.at).toBe('process');
+3 -3
View File
@@ -10,7 +10,7 @@ const inviteOnly = { members: 'invite' } as CatalogueEntry;
describe('what adding a member would do', () => {
it('predicts the outcome per service without contacting anything', () => {
expect(plannedOutcome(byId('photos')!)).toBe('provisioned');
expect(plannedOutcome(byId('jellyfin')!)).toBe('provisioned');
expect(plannedOutcome(inviteOnly)).toBe('invited');
expect(plannedOutcome(byId('transmission')!)).toBe('not-applicable');
});
@@ -18,8 +18,8 @@ describe('what adding a member would do', () => {
describe('which installed services have work to do', () => {
it('skips single-tenant daemons entirely', () => {
const ids = servicesNeedingMemberProvision(['photos', 'transmission', 'slskd', 'memos']).map((e) => e.id);
expect(ids.sort()).toEqual(['memos', 'photos']);
const ids = servicesNeedingMemberProvision(['jellyfin', 'transmission', 'slskd', 'memos']).map((e) => e.id);
expect(ids.sort()).toEqual(['jellyfin', 'memos']);
});
it('ignores services that are not installed', () => {
+2 -2
View File
@@ -12,8 +12,8 @@ describe('preflight is per mode, not per entry', () => {
it('allows pointing at an existing instance without Docker', async () => {
// The whole point: a host with no Docker can still use Photos against an Immich elsewhere.
// Refusing the entry outright is the over-strict check that makes people bypass the installer.
const photos = byId('photos')!;
expect(await preflight(photos, 'existing')).toEqual({ ok: true });
const jellyfin = byId('jellyfin')!;
expect(await preflight(jellyfin, 'existing')).toEqual({ ok: true });
});
it('allows a config-only sidecar regardless', async () => {