From d110f8f0ce163e2f31999b2bd1afb072b3b0a3b3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 4 Jul 2026 19:57:43 +0200 Subject: [PATCH] fix(plugin-sdk): make require('trek-plugin-sdk') actually resolve everywhere A freshly scaffolded plugin could not load anywhere: the npm package is ESM-only (no require condition in its exports map), so the scaffold's require('trek-plugin-sdk') threw ERR_PACKAGE_PATH_NOT_EXPORTED under `trek-plugin dev` - and the runtime injection the wiki promised for the plugin child never existed, so a packed plugin (node_modules stripped) crashed with MODULE_NOT_FOUND after a real install. - plugin child: inject a frozen {definePlugin, PLUGIN_API_VERSION} shim for require('trek-plugin-sdk'); subpaths fail with a pointed error - trek-plugin dev: inject the exact same shim, so a fresh scaffold runs with zero npm install and dev parity with production holds - npm package: ship a real CommonJS build (dist/cjs + require export conditions) so the installed package also requires cleanly on Node 18+ - create: scaffold a package.json (type commonjs, SDK as devDependency, npx scripts); print resolvable `npx trek-plugin-sdk ...` hints - wiki: package.json in the scaffold tree + publishing checklist, and document the zero-install dev flow --- plugin-sdk/package.json | 14 +++++--- plugin-sdk/scripts/finish-cjs.mjs | 9 +++++ plugin-sdk/src/cli/create.ts | 24 ++++++++++++- plugin-sdk/src/cli/dev.ts | 30 ++++++++++++++++ plugin-sdk/src/cli/trek-plugin.ts | 12 +++---- plugin-sdk/test/sdk.test.ts | 35 +++++++++++++++++++ plugin-sdk/tsconfig.cjs.json | 9 +++++ .../nest/plugins/runtime/plugin-host-entry.ts | 27 +++++++++++++- server/src/nest/plugins/runtime/plugin-sdk.ts | 3 ++ .../integration/plugins/supervisor.test.ts | 27 ++++++++++++++ wiki/Plugin-Development.md | 8 +++-- wiki/Plugin-Publishing.md | 1 + 12 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 plugin-sdk/scripts/finish-cjs.mjs create mode 100644 plugin-sdk/tsconfig.cjs.json diff --git a/plugin-sdk/package.json b/plugin-sdk/package.json index 773ed7c3d..e759593f2 100644 --- a/plugin-sdk/package.json +++ b/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "trek-plugin-sdk", - "version": "1.2.0", + "version": "1.2.1", "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", @@ -13,8 +13,14 @@ "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" } + ".": { + "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "require": { "types": "./dist/cjs/index.d.ts", "default": "./dist/cjs/index.js" } + }, + "./testing": { + "import": { "types": "./dist/mock-host.d.ts", "default": "./dist/mock-host.js" }, + "require": { "types": "./dist/cjs/mock-host.d.ts", "default": "./dist/cjs/mock-host.js" } + } }, "bin": { "trek-plugin-sdk": "./dist/cli/trek-plugin.js", @@ -23,7 +29,7 @@ }, "files": ["dist"], "scripts": { - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/finish-cjs.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "prepublishOnly": "npm run build && npm test" diff --git a/plugin-sdk/scripts/finish-cjs.mjs b/plugin-sdk/scripts/finish-cjs.mjs new file mode 100644 index 000000000..94faaf216 --- /dev/null +++ b/plugin-sdk/scripts/finish-cjs.mjs @@ -0,0 +1,9 @@ +// The package root says `"type": "module"`, so the CommonJS second build in +// dist/cjs needs its own scope marker or Node would parse those files as ESM. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'cjs'); +fs.mkdirSync(dir, { recursive: true }); +fs.writeFileSync(path.join(dir, 'package.json'), '{"type":"commonjs"}\n'); diff --git a/plugin-sdk/src/cli/create.ts b/plugin-sdk/src/cli/create.ts index 4ffb50960..4e57d16c4 100644 --- a/plugin-sdk/src/cli/create.ts +++ b/plugin-sdk/src/cli/create.ts @@ -7,6 +7,17 @@ import fs from 'node:fs'; import path from 'node:path'; import readline from 'node:readline'; +import { createRequire } from 'node:module'; + +/** This package's own version, for the scaffold's devDependency range. */ +function sdkVersionRange(): string { + try { + const pkg = createRequire(import.meta.url)('../../package.json') as { version?: string }; + return pkg.version ? `^${pkg.version}` : '^1'; + } catch { + return '^1'; + } +} export interface ScaffoldOptions { author?: string; @@ -42,6 +53,17 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc 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)); + // `type: commonjs` pins how the entry is parsed everywhere (dev, tests, TREK); + // the SDK is a devDependency ONLY (types + mock host) — at runtime both the + // dev server and TREK inject it, so it is never vendored into the artifact. + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ + name, + version: '1.0.0', + private: true, + type: 'commonjs', + scripts: { dev: 'npx -y trek-plugin-sdk dev', pack: 'npx -y trek-plugin-sdk pack' }, + devDependencies: { 'trek-plugin-sdk': sdkVersionRange() }, + }, null, 2) + '\n'); if (type !== 'integration') { fs.mkdirSync(path.join(root, 'client'), { recursive: true }); fs.writeFileSync(path.join(root, 'client', 'index.html'), CLIENT_HTML); @@ -162,7 +184,7 @@ if (process.argv[1] && process.argv[1].endsWith('create.js')) { } try { scaffold(name, type, process.cwd()); - console.log(`Created ${name}/ — fill in the README, build server/index.js, then \`npx trek-plugin validate ${name}\`.`); + console.log(`Created ${name}/ — fill in the README, build server/index.js, then \`npx trek-plugin-sdk validate ${name}\`.`); } catch (e) { console.error(e instanceof Error ? e.message : e); process.exit(1); diff --git a/plugin-sdk/src/cli/dev.ts b/plugin-sdk/src/cli/dev.ts index 3019c07c4..6112d1334 100644 --- a/plugin-sdk/src/cli/dev.ts +++ b/plugin-sdk/src/cli/dev.ts @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import { createRequire } from 'node:module'; +import * as sdk from '../index.js'; interface Fixtures { config?: Record; @@ -92,6 +93,34 @@ function createDevContext(id: string, grants: Set, fx: Fixtures, db: Plu }; } +/** + * Serve `require('trek-plugin-sdk')` from THIS package, exactly like the TREK + * child process does at runtime — so `dev` works on a fresh scaffold with no + * npm install, and what loads here is what loads in production. That parity is + * the point: the injected surface is the SAME minimal frozen shim the prod + * child serves (definePlugin + PLUGIN_API_VERSION), and subpaths fail with the + * same pointed error. validateManifest/createMockHost stay available where they + * belong — in your tests, which load the installed package without this patch. + */ +let sdkInjected = false; +export function installSdkInjection(): void { + if (sdkInjected) return; + const nodeModule = createRequire(import.meta.url)('node:module') as { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; + }; + const realLoad = nodeModule._load; + if (typeof realLoad !== 'function') return; + sdkInjected = true; + const shim = Object.freeze({ definePlugin: sdk.definePlugin, PLUGIN_API_VERSION: sdk.PLUGIN_API_VERSION }); + nodeModule._load = function (request: string, parent: unknown, isMain: boolean): unknown { + if (request === 'trek-plugin-sdk') return shim; + if (request.startsWith('trek-plugin-sdk/')) { + throw new Error(`${request} is a build/test-time module — only 'trek-plugin-sdk' itself is injected inside TREK`); + } + return realLoad.call(this, request, parent, isMain); + }; +} + function loadFixtures(dir: string): Fixtures { const p = path.join(dir, 'dev-fixtures.json'); if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { console.warn('warning: dev-fixtures.json is not valid JSON — ignoring'); } } @@ -120,6 +149,7 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise const fx = loadFixtures(abs); const { db, note: dbNote, close: closeDb } = createDevDb(path.join(abs, '.trek-dev', 'db.sqlite')); const broadcasts: unknown[] = []; + installSdkInjection(); const req = createRequire(path.join(abs, 'server', 'index.js')); let plugin: PluginLike = {}; diff --git a/plugin-sdk/src/cli/trek-plugin.ts b/plugin-sdk/src/cli/trek-plugin.ts index bacf0d31f..f4537b7bc 100644 --- a/plugin-sdk/src/cli/trek-plugin.ts +++ b/plugin-sdk/src/cli/trek-plugin.ts @@ -67,14 +67,14 @@ async function main(): Promise { const name = pos[0]; if (!name || flags.interactive) { const created = await interactiveScaffold(process.cwd(), name); - console.log(`\nCreated ${created}/ — build server/index.js, add docs/screenshot.png, then \`trek-plugin dev ${created}\`.`); + console.log(`\nCreated ${created}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${created}\`.`); return; } scaffold(name, flags.type || 'integration', process.cwd(), { author: flags.author, description: flags.description, permissions: flags.permissions ? flags.permissions.split(/[\s,]+/).filter(Boolean) : undefined, }); - console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, then \`trek-plugin dev ${name}\`.`); + console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${name}\`.`); } else if (cmd === 'dev') { await runDev(pos[0] || '.', { port: flags.port ? Number(flags.port) : undefined }); } else if (cmd === 'validate') { @@ -90,17 +90,17 @@ async function main(): Promise { console.log(`Packed ${r.files.length} files -> ${path.relative(process.cwd(), r.artifact) || r.artifact}`); for (const f of r.files) console.log(' ' + f); console.log(`\nsha256: ${r.sha256}\nsize: ${r.size}`); - console.log('\nUpload this plugin.zip to your release, then run `trek-plugin entry` to generate the registry entry.'); + console.log('\nUpload this plugin.zip to your release, then run `npx trek-plugin-sdk entry` to generate the registry entry.'); } } else if (cmd === 'keygen') { const keyPath = flags.key || defaultKeyPath(); const { publicKey } = generateKeypair(keyPath); console.log(`Signing key written to ${keyPath} (keep it safe + BACK IT UP — losing it means you can't ship signed updates).`); console.log(`\nauthorPublicKey (goes in your registry entry): ${publicKey}`); - console.log('\nSign releases with `trek-plugin release --sign` (or `entry --sign`).'); + console.log('\nSign releases with `npx trek-plugin-sdk release --sign` (or `entry --sign`).'); } else if (cmd === 'sign') { const zip = pos[0] || 'plugin.zip'; - if (!fs.existsSync(zip)) fail(`artifact not found: ${zip} — run \`trek-plugin pack\` first`); + if (!fs.existsSync(zip)) fail(`artifact not found: ${zip} — run \`npx trek-plugin-sdk pack\` first`); const key = loadPrivateKey(flags.key || defaultKeyPath()); const buf = fs.readFileSync(zip); console.log(`signature: ${signArtifact(buf, key)}`); @@ -162,7 +162,7 @@ async function main(): Promise { console.error(`Creating GitHub release ${flags.tag} on ${flags.repo}…`); execFileSync('gh', ['release', 'create', flags.tag, packed.artifact, '--repo', flags.repo, '--title', flags.tag, '--notes', flags.notes || `Release ${flags.tag}`], { stdio: 'inherit' }); const entry = buildEntry({ dir, repo: flags.repo, tag: flags.tag, zipPath: packed.artifact, commit: flags.commit, mergePath: flags.merge, signKeyPath: signKey(), now: new Date().toISOString() }); - console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `trek-plugin submit`):\n'); + console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `npx trek-plugin-sdk submit`):\n'); process.stdout.write(JSON.stringify(entry, null, 2) + '\n'); } else { console.error('usage: trek-plugin [...]'); diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index 885d1a2d1..72b2823c0 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -105,6 +105,15 @@ describe('scaffold + validate CLIs', () => { expect(r.warnings.some((w) => /placeholder|screenshot/.test(w))).toBe(true); // README is the unfilled template }); + it('scaffolds a CommonJS package.json with the SDK as a devDependency only', () => { + scaffold('my-widget', 'widget', tmp); + const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'my-widget', 'package.json'), 'utf8')); + expect(pkg.type).toBe('commonjs'); + expect(pkg.private).toBe(true); + expect(pkg.devDependencies['trek-plugin-sdk']).toMatch(/^\^\d/); + expect(pkg.dependencies).toBeUndefined(); // runtime deps are the author's call; the SDK never is one + }); + it('applies author, description, and permissions from options', () => { scaffold('opt-plug', 'integration', tmp, { author: 'Jane', description: 'Does X', permissions: ['db:own', 'db:read:trips'] }); const m = JSON.parse(fs.readFileSync(path.join(tmp, 'opt-plug', 'trek-plugin.json'), 'utf8')); @@ -122,6 +131,32 @@ describe('scaffold + validate CLIs', () => { }); }); +describe('dev-server SDK injection', () => { + let tmp: string; + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'inject-')); }); + afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + + it('makes require(trek-plugin-sdk) resolve from the package itself — no npm install', async () => { + const { installSdkInjection } = await import('../src/cli/dev.js'); + const { createRequire } = await import('node:module'); + installSdkInjection(); + // A scaffold-shaped CJS entry that requires the SDK without any node_modules. + // The injected surface must MATCH the prod child shim: definePlugin + + // PLUGIN_API_VERSION only, subpaths throw the same pointed error. + fs.writeFileSync(path.join(tmp, 'entry.cjs'), + "const sdkShim = require('trek-plugin-sdk');\n" + + "let testingError = '';\n" + + "try { require('trek-plugin-sdk/testing'); } catch (e) { testingError = e.message; }\n" + + 'module.exports = { def: sdkShim.definePlugin({ routes: [] }), api: sdkShim.PLUGIN_API_VERSION, keys: Object.keys(sdkShim).sort(), testingError };\n'); + const req = createRequire(path.join(tmp, 'entry.cjs')); + const mod = req(path.join(tmp, 'entry.cjs')) as { def: unknown; api: number; keys: string[]; testingError: string }; + expect(mod.def).toEqual({ routes: [] }); + expect(mod.api).toBe(1); + expect(mod.keys).toEqual(['PLUGIN_API_VERSION', 'definePlugin']); // prod parity — no dev-only extras + expect(mod.testingError).toMatch(/build\/test-time/); + }); +}); + describe('reference plugin (examples/koffi)', () => { const dir = path.resolve(import.meta.dirname, '..', 'examples', 'koffi'); diff --git a/plugin-sdk/tsconfig.cjs.json b/plugin-sdk/tsconfig.cjs.json new file mode 100644 index 000000000..13f45b990 --- /dev/null +++ b/plugin-sdk/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "dist/cjs" + }, + "include": ["src/index.ts", "src/manifest.ts", "src/mock-host.ts"] +} diff --git a/server/src/nest/plugins/runtime/plugin-host-entry.ts b/server/src/nest/plugins/runtime/plugin-host-entry.ts index 21faf89b6..4c06cec1c 100644 --- a/server/src/nest/plugins/runtime/plugin-host-entry.ts +++ b/server/src/nest/plugins/runtime/plugin-host-entry.ts @@ -15,7 +15,7 @@ import net from 'node:net'; import dns from 'node:dns'; import dgram from 'node:dgram'; import { createRequire } from 'node:module'; -import { createPluginContext, type ChildTransport, type PluginContext, type PluginDefinition } from './plugin-sdk'; +import { createPluginContext, definePlugin, PLUGIN_API_VERSION, type ChildTransport, type PluginContext, type PluginDefinition } from './plugin-sdk'; import { isBlockedIp, makeHostAllow, classifyConnect, dgramSendTarget, dgramConnectTarget } from './egress-policy'; import type { Envelope, RpcError } from '../protocol/envelope'; @@ -47,12 +47,37 @@ const transport: ChildTransport = { let def: PluginDefinition | null = null; let ctx: PluginContext | null = null; +/** + * Make `require('trek-plugin-sdk')` resolve inside the child WITHOUT the plugin + * vendoring the package: the shim below is served from memory for every require + * of that name, anywhere in the plugin's module graph. This is what lets `pack` + * strip node_modules and still keep the scaffold's + * `const { definePlugin } = require('trek-plugin-sdk')` working in production. + * Subpaths (`trek-plugin-sdk/testing`) are build/test-time tools and fail with a + * pointed message instead of a confusing MODULE_NOT_FOUND. + */ +function installSdkInjection(requirePlugin: NodeJS.Require): void { + const nodeModule = requirePlugin('node:module') as { + _load: (request: string, parent: unknown, isMain: boolean) => unknown; + }; + const realLoad = nodeModule._load; + const shim = Object.freeze({ definePlugin, PLUGIN_API_VERSION }); + nodeModule._load = function (request: string, parent: unknown, isMain: boolean): unknown { + if (request === 'trek-plugin-sdk') return shim; + if (request.startsWith('trek-plugin-sdk/')) { + throw new Error(`${request} is a build/test-time module — only 'trek-plugin-sdk' itself is injected inside TREK`); + } + return realLoad.call(this, request, parent, isMain); + }; +} + async function boot(config: Record): Promise { try { // createRequire works whether this bootstrap runs as CJS (prod dist) or ESM // (tsx in tests), so `require` being undefined in ESM never bites us. const entry = path.join(pluginDir, 'server', 'index.js'); const requirePlugin = createRequire(entry); + installSdkInjection(requirePlugin); const mod = requirePlugin(entry); def = mod && mod.default ? (mod.default as PluginDefinition) : (mod as PluginDefinition); ctx = createPluginContext(pluginId, config, transport); diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts index f10104ed9..f26584f52 100644 --- a/server/src/nest/plugins/runtime/plugin-sdk.ts +++ b/server/src/nest/plugins/runtime/plugin-sdk.ts @@ -8,6 +8,9 @@ * child holds no db handle, no secrets, no network by default. */ +/** Mirrors the published package's constant — bumped on any breaking API change. */ +export const PLUGIN_API_VERSION = 1 as const; + export interface PluginContext { readonly id: string; readonly config: Readonly>; diff --git a/server/tests/integration/plugins/supervisor.test.ts b/server/tests/integration/plugins/supervisor.test.ts index 87839b8a3..74ef53a48 100644 --- a/server/tests/integration/plugins/supervisor.test.ts +++ b/server/tests/integration/plugins/supervisor.test.ts @@ -92,6 +92,33 @@ describe('PluginSupervisor — isolated runtime', () => { expect(events.some((e) => e.topic === '__log')).toBe(true); }); + it('injects require(trek-plugin-sdk) — a scaffold-style plugin loads with no node_modules', async () => { + const events: Array<{ topic: string; data: unknown }> = []; + sup = makeSupervisor(events); + + writePlugin( + 'scaffolded', + `const { definePlugin, PLUGIN_API_VERSION } = require('trek-plugin-sdk'); + let testingError = ''; + try { require('trek-plugin-sdk/testing'); } catch (e) { testingError = e.message; } + module.exports = definePlugin({ + async onLoad() { + process.send({ k: 'evt', topic: 'diag', data: { api: PLUGIN_API_VERSION, fn: typeof definePlugin, testingError } }); + }, + routes: [{ method: 'GET', path: '/hello', auth: true, async handler() { return { status: 200 }; } }], + });`, + ); + + await sup.activate('scaffolded', new Set(['db:own']), {}); + expect(sup.isActive('scaffolded')).toBe(true); + + const diag = events.find((e) => e.topic === 'diag')?.data as { api: number; fn: string; testingError: string }; + expect(diag).toBeTruthy(); + expect(diag.api).toBe(1); // the injected shim, not a vendored copy + expect(diag.fn).toBe('function'); + expect(diag.testingError).toMatch(/build\/test-time/); // subpaths fail with a pointed message + }); + it('a plugin that throws in onLoad fails activation and is marked error — the host survives', async () => { const events: Array<{ topic: string; data: unknown }> = []; sup = makeSupervisor(events); diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index 484b5fb9e..51bd3afd5 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -22,6 +22,7 @@ This emits: ``` my-plugin/ trek-plugin.json # manifest + package.json # CommonJS marker + the SDK as a devDependency server/index.js # your plugin code (built, plain JS) client/index.html # page/widget iframe (page/widget only) README.md # fill this in — the registry requires a screenshot @@ -33,8 +34,11 @@ my-plugin/ npx trek-plugin-sdk dev # http://localhost:4317 ``` -`dev` loads your `server/index.js` through the same `definePlugin` contract the -host uses and gives you a **real request loop without a full TREK**: a dashboard +`dev` works straight after `create` — no `npm install` needed, because it +injects `require('trek-plugin-sdk')` from the CLI itself, exactly like TREK +injects it in production. It loads your `server/index.js` through the same +`definePlugin` contract the host uses and gives you a **real request loop +without a full TREK**: a dashboard listing your routes, the routes served under `/api/`, your page/widget UI at `/ui`, and a reload on every save. The injected `ctx` **enforces exactly the permissions your manifest grants** — an ungranted call throws `PERMISSION_DENIED`, diff --git a/wiki/Plugin-Publishing.md b/wiki/Plugin-Publishing.md index 13a303e3a..a37a105c3 100644 --- a/wiki/Plugin-Publishing.md +++ b/wiki/Plugin-Publishing.md @@ -38,6 +38,7 @@ npx trek-plugin-sdk create flight-tracker --type widget # integration | page | A publishable plugin has, at the repo root: - `trek-plugin.json` — the manifest (see [[Plugin Development|Plugin-Development]]) +- `package.json` — the CommonJS marker (`"type": "commonjs"`), with the SDK as a devDependency at most - `server/index.js` — the built server entry (required) - `client/` — the built frontend (only for `page`/`widget` plugins) - `README.md` — filled in, with a real screenshot (the quality gate is strict — see below)