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:
mauriceboe
2026-07-03 21:02:28 +02:00
committed by Maurice
parent 8c9476e487
commit 17f3c86967
22 changed files with 1196 additions and 188 deletions
+12 -4
View File
@@ -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); }
},
];
+5 -5
View File
@@ -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';
}
}