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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user