diff --git a/sdk/package.json b/sdk/package.json index 6a3e85cf..930226f6 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@punktfunk/host", - "version": "0.1.0", + "version": "0.1.1", "description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.", "type": "module", "license": "MIT OR Apache-2.0", diff --git a/sdk/src/runner.ts b/sdk/src/runner.ts index 8e3ffe18..fb58c965 100644 --- a/sdk/src/runner.ts +++ b/sdk/src/runner.ts @@ -92,19 +92,40 @@ export const discoverUnits = ( // no scripts dir — fine } const modules = path.join(pluginsDir, "node_modules"); + // Read a plugin package's manifest (`module`/`main` entry) and add it as a unit. + const addPlugin = (dir: string, name: string): void => { + try { + const manifest = JSON.parse( + fs.readFileSync(path.join(dir, "package.json"), "utf8"), + ) as { main?: string; module?: string }; + const rel = manifest.module ?? manifest.main ?? "index.js"; + const file = path.join(dir, rel); + if (!fileIsSafe(file, log)) return; + units.push({ name, file }); + } catch (e) { + log(`[runner] skipping ${name}: unreadable package.json (${e})`); + } + }; try { for (const pkg of fs.readdirSync(modules).sort()) { - if (!pkg.startsWith("punktfunk-plugin-")) continue; - try { - const manifest = JSON.parse( - fs.readFileSync(path.join(modules, pkg, "package.json"), "utf8"), - ) as { main?: string; module?: string }; - const rel = manifest.module ?? manifest.main ?? "index.js"; - const file = path.join(modules, pkg, rel); - if (!fileIsSafe(file, log)) continue; - units.push({ name: pkg, file }); - } catch (e) { - log(`[runner] skipping ${pkg}: unreadable package.json (${e})`); + // Unscoped convention: `punktfunk-plugin-*`. + if (pkg.startsWith("punktfunk-plugin-")) { + addPlugin(path.join(modules, pkg), pkg); + continue; + } + // Scoped convention: `@punktfunk/plugin-*` (first-party). A scoped name resolves cleanly + // from a single registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect` + // as shared (hoisted) deps rather than bundling its own copy of each. + if (pkg === "@punktfunk") { + try { + for (const scoped of fs.readdirSync(path.join(modules, pkg)).sort()) { + if (scoped.startsWith("plugin-")) { + addPlugin(path.join(modules, pkg, scoped), `${pkg}/${scoped}`); + } + } + } catch { + // no @punktfunk scope dir — fine + } } } } catch { diff --git a/sdk/test/runner.test.ts b/sdk/test/runner.test.ts index 3b9bad82..77a8d55c 100644 --- a/sdk/test/runner.test.ts +++ b/sdk/test/runner.test.ts @@ -83,6 +83,25 @@ describe("discovery", () => { expect(logs.join("\n")).toContain("REFUSING"); expect(logs.join("\n")).toContain("evil.ts"); }); + + test("discovers scoped @punktfunk/plugin-* packages (not other scoped pkgs)", () => { + const d = mkdirs("discover-scoped"); + write( + path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-y", "package.json"), + JSON.stringify({ name: "@punktfunk/plugin-y", module: "dist/index.js" }), + ); + write( + path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-y", "dist", "index.js"), + "export default { name: 'y', main: async () => {} };", + ); + // A non-plugin scoped package (e.g. the SDK itself) is ignored. + write( + path.join(d.pluginsDir, "node_modules", "@punktfunk", "host", "package.json"), + JSON.stringify({ name: "@punktfunk/host" }), + ); + const units = discoverUnits(d); + expect(units.map((u) => u.name)).toEqual(["@punktfunk/plugin-y"]); + }); }); describe("supervision", () => {