mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(plugins): trek-plugin-sdk package — types, mock host, scaffolder, validator (M6)
The author-facing SDK, a standalone dependency-free package (not wired into the app workspaces, so it can't affect the app build). - definePlugin + the full plugin type surface (PluginContext, PluginRoute, PluginJob, PhotoProvider, CalendarSource) mirroring what the isolated runtime injects; PLUGIN_API_VERSION. - createMockHost (trek-plugin-sdk/testing): a PluginContext that enforces the SAME permission model + membership checks, so authors can unit-test that their plugin degrades gracefully — no running TREK needed. - validateManifest: the exact rules the registry CI runs, so a local pass predicts a CI pass. - CLIs: create-trek-plugin (scaffolds a working plugin + README + starter iframe) and trek-plugin validate (manifest + README sanity). Consolidating the server loader to import this shared validator is a follow-up.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
@@ -0,0 +1,60 @@
|
||||
# trek-plugin-sdk
|
||||
|
||||
The SDK for building [TREK](https://github.com/mauriceboe/TREK) plugins.
|
||||
|
||||
## Scaffold a plugin
|
||||
|
||||
```bash
|
||||
npx create-trek-plugin my-plugin --type integration|page|widget
|
||||
cd my-plugin
|
||||
# build server/index.js, fill in the README
|
||||
npx trek-plugin validate .
|
||||
```
|
||||
|
||||
## Write a plugin
|
||||
|
||||
```js
|
||||
const { definePlugin } = require('trek-plugin-sdk')
|
||||
|
||||
module.exports = definePlugin({
|
||||
async onLoad(ctx) {
|
||||
await ctx.db.migrate('001', 'CREATE TABLE cache (k TEXT PRIMARY KEY, v TEXT)')
|
||||
},
|
||||
routes: [
|
||||
{ method: 'GET', path: '/status', auth: true, async handler(req, ctx) {
|
||||
return { status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":true}' }
|
||||
}},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
Your plugin runs in an **isolated child process**. `ctx` is the only way to reach
|
||||
TREK, and it grants exactly the permissions your `trek-plugin.json` declares — an
|
||||
ungranted call throws `PERMISSION_DENIED`.
|
||||
|
||||
## Test without a running TREK
|
||||
|
||||
```js
|
||||
import { createMockHost } from 'trek-plugin-sdk/testing'
|
||||
|
||||
const { ctx, broadcasts } = createMockHost({
|
||||
grants: ['db:read:trips', 'ws:broadcast:trip'],
|
||||
trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
|
||||
})
|
||||
// the mock enforces the SAME permission model, so you can prove your plugin
|
||||
// degrades gracefully when a permission is missing.
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
- `definePlugin(def)` + all the plugin types (`PluginContext`, `PluginRoute`, `PluginJob`, `PhotoProvider`, `CalendarSource`).
|
||||
- `PLUGIN_API_VERSION` — embed as `apiVersion` in your manifest.
|
||||
- `validateManifest(json)` — the same checks the registry CI runs.
|
||||
- `createMockHost(opts)` (from `trek-plugin-sdk/testing`).
|
||||
|
||||
## CLIs
|
||||
|
||||
- `create-trek-plugin <name> --type …` — scaffold.
|
||||
- `trek-plugin validate [dir]` — validate the manifest + README locally (predicts the registry CI result).
|
||||
|
||||
MIT.
|
||||
Generated
+1459
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "trek-plugin-sdk",
|
||||
"version": "1.0.0",
|
||||
"description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
||||
"./testing": { "types": "./dist/mock-host.d.ts", "import": "./dist/mock-host.js" }
|
||||
},
|
||||
"bin": {
|
||||
"create-trek-plugin": "./dist/cli/create.js",
|
||||
"trek-plugin": "./dist/cli/trek-plugin.js"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"keywords": ["trek", "plugin", "sdk"]
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* create-trek-plugin <name> [--type integration|page|widget] (#plugins, M6).
|
||||
* Scaffolds a working plugin: manifest, an isolated server entry using
|
||||
* definePlugin, a README you must fill in, and (page/widget) a starter iframe.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function scaffold(name: string, type: string, targetDir: string): void {
|
||||
if (!/^[a-z][a-z0-9-]{2,39}$/.test(name)) throw new Error(`invalid plugin id "${name}" (lowercase slug, 3–40 chars)`);
|
||||
if (!['integration', 'page', 'widget'].includes(type)) throw new Error(`invalid type "${type}"`);
|
||||
|
||||
const root = path.join(targetDir, name);
|
||||
if (fs.existsSync(root)) throw new Error(`${root} already exists`);
|
||||
fs.mkdirSync(path.join(root, 'server'), { recursive: true });
|
||||
|
||||
const manifest: Record<string, unknown> = {
|
||||
id: name,
|
||||
name: name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
version: '1.0.0',
|
||||
apiVersion: 1,
|
||||
author: 'Your Name',
|
||||
description: 'Describe what your plugin does.',
|
||||
type,
|
||||
trek: '>=3.2.0 <4.0.0',
|
||||
nativeModules: false,
|
||||
permissions: ['db:own'],
|
||||
routes: [{ method: 'GET', path: '/hello', auth: true }],
|
||||
};
|
||||
if (type === 'page') manifest.capabilities = { nav: { label: manifest.name, icon: 'Blocks', position: 'main' } };
|
||||
if (type === 'widget') manifest.capabilities = { widget: { title: manifest.name, defaultSize: 'medium' } };
|
||||
|
||||
fs.writeFileSync(path.join(root, 'trek-plugin.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(root, 'server', 'index.js'), SERVER_JS);
|
||||
fs.writeFileSync(path.join(root, 'README.md'), readme(name));
|
||||
if (type !== 'integration') {
|
||||
fs.mkdirSync(path.join(root, 'client'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'client', 'index.html'), CLIENT_HTML);
|
||||
}
|
||||
}
|
||||
|
||||
const SERVER_JS = `// Built plugin entry — runs in an isolated child process.
|
||||
const { definePlugin } = require('trek-plugin-sdk');
|
||||
|
||||
module.exports = definePlugin({
|
||||
async onLoad(ctx) {
|
||||
await ctx.db.migrate('001_init', 'CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)');
|
||||
ctx.log.info('plugin loaded');
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
method: 'GET', path: '/hello', auth: true,
|
||||
async handler(req, ctx) {
|
||||
return { status: 200, headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ hello: req.user && req.user.username }) };
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
`;
|
||||
|
||||
const CLIENT_HTML = `<!doctype html>
|
||||
<html><head><meta charset="utf-8" /><title>Plugin</title></head>
|
||||
<body>
|
||||
<h1>Hello from your plugin</h1>
|
||||
<script>
|
||||
// The frame is sandboxed (opaque origin) — talk to TREK only via postMessage.
|
||||
window.parent.postMessage({ type: 'trek:ready' }, '*');
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data && e.data.type === 'trek:context') {
|
||||
document.body.dataset.theme = e.data.theme;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
function readme(name: string): string {
|
||||
return `# ${name}
|
||||
|
||||
> One sentence: what this plugin does.
|
||||
|
||||

|
||||
|
||||
## What it does
|
||||
|
||||
Describe the feature this plugin adds to TREK.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Show it in context (at least one image).
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Why |
|
||||
|---|---|
|
||||
| \`db:own\` | store the plugin's own data |
|
||||
|
||||
## Setup
|
||||
|
||||
How to configure it.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
`;
|
||||
}
|
||||
|
||||
// CLI entry
|
||||
if (process.argv[1] && process.argv[1].endsWith('create.js')) {
|
||||
const args = process.argv.slice(2);
|
||||
const name = args.find((a: string) => !a.startsWith('-'));
|
||||
const typeIdx = args.indexOf('--type');
|
||||
const type = typeIdx >= 0 ? args[typeIdx + 1] : 'integration';
|
||||
if (!name) {
|
||||
console.error('usage: create-trek-plugin <name> [--type integration|page|widget]');
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
scaffold(name, type, process.cwd());
|
||||
console.log(`Created ${name}/ — fill in the README, build server/index.js, then \`npx trek-plugin validate ${name}\`.`);
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/** `trek-plugin <command>` dispatcher (#plugins, M6). Commands: validate. */
|
||||
import { validatePluginDir } from './validate.js';
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
|
||||
if (cmd === 'validate') {
|
||||
const r = validatePluginDir(rest[0] || '.');
|
||||
for (const w of r.warnings) console.warn('warning: ' + w);
|
||||
if (r.ok) {
|
||||
console.log('✓ plugin is valid');
|
||||
} else {
|
||||
for (const e of r.errors) console.error('error: ' + e);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error('usage: trek-plugin validate [dir]');
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* trek-plugin validate [dir] (#plugins, M6). Runs the SAME manifest checks the
|
||||
* registry CI runs, plus a light README sanity check — so a local pass predicts
|
||||
* a CI pass. Returns a structured result; the CLI prints + exits non-zero on
|
||||
* failure.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { validateManifest } from '../manifest.js';
|
||||
|
||||
export interface ValidateReport {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function validatePluginDir(dir: string): ValidateReport {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const manifestPath = path.join(dir, 'trek-plugin.json');
|
||||
if (!fs.existsSync(manifestPath)) return { ok: false, errors: ['no trek-plugin.json in ' + dir], warnings };
|
||||
|
||||
let manifestId = '';
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const res = validateManifest(raw);
|
||||
errors.push(...res.errors);
|
||||
manifestId = res.manifest?.id ?? String(raw.id ?? '');
|
||||
// dir name should match the id
|
||||
if (manifestId && path.basename(path.resolve(dir)) !== manifestId) {
|
||||
warnings.push(`directory name should equal the plugin id "${manifestId}"`);
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push('trek-plugin.json is not valid JSON: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
|
||||
// README sanity
|
||||
const readmePath = path.join(dir, 'README.md');
|
||||
if (!fs.existsSync(readmePath)) {
|
||||
errors.push('README.md is missing');
|
||||
} else {
|
||||
const md = fs.readFileSync(readmePath, 'utf8');
|
||||
if (!/!\[[^\]]*\]\([^)]+\)/.test(md)) warnings.push('README has no screenshot (registry requires at least one)');
|
||||
if (/\{\{[^}]*\}\}|REPLACE_ME|Describe (what|the)|One sentence:/i.test(md)) {
|
||||
warnings.push('README still contains template placeholders — fill it in before publishing');
|
||||
}
|
||||
}
|
||||
|
||||
// Server entry present
|
||||
if (!fs.existsSync(path.join(dir, 'server', 'index.js'))) errors.push('server/index.js is missing (build your plugin first)');
|
||||
|
||||
return { ok: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('validate.js')) {
|
||||
const dir = process.argv[2] || '.';
|
||||
const r = validatePluginDir(dir);
|
||||
for (const w of r.warnings) console.warn('warning: ' + w);
|
||||
if (r.ok) {
|
||||
console.log('✓ plugin is valid');
|
||||
} else {
|
||||
for (const e of r.errors) console.error('error: ' + e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* trek-plugin-sdk — the author-facing SDK for building TREK plugins (#plugins, M6).
|
||||
*
|
||||
* Types + `definePlugin` mirror what the isolated runtime injects, so a plugin
|
||||
* written against this package runs unchanged inside TREK. Pure and
|
||||
* dependency-free.
|
||||
*/
|
||||
|
||||
/** Bumped on any breaking change to the plugin API surface. Embed as `apiVersion` in your manifest. */
|
||||
export const PLUGIN_API_VERSION = 1 as const;
|
||||
|
||||
export interface PluginContext {
|
||||
readonly id: string;
|
||||
readonly config: Readonly<Record<string, unknown>>;
|
||||
db: {
|
||||
query<T = unknown>(sql: string, ...args: unknown[]): Promise<T[]>;
|
||||
exec(sql: string, ...args: unknown[]): Promise<{ changes: number }>;
|
||||
migrate(id: string, sql: string): Promise<{ applied: boolean }>;
|
||||
};
|
||||
trips: {
|
||||
getById(tripId: number, asUserId: number): Promise<unknown>;
|
||||
getPlaces(tripId: number, asUserId: number): Promise<unknown[]>;
|
||||
getReservations(tripId: number, asUserId: number): Promise<unknown[]>;
|
||||
};
|
||||
users: { getById(id: number): Promise<unknown> };
|
||||
ws: {
|
||||
broadcastToTrip(tripId: number, event: string, data: Record<string, unknown>): Promise<void>;
|
||||
broadcastToUser(userId: number, event: string, data: Record<string, unknown>): Promise<void>;
|
||||
};
|
||||
log: {
|
||||
info(msg: string, meta?: Record<string, unknown>): void;
|
||||
warn(msg: string, meta?: Record<string, unknown>): void;
|
||||
error(msg: string, meta?: Record<string, unknown>): void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
query: Record<string, unknown>;
|
||||
body: unknown;
|
||||
user: { id: number; username: string; isAdmin: boolean } | null;
|
||||
}
|
||||
export interface PluginResponse {
|
||||
status: number;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
}
|
||||
export interface PluginRoute {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
path: string;
|
||||
/** Default true. Set false for OAuth callbacks / webhooks (public route). */
|
||||
auth?: boolean;
|
||||
handler(req: PluginRequest, ctx: PluginContext): Promise<PluginResponse>;
|
||||
}
|
||||
export interface PluginJob {
|
||||
id: string;
|
||||
/** Cron expression; the host owns the schedule. */
|
||||
schedule: string;
|
||||
handler(ctx: PluginContext): Promise<void>;
|
||||
}
|
||||
|
||||
// ── integration hook interfaces ──────────────────────────────────────────────
|
||||
export interface Photo {
|
||||
id: string;
|
||||
title?: string;
|
||||
thumbnailUrl: string;
|
||||
fullUrl: string;
|
||||
takenAt?: string;
|
||||
}
|
||||
export interface PhotoProvider {
|
||||
search(query: string, opts: { page: number; limit: number }): Promise<{ photos: Photo[]; total: number; hasMore: boolean }>;
|
||||
getById(id: string): Promise<Photo | null>;
|
||||
}
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
}
|
||||
export interface CalendarSource {
|
||||
getName(): string;
|
||||
getEvents(userId: number, start: Date, end: Date): Promise<CalendarEvent[]>;
|
||||
}
|
||||
|
||||
export interface PluginDefinition {
|
||||
onLoad?(ctx: PluginContext): Promise<void> | void;
|
||||
onUnload?(ctx: PluginContext): Promise<void> | void;
|
||||
routes?: PluginRoute[];
|
||||
jobs?: PluginJob[];
|
||||
hooks?: { photoProvider?: PhotoProvider; calendarSource?: CalendarSource };
|
||||
}
|
||||
|
||||
/** Define a plugin. Gives you types; the returned object is what TREK loads. */
|
||||
export function definePlugin(def: PluginDefinition): PluginDefinition {
|
||||
return def;
|
||||
}
|
||||
|
||||
export { validateManifest, type PluginManifest, type ValidationResult } from './manifest.js';
|
||||
export { createMockHost, type MockHostOptions } from './mock-host.js';
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Manifest validation (#plugins, M6). Identical rules to the TREK server's
|
||||
* loader, so `trek-plugin validate` locally == the registry CI gate. Returns a
|
||||
* result (no throw) so the CLI can print every problem at once.
|
||||
*/
|
||||
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
apiVersion: number;
|
||||
type: 'integration' | 'page' | 'widget';
|
||||
permissions: string[];
|
||||
egress: string[];
|
||||
nativeModules?: boolean;
|
||||
}
|
||||
export interface ValidationResult {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
manifest?: PluginManifest;
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
|
||||
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
const TYPES = ['integration', 'page', 'widget'];
|
||||
const KNOWN_PERMISSIONS = [
|
||||
'db:own', 'db:read:trips', 'db:read:users', 'ws:broadcast:trip', 'ws:broadcast:user',
|
||||
'hook:photo-provider', 'hook:calendar-source', 'http:outbound',
|
||||
];
|
||||
|
||||
function isKnownPermission(p: string): boolean {
|
||||
return KNOWN_PERMISSIONS.includes(p) || p.startsWith('http:outbound:');
|
||||
}
|
||||
|
||||
export function validateManifest(raw: unknown): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const m = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
|
||||
if (!raw || typeof raw !== 'object') return { ok: false, errors: ['manifest is not an object'] };
|
||||
|
||||
const req = (k: string) => {
|
||||
if (typeof m[k] !== 'string' || !m[k]) errors.push(`missing/invalid "${k}"`);
|
||||
};
|
||||
req('id'); req('name'); req('version'); req('type');
|
||||
|
||||
if (typeof m.id === 'string' && !ID_RE.test(m.id)) errors.push(`id "${m.id}" must be a lowercase slug (3–40 chars)`);
|
||||
if (typeof m.version === 'string' && !SEMVER_RE.test(m.version)) errors.push(`version "${m.version}" is not semver`);
|
||||
if (typeof m.type === 'string' && !TYPES.includes(m.type)) errors.push(`type must be one of ${TYPES.join('/')}`);
|
||||
if (m.apiVersion !== undefined && typeof m.apiVersion !== 'number') errors.push('apiVersion must be a number');
|
||||
if (m.nativeModules === true) errors.push('native modules are not allowed (v1)');
|
||||
|
||||
const permissions = Array.isArray(m.permissions) ? m.permissions.map(String) : [];
|
||||
for (const p of permissions) if (!isKnownPermission(p)) errors.push(`unknown permission: ${p}`);
|
||||
|
||||
const egress = Array.isArray(m.egress) ? m.egress.map(String) : [];
|
||||
const wantsOutbound = permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'));
|
||||
if (wantsOutbound && egress.length === 0) errors.push('http:outbound declared but egress[] is empty');
|
||||
if (egress.includes('*')) errors.push('egress[] must not contain a bare "*"');
|
||||
|
||||
if (errors.length) return { ok: false, errors };
|
||||
return {
|
||||
ok: true,
|
||||
errors: [],
|
||||
manifest: {
|
||||
id: m.id as string,
|
||||
name: m.name as string,
|
||||
version: m.version as string,
|
||||
apiVersion: typeof m.apiVersion === 'number' ? m.apiVersion : 1,
|
||||
type: m.type as PluginManifest['type'],
|
||||
permissions,
|
||||
egress,
|
||||
nativeModules: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { PluginContext } from './index.js';
|
||||
|
||||
/**
|
||||
* A mock PluginContext for unit-testing a plugin without a running TREK
|
||||
* (#plugins, M6). It enforces the SAME permission model: calling a capability
|
||||
* your plugin wasn't granted throws PERMISSION_DENIED — so a test can prove your
|
||||
* plugin degrades gracefully. Data access returns configured fixtures; the db is
|
||||
* a lightweight recorder (configure results, or use an integration test for real
|
||||
* SQL).
|
||||
*/
|
||||
|
||||
export interface MockHostOptions {
|
||||
grants?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
/** Fixtures keyed by trip id; `members` gates access like the real host. */
|
||||
trips?: Record<number, { members: number[]; data?: unknown; places?: unknown[]; reservations?: unknown[] }>;
|
||||
users?: Record<number, unknown>;
|
||||
/** Optional canned db.query results, keyed by the exact sql string. */
|
||||
queryResults?: Record<string, unknown[]>;
|
||||
}
|
||||
|
||||
export interface MockHost {
|
||||
ctx: PluginContext;
|
||||
/** Everything the plugin did, for assertions. */
|
||||
calls: { method: string; args: unknown[] }[];
|
||||
logs: { level: string; msg: string }[];
|
||||
broadcasts: { kind: 'trip' | 'user'; target: number; event: string; data: unknown }[];
|
||||
}
|
||||
|
||||
class PermissionDenied extends Error {}
|
||||
|
||||
export function createMockHost(opts: MockHostOptions = {}): MockHost {
|
||||
const grants = new Set(opts.grants ?? []);
|
||||
const calls: MockHost['calls'] = [];
|
||||
const logs: MockHost['logs'] = [];
|
||||
const broadcasts: MockHost['broadcasts'] = [];
|
||||
|
||||
const need = (perm: string, method: string) => {
|
||||
calls.push({ method, args: [] });
|
||||
if (!grants.has(perm)) throw new PermissionDenied(`PERMISSION_DENIED: ${method} requires ${perm}`);
|
||||
};
|
||||
const assertMember = (tripId: number, asUserId: number) => {
|
||||
const t = opts.trips?.[tripId];
|
||||
if (!t || !t.members.includes(asUserId)) throw new Error(`RESOURCE_FORBIDDEN: no access to trip ${tripId}`);
|
||||
return t;
|
||||
};
|
||||
|
||||
const ctx: PluginContext = {
|
||||
id: 'mock-plugin',
|
||||
config: Object.freeze({ ...(opts.config ?? {}) }),
|
||||
db: {
|
||||
async query(sql) {
|
||||
need('db:own', 'db.query');
|
||||
return (opts.queryResults?.[sql] ?? []) as never[];
|
||||
},
|
||||
async exec() {
|
||||
need('db:own', 'db.exec');
|
||||
return { changes: 0 };
|
||||
},
|
||||
async migrate() {
|
||||
need('db:own', 'db.migrate');
|
||||
return { applied: true };
|
||||
},
|
||||
},
|
||||
trips: {
|
||||
async getById(tripId, asUserId) {
|
||||
need('db:read:trips', 'trips.getById');
|
||||
return assertMember(tripId, asUserId).data ?? null;
|
||||
},
|
||||
async getPlaces(tripId, asUserId) {
|
||||
need('db:read:trips', 'trips.getPlaces');
|
||||
return assertMember(tripId, asUserId).places ?? [];
|
||||
},
|
||||
async getReservations(tripId, asUserId) {
|
||||
need('db:read:trips', 'trips.getReservations');
|
||||
return assertMember(tripId, asUserId).reservations ?? [];
|
||||
},
|
||||
},
|
||||
users: {
|
||||
async getById(id) {
|
||||
need('db:read:users', 'users.getById');
|
||||
return opts.users?.[id] ?? null;
|
||||
},
|
||||
},
|
||||
ws: {
|
||||
async broadcastToTrip(tripId, event, data) {
|
||||
need('ws:broadcast:trip', 'ws.broadcastToTrip');
|
||||
broadcasts.push({ kind: 'trip', target: tripId, event, data });
|
||||
},
|
||||
async broadcastToUser(userId, event, data) {
|
||||
need('ws:broadcast:user', 'ws.broadcastToUser');
|
||||
broadcasts.push({ kind: 'user', target: userId, event, data });
|
||||
},
|
||||
},
|
||||
log: {
|
||||
info: (msg) => logs.push({ level: 'info', msg }),
|
||||
warn: (msg) => logs.push({ level: 'warn', msg }),
|
||||
error: (msg) => logs.push({ level: 'error', msg }),
|
||||
},
|
||||
};
|
||||
|
||||
return { ctx, calls, logs, broadcasts };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { definePlugin, PLUGIN_API_VERSION, validateManifest, createMockHost } from '../src/index.js';
|
||||
import { scaffold } from '../src/cli/create.js';
|
||||
import { validatePluginDir } from '../src/cli/validate.js';
|
||||
|
||||
describe('definePlugin + api version', () => {
|
||||
it('returns the definition and exposes the api version', () => {
|
||||
const def = { onLoad: async () => {} };
|
||||
expect(definePlugin(def)).toBe(def);
|
||||
expect(PLUGIN_API_VERSION).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateManifest', () => {
|
||||
const base = { id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', apiVersion: 1 };
|
||||
it('accepts a valid manifest', () => {
|
||||
expect(validateManifest(base).ok).toBe(true);
|
||||
});
|
||||
it('collects every problem', () => {
|
||||
const r = validateManifest({ id: 'Bad', version: '1.x', type: 'nope', permissions: ['fs:read'] });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.length).toBeGreaterThan(2);
|
||||
});
|
||||
it('requires egress when http:outbound is declared', () => {
|
||||
expect(validateManifest({ ...base, permissions: ['http:outbound'] }).ok).toBe(false);
|
||||
expect(validateManifest({ ...base, permissions: ['http:outbound'], egress: ['api.x.com'] }).ok).toBe(true);
|
||||
});
|
||||
it('rejects native modules and non-objects', () => {
|
||||
expect(validateManifest({ ...base, nativeModules: true }).ok).toBe(false);
|
||||
expect(validateManifest('nope').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMockHost', () => {
|
||||
it('enforces the granted permission set', async () => {
|
||||
const { ctx } = createMockHost({ grants: ['db:own'] });
|
||||
await expect(ctx.db.migrate('1', 'CREATE TABLE t (x)')).resolves.toEqual({ applied: true });
|
||||
await expect(ctx.trips.getById(1, 1)).rejects.toThrow(/PERMISSION_DENIED/);
|
||||
});
|
||||
|
||||
it('membership-checks trip reads and records broadcasts', async () => {
|
||||
const { ctx, broadcasts } = createMockHost({
|
||||
grants: ['db:read:trips', 'ws:broadcast:trip'],
|
||||
trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
|
||||
});
|
||||
await expect(ctx.trips.getById(1, 99)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
|
||||
expect(await ctx.trips.getById(1, 42)).toEqual({ id: 1, name: 'Japan' });
|
||||
await ctx.ws.broadcastToTrip(1, 'ping', { a: 1 });
|
||||
expect(broadcasts).toEqual([{ kind: 'trip', target: 1, event: 'ping', data: { a: 1 } }]);
|
||||
});
|
||||
|
||||
it('returns canned db.query results + records logs', async () => {
|
||||
const { ctx, logs } = createMockHost({ grants: ['db:own'], queryResults: { 'SELECT 1': [{ n: 1 }] } });
|
||||
expect(await ctx.db.query('SELECT 1')).toEqual([{ n: 1 }]);
|
||||
ctx.log.info('hi');
|
||||
expect(logs).toEqual([{ level: 'info', msg: 'hi' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scaffold + validate CLIs', () => {
|
||||
let tmp: string;
|
||||
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-')); });
|
||||
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
it('scaffolds a widget plugin that then validates (with README warnings)', () => {
|
||||
scaffold('my-widget', 'widget', tmp);
|
||||
const dir = path.join(tmp, 'my-widget');
|
||||
expect(fs.existsSync(path.join(dir, 'trek-plugin.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, 'server', 'index.js'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, 'client', 'index.html'))).toBe(true);
|
||||
|
||||
const r = validatePluginDir(dir);
|
||||
expect(r.ok).toBe(true); // manifest + files valid
|
||||
expect(r.warnings.some((w) => /placeholder|screenshot/.test(w))).toBe(true); // README is the unfilled template
|
||||
});
|
||||
|
||||
it('rejects an invalid plugin name', () => {
|
||||
expect(() => scaffold('Bad Name', 'widget', tmp)).toThrow(/invalid plugin id/);
|
||||
});
|
||||
|
||||
it('validatePluginDir flags a missing manifest', () => {
|
||||
expect(validatePluginDir(tmp).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "node_modules", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user