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:
mauriceboe
2026-07-03 17:44:55 +02:00
parent c98bf7977d
commit 7318ee0d42
12 changed files with 2146 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.log
+60
View File
@@ -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.
+1459
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"]
}
+127
View File
@@ -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.
![screenshot](./docs/screenshot-1.png)
## 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);
}
}
+19
View File
@@ -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);
}
+67
View File
@@ -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);
}
}
+101
View File
@@ -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';
+74
View File
@@ -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,
},
};
}
+103
View File
@@ -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 };
}
+87
View File
@@ -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);
});
});
+17
View File
@@ -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"]
}