mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
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:
@@ -44,7 +44,11 @@ describe('discoverPlugins', () => {
|
||||
name: 'Flight',
|
||||
type: 'widget',
|
||||
permissions: ['db:own'],
|
||||
settings: [{ key: 'api_key', input_type: 'password', scope: 'instance', secret: true }],
|
||||
settings: [
|
||||
{ key: 'api_key', input_type: 'password', scope: 'instance', secret: true },
|
||||
{ key: 'units', input_type: 'select', scope: 'user', options: [{ value: 'm', label: 'Metric' }] },
|
||||
{ key: 'oauth', input_type: 'oauth', scope: 'user', oauth: { initPath: '/o/start', callbackPath: '/o/cb' } },
|
||||
],
|
||||
});
|
||||
const res = discoverPlugins(db);
|
||||
expect(res.discovered).toEqual(['flight-tracker']);
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,22 +43,23 @@ function makeTarGz(entries: Array<{ name: string; data?: string; type?: string;
|
||||
parts.push(Buffer.alloc(1024, 0));
|
||||
return zlib.gzipSync(Buffer.concat(parts));
|
||||
}
|
||||
function makeStoredZip(name: string, data: string): Buffer {
|
||||
function makeZip(name: string, data: string, method = 0): Buffer {
|
||||
const nameB = Buffer.from(name);
|
||||
const dataB = Buffer.from(data);
|
||||
const raw = Buffer.from(data);
|
||||
const comp = method === 8 ? zlib.deflateRawSync(raw) : raw;
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(0, 8); // method 0 (stored)
|
||||
local.writeUInt32LE(dataB.length, 18);
|
||||
local.writeUInt32LE(dataB.length, 22);
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt32LE(comp.length, 18);
|
||||
local.writeUInt32LE(raw.length, 22);
|
||||
local.writeUInt16LE(nameB.length, 26);
|
||||
const localRec = Buffer.concat([local, nameB, dataB]);
|
||||
const localRec = Buffer.concat([local, nameB, comp]);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(0, 10);
|
||||
central.writeUInt32LE(dataB.length, 20);
|
||||
central.writeUInt32LE(dataB.length, 24);
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt32LE(comp.length, 20);
|
||||
central.writeUInt32LE(raw.length, 24);
|
||||
central.writeUInt16LE(nameB.length, 28);
|
||||
central.writeUInt32LE(0, 42); // local header offset
|
||||
const centralRec = Buffer.concat([central, nameB]);
|
||||
@@ -101,10 +102,16 @@ describe('extractArchive', () => {
|
||||
});
|
||||
|
||||
it('extracts a stored zip', () => {
|
||||
extractArchive(makeStoredZip('a.txt', 'hello'), dest);
|
||||
extractArchive(makeZip('a.txt', 'hello'), dest);
|
||||
expect(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('extracts a deflated zip and rejects an unsupported compression method', () => {
|
||||
extractArchive(makeZip('b.txt', 'world', 8), dest);
|
||||
expect(fs.readFileSync(path.join(dest, 'b.txt'), 'utf8')).toBe('world');
|
||||
expect(() => extractArchive(makeZip('c.txt', 'x', 99), dest)).toThrow(/unsupported zip compression/);
|
||||
});
|
||||
|
||||
it('refuses a symlink entry', () => {
|
||||
const gz = makeTarGz([{ name: 'link', type: '2', link: '/etc/passwd' }]);
|
||||
expect(() => extractArchive(gz, dest)).toThrow(/non-regular/);
|
||||
|
||||
@@ -58,6 +58,15 @@ describe('safeDownload', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects an invalid url', async () => {
|
||||
await expect(safeDownload('not-a-url')).rejects.toThrow(/invalid url/);
|
||||
});
|
||||
|
||||
it('rejects a redirect without a location header', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ status: 302, headers: new Headers() }) as unknown as Response));
|
||||
await expect(safeDownload('https://github.com/x')).rejects.toThrow(/redirect without/);
|
||||
});
|
||||
|
||||
it('enforces the size cap', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true, headers: new Headers(), arrayBuffer: async () => Buffer.alloc(2000) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
Reference in New Issue
Block a user