feat(plugins): TREK-side registry — browse + one-click install (M5)

Connects TREK to the static GitHub registry (mauriceboe/TREK-Plugins). The
registry repo + CI gates were already live; this is the server side.

- registry.service: fetches the single aggregated dist/index.json (never
  per-plugin GitHub API calls — the HACS rate-limit lesson), caches it 30 min,
  soft-fails to a stale/empty registry, and installs a pinned version through
  the M4 pipeline: safe download -> sha256 verify -> slip-safe extract ->
  manifest re-validate -> native re-scan -> atomic move -> discover (inactive),
  recording repo/commit/sha provenance. Handles the codeload {repo}-{sha}/
  wrapper directory.
- Admin endpoints: GET /api/admin/plugins/registry (browse metadata) and
  POST /api/admin/plugins/install { id, version }. Install never executes code;
  activation stays a separate, deliberate step.
This commit is contained in:
mauriceboe
2026-07-03 17:38:10 +02:00
committed by Maurice
parent f7c54d3d90
commit f61e144e11
8 changed files with 376 additions and 18 deletions
@@ -51,7 +51,7 @@ describe('PluginsService.list', () => {
it('controller delegates to the service', () => {
const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService;
const runtime = {} as unknown as import('../../../src/nest/plugins/plugin-runtime.service').PluginRuntimeService;
const res = new PluginsController(svc, runtime).list();
const res = new PluginsController(svc, runtime, {} as never).list();
expect(svc.list).toHaveBeenCalled();
expect(res).toEqual({ enabled: false, plugins: [] });
});
@@ -86,31 +86,31 @@ describe('PluginsController M2 endpoints', () => {
it('get/update config delegate to the service', () => {
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn() } as never;
const c = new PluginsController(svc, rt);
const c = new PluginsController(svc, rt, {} as never);
expect(c.getConfig('x')).toEqual({ config: { a: 1 } });
expect(c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 } });
});
it('activate spawns via the runtime when enabled', async () => {
const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never;
const out = await new PluginsController(svc, rt).activate('x');
const out = await new PluginsController(svc, rt, {} as never).activate('x');
expect(out).toEqual({ status: 'active' });
});
it('activate is 503 when the runtime is disabled', async () => {
process.env.TREK_PLUGINS_ENABLED = 'false';
const rt = { activate: vi.fn(), isActive: vi.fn() } as never;
await expect(new PluginsController(svc, rt).activate('x')).rejects.toMatchObject({ status: 503 });
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 503 });
});
it('activate surfaces an activation error as 400', async () => {
const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never;
await expect(new PluginsController(svc, rt).activate('x')).rejects.toMatchObject({ status: 400 });
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 400 });
});
it('deactivate stops the plugin', async () => {
const rt = { deactivate: vi.fn(async () => {}) } as never;
expect(await new PluginsController(svc, rt).deactivate('x')).toEqual({ status: 'inactive' });
expect(await new PluginsController(svc, rt, {} as never).deactivate('x')).toEqual({ status: 'inactive' });
});
});