mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(plugins): hero widget slot + Koffi reference plugin
Widget plugins can now declare capabilities.widget.slot 'hero' to render as a transparent, click-through overlay sitting on the boarding-pass bar's top edge (migration 157 persists capabilities; manifest validation server+SDK, feed exposes the slot, dashboard mounts hero frames above the pass). Sidebar stays the default slot. Replaces the trip-countdown example with Koffi, the TREK mascot: an animated suitcase with a 14-state behavior engine driven by real trip data — walking, waving, napping, trolley rolls, passport-stamp stickers, a split-flap luggage- tag countdown under 7 days, and sunglasses while the trip runs. Validated by the SDK suite like any author plugin; published as mauriceboe/trek-plugin-koffi in the registry. Migrations 156/157 follow the idempotent ALTER pattern (the reconciliation test re-runs everything from v135, so duplicate-column must stay non-fatal).
This commit is contained in:
@@ -3357,10 +3357,18 @@ function runMigrations(db: Database.Database): void {
|
||||
// erased the "keep it on" intent, so the plugin never rebooted after a deploy.
|
||||
// Boot now retries every enabled plugin regardless of last status.
|
||||
() => {
|
||||
db.exec("ALTER TABLE plugins ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;");
|
||||
// Anything not explicitly deactivated was meant to be on ('inactive' is the
|
||||
// only status deactivate() sets; a crash/shutdown could leave error/stopped/starting).
|
||||
db.exec("UPDATE plugins SET enabled = 1 WHERE status != 'inactive';");
|
||||
try {
|
||||
db.exec("ALTER TABLE plugins ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;");
|
||||
// Anything not explicitly deactivated was meant to be on ('inactive' is the
|
||||
// only status deactivate() sets; a crash/shutdown could leave error/stopped/starting).
|
||||
db.exec("UPDATE plugins SET enabled = 1 WHERE status != 'inactive';");
|
||||
} catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
|
||||
},
|
||||
// Migration 157: plugin capabilities (from trek-plugin.json) — the client
|
||||
// needs them to place widgets (e.g. widget.slot 'hero' renders as an overlay
|
||||
// on the boarding-pass bar instead of the dashboard sidebar).
|
||||
() => {
|
||||
try { db.exec("ALTER TABLE plugins ADD COLUMN capabilities TEXT NOT NULL DEFAULT '{}';"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -45,13 +45,13 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
|
||||
if (existing) {
|
||||
db.prepare(
|
||||
`UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?,
|
||||
min_trek_version = ?, permissions = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), m.id);
|
||||
min_trek_version = ?, permissions = ?, capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), m.id);
|
||||
} else {
|
||||
db.prepare(
|
||||
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, granted_permissions, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', 'inactive')`,
|
||||
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions));
|
||||
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, granted_permissions, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', 'inactive')`,
|
||||
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities));
|
||||
}
|
||||
|
||||
// Refresh the settings-field descriptors from the manifest.
|
||||
|
||||
@@ -20,6 +20,17 @@ export interface ManifestSettingField {
|
||||
oauth?: { initPath?: string; callbackPath?: string };
|
||||
}
|
||||
|
||||
export interface WidgetCapability {
|
||||
title?: string;
|
||||
defaultSize?: string;
|
||||
/** Where the widget mounts: dashboard sidebar (default) or as a hero-bar overlay. */
|
||||
slot?: 'sidebar' | 'hero';
|
||||
}
|
||||
|
||||
export interface PluginCapabilities {
|
||||
widget?: WidgetCapability;
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -36,6 +47,7 @@ export interface PluginManifest {
|
||||
permissions: string[];
|
||||
egress: string[];
|
||||
settings: ManifestSettingField[];
|
||||
capabilities: PluginCapabilities;
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
|
||||
@@ -84,9 +96,27 @@ export function parseManifest(raw: unknown): PluginManifest {
|
||||
permissions,
|
||||
egress,
|
||||
settings: parseSettings(m.settings),
|
||||
capabilities: parseCapabilities(m.capabilities),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCapabilities(raw: unknown): PluginCapabilities {
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
const c = raw as Record<string, unknown>;
|
||||
const out: PluginCapabilities = {};
|
||||
if (c.widget && typeof c.widget === 'object') {
|
||||
const w = c.widget as Record<string, unknown>;
|
||||
const slot = optStr(w.slot);
|
||||
if (slot && slot !== 'sidebar' && slot !== 'hero') throw new ManifestError(`invalid widget slot "${slot}"`);
|
||||
out.widget = {
|
||||
title: optStr(w.title),
|
||||
defaultSize: optStr(w.defaultSize),
|
||||
slot: (slot as WidgetCapability['slot']) ?? 'sidebar',
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseSettings(raw: unknown): ManifestSettingField[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ActivePlugin {
|
||||
name: string;
|
||||
type: string;
|
||||
icon: string | null;
|
||||
slot: 'sidebar' | 'hero';
|
||||
}
|
||||
|
||||
@Controller('api/plugins')
|
||||
@@ -23,9 +24,19 @@ export class PluginsFeedController {
|
||||
@Get()
|
||||
list(): { plugins: ActivePlugin[] } {
|
||||
if (!pluginsEnabled()) return { plugins: [] };
|
||||
const plugins = db
|
||||
.prepare("SELECT id, name, type, icon FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
|
||||
.all() as ActivePlugin[];
|
||||
const rows = db
|
||||
.prepare("SELECT id, name, type, icon, capabilities FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
|
||||
.all() as Array<Omit<ActivePlugin, 'slot'> & { capabilities: string }>;
|
||||
const plugins = rows.map(({ capabilities, ...p }) => ({ ...p, slot: slotOf(capabilities) }));
|
||||
return { plugins };
|
||||
}
|
||||
}
|
||||
|
||||
function slotOf(capabilities: string): 'sidebar' | 'hero' {
|
||||
try {
|
||||
const c = JSON.parse(capabilities || '{}') as { widget?: { slot?: string } };
|
||||
return c.widget?.slot === 'hero' ? 'hero' : 'sidebar';
|
||||
} catch {
|
||||
return 'sidebar';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const { testDb } = vi.hoisted(() => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
||||
api_version INTEGER, min_trek_version TEXT, permissions TEXT, granted_permissions TEXT, status TEXT, config TEXT,
|
||||
api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, config TEXT,
|
||||
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, updated_at TEXT);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
|
||||
|
||||
@@ -27,7 +27,7 @@ beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
||||
api_version INTEGER, min_trek_version TEXT, permissions TEXT, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
|
||||
api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT,
|
||||
required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
|
||||
|
||||
@@ -47,3 +47,17 @@ describe('parseManifest', () => {
|
||||
expect(() => parseManifest(input)).toThrow(re as RegExp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseManifest capabilities', () => {
|
||||
it('parses a hero widget slot and defaults to sidebar', () => {
|
||||
const hero = parseManifest({ ...base, capabilities: { widget: { slot: 'hero', title: 'T' } } });
|
||||
expect(hero.capabilities.widget?.slot).toBe('hero');
|
||||
const plain = parseManifest({ ...base, capabilities: { widget: {} } });
|
||||
expect(plain.capabilities.widget?.slot).toBe('sidebar');
|
||||
expect(parseManifest(base).capabilities).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects an unknown widget slot', () => {
|
||||
expect(() => parseManifest({ ...base, capabilities: { widget: { slot: 'floating' } } })).toThrow(ManifestError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ const { testDb } = vi.hoisted(() => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE plugins (
|
||||
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
||||
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', updated_at TEXT,
|
||||
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', updated_at TEXT,
|
||||
sort_order INTEGER DEFAULT 0);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
|
||||
@@ -66,11 +66,20 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
const active = feed.list();
|
||||
expect(active.plugins).toEqual([{ id: 'w', name: 'W', type: 'widget', icon: 'Box' }]);
|
||||
expect(active.plugins).toEqual([{ id: 'w', name: 'W', type: 'widget', icon: 'Box', slot: 'sidebar' }]);
|
||||
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
expect(feed.list().plugins).toEqual([]);
|
||||
});
|
||||
|
||||
it('exposes the widget slot from capabilities (hero) and defaults on bad JSON', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run();
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
const out = new PluginsFeedController().list();
|
||||
expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero');
|
||||
expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginsController M2 endpoints', () => {
|
||||
|
||||
Reference in New Issue
Block a user