fluxplugin 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,13 +38,25 @@ export default definePlugin({ import { usePluginIPC } from
38
38
  ```
39
39
  More: [ARCHITECTURE](docs/ARCHITECTURE.md) · [SECURITY](docs/SECURITY.md) · [PLUGIN-AUTHORS](docs/PLUGIN-AUTHORS.md) · [RELEASE](docs/RELEASE.md)
40
40
 
41
+ ## What `fluxplugin init` generates
42
+ All application code lives in `src/app`, in layers (`shared` → `main` / `preload` / `renderer`):
43
+ ```
44
+ src/app/
45
+ shared/ constants.ts · types.ts (depends on nothing)
46
+ main/ index.ts (Electron) · web.ts (browser) · framework.ts · operations.ts · window.ts · paths.ts
47
+ preload/ index.ts
48
+ renderer/ index.tsx · bootstrap.ts · extension-points.ts · routes.ts · App.tsx
49
+ providers/ · shell/ · pages/ · hooks/ · services/ · styles/
50
+ plugins/ loaded at startup
51
+ ```
52
+
41
53
  ## Status — read this
42
- * **Verified:** 121 automated tests; typecheck strict; the packed tarball installed into a *freshly generated app*
43
- (`init` → `npm install` → typecheck → `build` → run in browser mode); a Playwright E2E against that app with
44
- 3 plugins (menus, slots, extension points, hooks, validated IPC, push events, live enable/disable, palette, manager UI);
45
- `plugin new` + `build` hot-loads into a running app.
46
- * **Not verified:** a real Electron binary (unavailable in the build sandbox). `main.ts`/`preload.ts` templates and
47
- `attachElectron` follow the Electron API and are tested against fakes — expect small adjustments on first `npm run dev`.
48
- * **Not published:** the package is not on npm. Run the steps in `docs/RELEASE.md`; check the name is free first.
49
- * **Security:** plugin `main` code is not sandboxed (permissions are an API contract). Read `docs/SECURITY.md`.
50
- * **Not done:** per-plugin process isolation, hosted marketplace (only the `PluginRegistry` abstraction + local/HTTP), Tailwind wiring in the starter.
54
+ * **Verified:** 121 tests; strict typecheck; the packed tarball installed into a freshly generated app
55
+ (`init` → `npm install` → typecheck → `build`); the generated app run **in real Electron 33** (under Xvfb, Linux) through
56
+ `fluxplugin dev`: window loads, preload bridge present, **no Node globals leak into the page**, plugin menus, hook-wrapped
57
+ host operation, live plugin disable — and in browser mode with a Playwright E2E (3 plugins).
58
+ * **Not verified:** Windows and macOS (only Linux was available), packaging an installer (electron-builder/forge is not set up).
59
+ * **Linux note:** if Electron's sandbox helper is not setuid-root (or you run as root) `fluxplugin dev` starts it with
60
+ `--no-sandbox` and tells you how to fix it. Extra flags: `FLUXPLUGIN_ELECTRON_ARGS="--remote-debugging-port=9222"`.
61
+ * **Not published** on npm — see `docs/RELEASE.md`. **Security:** plugin `main` code is not sandboxed (see `docs/SECURITY.md`).
62
+ * **Not done:** per-plugin process isolation, hosted marketplace (only `PluginRegistry` + local/HTTP), Tailwind wiring.
package/dist/cli.js CHANGED
@@ -892,7 +892,7 @@ async function buildPlugin(root, watch = false, log2 = console.log) {
892
892
 
893
893
  // src/cli/host.ts
894
894
  import { spawn } from "node:child_process";
895
- import { promises as fs7 } from "node:fs";
895
+ import { existsSync, promises as fs7, statSync } from "node:fs";
896
896
  import { createRequire } from "node:module";
897
897
  import path8 from "node:path";
898
898
  import { build as build2, context as context2 } from "esbuild";
@@ -915,17 +915,25 @@ async function loadConfig(flags, cwd = process.cwd()) {
915
915
  // src/cli/host.ts
916
916
  var exists2 = (f) => fs7.access(f).then(() => true, () => false);
917
917
  async function isHostProject(cwd) {
918
- return await exists2(path8.join(cwd, "fluxplugin.config.json")) || await exists2(path8.join(cwd, "src/renderer.tsx")) && await exists2(path8.join(cwd, "src/framework.ts"));
918
+ if (await exists2(path8.join(cwd, "fluxplugin.config.json"))) return true;
919
+ return await exists2(path8.join(cwd, "src/app/renderer/index.tsx")) || await exists2(path8.join(cwd, "src/renderer.tsx"));
919
920
  }
920
921
  var nodeBanner = { js: "import { createRequire as __r } from 'node:module'; const require = __r(import.meta.url);" };
922
+ var pick = (cwd, ...rels) => {
923
+ for (const r of rels) if (existsSync(path8.join(cwd, r))) return path8.join(cwd, r);
924
+ return path8.join(cwd, rels[0]);
925
+ };
921
926
  function hostBuilds(cwd, outDir, dev) {
922
927
  const common = { absWorkingDir: cwd, bundle: true, sourcemap: dev, logLevel: "warning", jsx: "automatic", define: { "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production") } };
923
- const entry = (f) => path8.join(cwd, "src", f);
928
+ const renderer = pick(cwd, "src/app/renderer/index.tsx", "src/renderer.tsx");
929
+ const main = pick(cwd, "src/app/main/index.ts", "src/main.ts");
930
+ const preload = pick(cwd, "src/app/preload/index.ts", "src/preload.ts");
931
+ const web = pick(cwd, "src/app/main/web.ts", "src/web.ts");
924
932
  return [
925
- { ...common, entryPoints: [entry("renderer.tsx")], outfile: path8.join(outDir, "renderer.js"), platform: "browser", format: "esm", target: "chrome120" },
926
- { ...common, entryPoints: [entry("main.ts")], outfile: path8.join(outDir, "main.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
927
- { ...common, entryPoints: [entry("preload.ts")], outfile: path8.join(outDir, "preload.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
928
- { ...common, entryPoints: [entry("web.ts")], outfile: path8.join(outDir, "web.mjs"), platform: "node", format: "esm", target: "node20", external: ["electron"], banner: nodeBanner }
933
+ { ...common, entryPoints: [renderer], outfile: path8.join(outDir, "renderer.js"), platform: "browser", format: "esm", target: "chrome120" },
934
+ { ...common, entryPoints: [main], outfile: path8.join(outDir, "main.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
935
+ { ...common, entryPoints: [preload], outfile: path8.join(outDir, "preload.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
936
+ { ...common, entryPoints: [web], outfile: path8.join(outDir, "web.mjs"), platform: "node", format: "esm", target: "node20", external: ["electron"], banner: nodeBanner }
929
937
  ];
930
938
  }
931
939
  async function pluginDirs(cwd, pluginsDir) {
@@ -941,11 +949,12 @@ async function buildHost(cwd, opts = {}) {
941
949
  const cfg = await loadConfig({}, cwd);
942
950
  const outDir = path8.resolve(cwd, cfgFile.outDir ?? "dist");
943
951
  await fs7.mkdir(outDir, { recursive: true });
944
- await fs7.copyFile(path8.join(cwd, "src/index.html"), path8.join(outDir, "index.html"));
952
+ await fs7.copyFile(pick(cwd, "src/app/renderer/index.html", "src/index.html"), path8.join(outDir, "index.html"));
945
953
  const disposers = [];
946
954
  const opts2 = hostBuilds(cwd, outDir, !!opts.watch);
947
955
  if (opts.watch) {
948
956
  const ctxs = await Promise.all(opts2.map((o) => context2(o)));
957
+ await Promise.all(ctxs.map((c) => c.rebuild()));
949
958
  await Promise.all(ctxs.map((c) => c.watch()));
950
959
  disposers.push(async () => void await Promise.all(ctxs.map((c) => c.dispose())));
951
960
  } else for (const o of opts2) await build2(o);
@@ -962,17 +971,115 @@ async function buildHost(cwd, opts = {}) {
962
971
  function electronBinary(cwd) {
963
972
  try {
964
973
  const p = createRequire(path8.join(cwd, "package.json"))("electron");
965
- return typeof p === "string" ? p : void 0;
974
+ if (typeof p === "string" && existsSync(p)) return { bin: p };
975
+ return { error: "the Electron binary was not found" };
976
+ } catch (e) {
977
+ return { error: e.message.split("\n")[0] };
978
+ }
979
+ }
980
+ function needsNoSandbox(bin) {
981
+ if (process.platform !== "linux") return false;
982
+ if (process.getuid?.() === 0) return true;
983
+ try {
984
+ const st = statSync(path8.join(path8.dirname(bin), "chrome-sandbox"));
985
+ return !(st.uid === 0 && (st.mode & 2048) !== 0);
966
986
  } catch {
967
- return void 0;
987
+ return false;
968
988
  }
969
989
  }
970
990
  function startApp(cwd, opts = {}) {
971
- const bin = opts.web ? void 0 : electronBinary(cwd);
991
+ const log2 = opts.log ?? console.log;
972
992
  const env = { ...process.env, FLUXPLUGIN_PROJECT: cwd, ...opts.dev ? { FLUXPLUGIN_DEV: "1" } : {}, ...opts.port ? { PORT: String(opts.port) } : {} };
973
- if (bin) return { child: spawn(bin, [path8.join(cwd, "dist/main.cjs")], { cwd, env, stdio: "inherit" }), mode: "electron" };
974
- return { child: spawn(process.execPath, [path8.join(cwd, "dist/web.mjs")], { cwd, env, stdio: "inherit" }), mode: "web" };
993
+ if (opts.web) return { child: spawn(process.execPath, [path8.join(cwd, "dist/web.mjs")], { cwd, env, stdio: "inherit" }), mode: "web" };
994
+ const { bin, error } = electronBinary(cwd);
995
+ if (!bin) throw new Error(`cannot start Electron: ${error}.
996
+ Install it with: npm install -D electron (then re-run)
997
+ or run in a browser: fluxplugin ${opts.dev ? "dev" : "start"} --web`);
998
+ const args = [...process.env["FLUXPLUGIN_ELECTRON_ARGS"]?.split(" ").filter(Boolean) ?? [], path8.join(cwd, "dist/main.cjs")];
999
+ if (needsNoSandbox(bin)) {
1000
+ args.unshift("--no-sandbox");
1001
+ log2("! Linux: Electron's sandbox helper is not configured (or you run as root) \u2192 starting with --no-sandbox.\n To fix permanently: sudo chown root node_modules/electron/dist/chrome-sandbox && sudo chmod 4755 node_modules/electron/dist/chrome-sandbox");
1002
+ }
1003
+ const started = Date.now();
1004
+ const child = spawn(bin, args, { cwd, env, stdio: "inherit" });
1005
+ child.on("exit", (code) => {
1006
+ if (code && Date.now() - started < 5e3) log2(`Electron exited with code ${code} right after starting. Scroll up for its error, or try: fluxplugin dev --web`);
1007
+ });
1008
+ return { child, mode: "electron" };
1009
+ }
1010
+
1011
+ // src/cli/host-files.generated.ts
1012
+ var HOST_APP_FILES = {
1013
+ "src/app/main/framework.ts": `import { MainFramework } from "fluxplugin/main";
1014
+ import { CHANNELS } from "../shared/constants";
1015
+ import { registerOperations } from "./operations";
1016
+
1017
+ export interface FrameworkOptions {
1018
+ pluginDirs: string[];
1019
+ dataDir: string;
1020
+ dev?: boolean;
1021
+ assetUrl?: (id: string, rel: string) => string;
1022
+ }
1023
+
1024
+ /** Creates the plugin framework. Shared by the Electron entry (index.ts) and the browser entry (web.ts). */
1025
+ export function createFramework(opts: FrameworkOptions): MainFramework {
1026
+ const fw = new MainFramework({
1027
+ pluginDirs: opts.pluginDirs,
1028
+ dataDir: opts.dataDir,
1029
+ hotReload: opts.dev,
1030
+ assetUrl: opts.assetUrl,
1031
+ requireApproval: false, // true \u2192 users must approve each plugin's permissions
1032
+ allowedRendererChannels: [CHANNELS.createItem],
1033
+ });
1034
+ registerOperations(fw);
1035
+ return fw;
1036
+ }
1037
+ `,
1038
+ "src/app/main/index.ts": '// Electron entry (main process). Keep it thin: wiring only, logic lives in the other files of this folder.\nimport { app, ipcMain, protocol, webContents } from "electron";\nimport path from "node:path";\nimport { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";\nimport { createFramework } from "./framework";\nimport { pluginDirs, projectRoot } from "./paths";\nimport { createMainWindow } from "./window";\n\nprotocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before the app is ready\n\napp.whenReady().then(async () => {\n const fw = createFramework({\n pluginDirs: pluginDirs(projectRoot(__dirname), app.getPath("userData")),\n dataDir: path.join(app.getPath("userData"), "plugins-data"),\n dev: !!process.env["FLUXPLUGIN_DEV"],\n });\n registerPluginProtocol(fw, protocol);\n attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });\n await fw.start();\n createMainWindow(__dirname);\n app.on("before-quit", () => void fw.stop());\n});\n\napp.on("window-all-closed", () => app.quit());\n',
1039
+ "src/app/main/operations.ts": 'import { s } from "fluxplugin";\nimport type { MainFramework } from "fluxplugin/main";\nimport { CHANNELS, OPERATIONS } from "../shared/constants";\nimport type { Item } from "../shared/types";\n\n/**\n * Host operations. Each one runs through the hook pipeline, so plugins can validate, normalize or veto it\n * (before.item.create, transform.item.create, ...) without touching this file.\n */\nexport function registerOperations(fw: MainFramework): void {\n fw.manager.ipc.handle(\n CHANNELS.createItem,\n (input: { name: string }) =>\n fw.manager.hooks.run(OPERATIONS.createItem, input, (i) => {\n const item: Item = { id: Math.random().toString(36).slice(2, 8), name: i.name };\n fw.manager.events.emit("item.created", item, "host");\n return item as unknown as Record<string, unknown>;\n }),\n "host",\n { input: s.object({ name: s.string() }) },\n );\n}\n',
1040
+ "src/app/main/paths.ts": 'import path from "node:path";\n\n/** Project root: set by `fluxplugin dev|start`; falls back to the folder above dist/ (packaged app). */\nexport function projectRoot(distDir: string): string {\n return process.env["FLUXPLUGIN_PROJECT"] ?? path.resolve(distDir, "..");\n}\n\n/** Plugins bundled with the app (read-only) and plugins installed by the user (writable). */\nexport function pluginDirs(root: string, userData: string): string[] {\n return [path.join(root, "plugins"), path.join(userData, "plugins")];\n}\n',
1041
+ "src/app/main/web.ts": '// Browser entry: `fluxplugin dev --web` (no Electron needed).\nimport path from "node:path";\nimport { serveWeb } from "fluxplugin/main";\nimport { APP_NAME } from "../shared/constants";\nimport { createFramework } from "./framework";\n\nconst root = process.env["FLUXPLUGIN_PROJECT"] ?? process.cwd();\nconst fw = createFramework({\n pluginDirs: [path.join(root, "plugins")],\n dataDir: path.join(root, ".flux-data"),\n dev: true,\n assetUrl: (id, rel) => "/@plugins/" + id + "/" + rel.replace("./", ""),\n});\nawait fw.start();\nconst srv = await serveWeb(fw, { distDir: path.join(root, "dist"), port: Number(process.env["PORT"] ?? 5173) });\nconsole.log(APP_NAME + " -> http://127.0.0.1:" + srv.port);\n',
1042
+ "src/app/main/window.ts": 'import { BrowserWindow } from "electron";\nimport path from "node:path";\n\nexport function createMainWindow(distDir: string): BrowserWindow {\n const win = new BrowserWindow({\n width: 1100,\n height: 760,\n show: false,\n webPreferences: {\n preload: path.join(distDir, "preload.cjs"),\n contextIsolation: true,\n sandbox: true,\n nodeIntegration: false,\n },\n });\n win.once("ready-to-show", () => win.show());\n // The app never navigates away and never opens new windows.\n win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));\n win.webContents.on("will-navigate", (e) => e.preventDefault());\n void win.loadFile(path.join(distDir, "index.html"));\n return win;\n}\n',
1043
+ "src/app/preload/index.ts": 'import { contextBridge, ipcRenderer } from "electron";\nimport { exposeFluxBridge } from "fluxplugin/preload";\n\n// The only thing renderer code receives from the preload: window.__fluxplugin\nexposeFluxBridge(contextBridge, ipcRenderer);\n',
1044
+ "src/app/renderer/App.tsx": 'import type { FluxBridge } from "fluxplugin";\nimport type { RendererFramework } from "fluxplugin/react";\nimport { AppProviders } from "./providers/AppProviders";\nimport { AppShell } from "./shell/AppShell";\n\nexport function App({ framework, bridge }: { framework: RendererFramework; bridge: FluxBridge }) {\n return (\n <AppProviders framework={framework} bridge={bridge}>\n <AppShell />\n </AppProviders>\n );\n}\n',
1045
+ "src/app/renderer/bootstrap.ts": 'import React from "react";\nimport * as jsxRuntime from "react/jsx-runtime";\nimport * as ReactDOMClient from "react-dom/client";\nimport * as flux from "fluxplugin";\nimport * as fluxReact from "fluxplugin/react";\nimport type { FluxBridge } from "fluxplugin";\nimport { RendererFramework, createHttpBridge, installSharedModules } from "fluxplugin/react";\nimport { declareExtensionPoints } from "./extension-points";\nimport { registerHostRoutes } from "./routes";\n\n/**\n * Infrastructure layer: connects to the main process, shares singletons with plugin bundles,\n * declares what plugins may extend, registers the host\'s own routes, then starts the plugins.\n */\nexport async function bootstrap(): Promise<{ framework: RendererFramework; bridge: FluxBridge }> {\n // One copy of React and of the runtime: plugin bundles import these from globalThis.\n installSharedModules({\n react: React,\n "react/jsx-runtime": jsxRuntime,\n "react/jsx-dev-runtime": jsxRuntime,\n "react-dom/client": ReactDOMClient,\n fluxplugin: flux,\n "fluxplugin/react": fluxReact,\n });\n\n // Electron: the preload bridge. Browser mode: HTTP bridge to `fluxplugin dev --web`.\n const bridge: FluxBridge = (window as unknown as { __fluxplugin?: FluxBridge }).__fluxplugin ?? createHttpBridge();\n const framework = new RendererFramework({ bridge });\n\n declareExtensionPoints(framework); // BEFORE plugins start\n registerHostRoutes(framework);\n await framework.start();\n return { framework, bridge };\n}\n',
1046
+ "src/app/renderer/extension-points.ts": 'import { s } from "fluxplugin";\nimport type { RendererFramework } from "fluxplugin/react";\nimport { EXTENSION_POINTS } from "../shared/constants";\n\n/** Declare what plugins may contribute. Contributions are validated against these schemas. */\nexport function declareExtensionPoints(framework: RendererFramework): void {\n framework.manager.extensionPoints.declare({\n name: EXTENSION_POINTS.dashboard,\n contributions: {\n toolbar: s.object({ id: s.string(), label: s.string(), command: s.string().optional() }),\n },\n });\n}\n',
1047
+ "src/app/renderer/hooks/useCreateItem.ts": 'import { useState } from "react";\nimport { useBridge } from "../providers/AppProviders";\nimport { itemsApi } from "../services/items";\n\n/** Calls the host operation (which plugins may rewrite or veto through hooks) and exposes the outcome. */\nexport function useCreateItem() {\n const bridge = useBridge();\n const [result, setResult] = useState("");\n const create = async (name: string) => {\n try {\n setResult(JSON.stringify(await itemsApi.create(bridge, name)));\n } catch (e) {\n setResult("blocked: " + (e as Error).message);\n }\n };\n return { create, result };\n}\n',
1048
+ "src/app/renderer/index.html": `<!doctype html>
1049
+ <html>
1050
+ <head>
1051
+ <meta charset="utf-8" />
1052
+ <title>__APP_NAME__</title>
1053
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; connect-src 'self'; img-src 'self' data:" />
1054
+ <link rel="stylesheet" href="./renderer.css" />
1055
+ </head>
1056
+ <body>
1057
+ <div id="root"></div>
1058
+ <script type="module" src="./renderer.js"></script>
1059
+ </body>
1060
+ </html>
1061
+ `,
1062
+ "src/app/renderer/index.tsx": '// Renderer entry. Order matters: styles \u2192 bootstrap (infrastructure) \u2192 mount (UI).\nimport "./styles/app.css";\nimport { createRoot } from "react-dom/client";\nimport { bootstrap } from "./bootstrap";\nimport { App } from "./App";\n\nconst { framework, bridge } = await bootstrap();\ncreateRoot(document.getElementById("root")!).render(<App framework={framework} bridge={bridge} />);\n',
1063
+ "src/app/renderer/pages/DashboardPage.tsx": 'import { PluginSlot, useExtensionItems, usePluginFramework } from "fluxplugin/react";\nimport { EXTENSION_POINTS, SLOTS } from "../../shared/constants";\nimport type { ToolbarAction } from "../../shared/types";\nimport { useCreateItem } from "../hooks/useCreateItem";\n\nexport function DashboardPage() {\n const framework = usePluginFramework();\n const actions = useExtensionItems<ToolbarAction>(EXTENSION_POINTS.dashboard, "toolbar");\n const { create, result } = useCreateItem();\n return (\n <div>\n <h2>Dashboard</h2>\n <PluginSlot name={SLOTS.dashboardBefore} />\n <p className="hint">These buttons are contributed by plugins through a host-declared extension point.</p>\n <div className="row" data-testid="toolbar">\n {actions.map((a) => (\n <button key={a.id} className="btn" onClick={() => a.command && void framework.manager.commands.execute(a.command)}>\n {a.label}\n </button>\n ))}\n </div>\n <div className="row">\n <button className="btn" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>\n <button className="btn" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>\n </div>\n <pre data-testid="hook-result" className="hint">{result}</pre>\n <PluginSlot name={SLOTS.dashboardAfter} />\n </div>\n );\n}\n',
1064
+ "src/app/renderer/pages/PluginsPage.tsx": 'import { PluginManagerUI } from "fluxplugin/manager-ui";\n\n/** Ready-made plugin manager (enable/disable, permissions, settings, logs, install). Replace with your own UI if you like. */\nexport function PluginsPage() {\n return <PluginManagerUI />;\n}\n',
1065
+ "src/app/renderer/providers/AppProviders.tsx": 'import { createContext, useContext, type ReactNode } from "react";\nimport type { FluxBridge } from "fluxplugin";\nimport { PluginProvider, StyleHost, baseCss, type RendererFramework } from "fluxplugin/react";\n\nconst BridgeContext = createContext<FluxBridge | null>(null);\n\n/** Gives pages access to the main-process bridge without importing globals. */\nexport function useBridge(): FluxBridge {\n const b = useContext(BridgeContext);\n if (!b) throw new Error("useBridge must be used inside <AppProviders>");\n return b;\n}\n\nexport function AppProviders({ framework, bridge, children }: { framework: RendererFramework; bridge: FluxBridge; children: ReactNode }) {\n return (\n <PluginProvider framework={framework}>\n <BridgeContext.Provider value={bridge}>\n <style>{baseCss}</style>\n <StyleHost />\n {children}\n </BridgeContext.Provider>\n </PluginProvider>\n );\n}\n',
1066
+ "src/app/renderer/routes.ts": `import type { RendererFramework } from "fluxplugin/react";
1067
+ import { DashboardPage } from "./pages/DashboardPage";
1068
+ import { PluginsPage } from "./pages/PluginsPage";
1069
+
1070
+ /** The host's own pages (owner "host"). Plugins add theirs at runtime through the same registry. */
1071
+ export function registerHostRoutes(framework: RendererFramework): void {
1072
+ framework.manager.routes.register({ path: "/", component: DashboardPage, meta: { title: "Dashboard" } }, "host");
1073
+ framework.manager.routes.register({ path: "/plugins", component: PluginsPage, meta: { title: "Plugins", breadcrumb: "Plugins" } }, "host");
975
1074
  }
1075
+ `,
1076
+ "src/app/renderer/services/items.ts": 'import type { FluxBridge } from "fluxplugin";\nimport { CHANNELS } from "../../shared/constants";\nimport type { Item } from "../../shared/types";\n\n/** Service layer: the only place that knows the IPC channel names. UI code calls these functions. */\nexport const itemsApi = {\n create: (bridge: FluxBridge, name: string) => bridge.invoke(CHANNELS.createItem, { name }) as Promise<Item>,\n};\n',
1077
+ "src/app/renderer/shell/AppShell.tsx": 'import { Breadcrumbs, CommandPalette, PluginRoutes, useKeybindings } from "fluxplugin/react";\nimport { Sidebar } from "./Sidebar";\n\n/** Layout of the whole window: sidebar + routed content + command palette. */\nexport function AppShell() {\n useKeybindings(); // runs commands bound to keyboard shortcuts (Mod+Shift+R, ...)\n return (\n <div className="app">\n <Sidebar />\n <main className="main">\n <Breadcrumbs />\n <PluginRoutes />\n </main>\n <CommandPalette />\n </div>\n );\n}\n',
1078
+ "src/app/renderer/shell/Sidebar.tsx": 'import { Link, PluginSlot, useMenu } from "fluxplugin/react";\nimport { APP_NAME, SLOTS } from "../../shared/constants";\n\nexport function Sidebar() {\n const { items, run } = useMenu("sidebar"); // entries added by plugins (routes with meta.menu, or menus.register)\n return (\n <aside className="side">\n <h1>\n {APP_NAME} <PluginSlot name={SLOTS.sidebarHeader} />\n </h1>\n <Link to="/">Dashboard</Link>\n {items.map((i) => (\n <a key={i.id} href={"#" + i.path} onClick={(e) => (e.preventDefault(), run(i))}>\n {i.label}\n </a>\n ))}\n <Link to="/plugins">Plugins</Link>\n <p className="hint">Ctrl/Cmd+K: commands</p>\n </aside>\n );\n}\n',
1079
+ "src/app/renderer/styles/app.css": "body { margin: 0; font: 14px system-ui; background: var(--flux-bg); color: var(--flux-fg); }\n.app { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }\n.side { border-right: 1px solid var(--flux-border); padding: 16px; background: var(--flux-surface); }\n.side h1 { font-size: 16px; margin: 0 0 14px; }\n.side a { display: block; padding: 7px 10px; border-radius: 8px; color: inherit; text-decoration: none; margin: 2px 0; }\n.side a[data-active] { background: var(--flux-accent); color: #fff; }\n.main { padding: 24px; max-width: 860px; }\n.row { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }\n.btn { border: 1px solid var(--flux-border); background: var(--flux-bg); color: inherit; border-radius: 8px; padding: 6px 12px; cursor: pointer; }\n.hint { color: var(--flux-muted); font-size: 12px; }\n",
1080
+ "src/app/shared/constants.ts": '/** Names shared by the main process and the renderer. No imports here: this layer depends on nothing. */\nexport const APP_NAME = "__APP_NAME__";\n\n/** IPC channels the host exposes to the renderer (also allow-listed in main/framework.ts). */\nexport const CHANNELS = {\n createItem: "host.item.create",\n} as const;\n\n/** Declarative extension points the host offers to plugins (see renderer/extension-points.ts). */\nexport const EXTENSION_POINTS = {\n dashboard: "dashboard",\n} as const;\n\n/** UI slots where plugins can inject components (see <PluginSlot name=... />). */\nexport const SLOTS = {\n sidebarHeader: "header.badges",\n dashboardBefore: "dashboard.before",\n dashboardAfter: "dashboard.after",\n} as const;\n\n/** Hook pipeline operations plugins can intercept: before./after./transform./validate.<operation>. */\nexport const OPERATIONS = {\n createItem: "item.create",\n} as const;\n',
1081
+ "src/app/shared/types.ts": 'export interface Item {\n id: string;\n name: string;\n}\n\n/** Shape of a contribution to the "dashboard" extension point (validated in renderer/extension-points.ts). */\nexport interface ToolbarAction {\n id: string;\n label: string;\n command?: string;\n}\n'
1082
+ };
976
1083
 
977
1084
  // src/cli/templates.ts
978
1085
  var pkg = (v) => JSON.stringify(
@@ -1201,113 +1308,9 @@ function hostFiles(v, withSample = true) {
1201
1308
  2
1202
1309
  ),
1203
1310
  ".gitignore": "node_modules\ndist\n.flux-data\n*.tgz\n",
1204
- "src/index.html": `<!doctype html>
1205
- <html><head><meta charset="utf-8"><title>${v.name}</title>
1206
- <meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; connect-src 'self'; img-src 'self' data:">
1207
- <style>body{margin:0;font:14px system-ui}</style></head>
1208
- <body><div id="root"></div><script type="module" src="./renderer.js"></script></body></html>
1209
- `,
1210
- "src/framework.ts": `import { MainFramework } from "fluxplugin/main";
1211
- import { s } from "fluxplugin";
1212
-
1213
- /** Shared by Electron (main.ts) and browser mode (web.ts). Put host-side setup here. */
1214
- export function createFramework(opts: { pluginDirs: string[]; dataDir: string; dev?: boolean; assetUrl?: (id: string, rel: string) => string }) {
1215
- const fw = new MainFramework({
1216
- pluginDirs: opts.pluginDirs,
1217
- dataDir: opts.dataDir,
1218
- hotReload: opts.dev,
1219
- assetUrl: opts.assetUrl,
1220
- requireApproval: false, // set true to make users approve each plugin's permissions
1221
- allowedRendererChannels: ["host.item.create"],
1222
- });
1223
-
1224
- // A host operation wrapped in the hook pipeline: plugins can validate, normalize or veto it
1225
- // (before./transform./after.item.create) without changing this file.
1226
- fw.manager.ipc.handle(
1227
- "host.item.create",
1228
- (input: { name: string }) =>
1229
- fw.manager.hooks.run("item.create", input, (i) => {
1230
- const item = { id: Math.random().toString(36).slice(2, 8), name: i.name };
1231
- fw.manager.events.emit("item.created", item, "host");
1232
- return item as Record<string, unknown>;
1233
- }),
1234
- "host",
1235
- { input: s.object({ name: s.string() }) },
1236
- );
1237
- return fw;
1238
- }
1239
- `,
1240
- "src/main.ts": `// Electron main process.
1241
- import { app, BrowserWindow, ipcMain, protocol, webContents } from "electron";
1242
- import path from "node:path";
1243
- import { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";
1244
- import { createFramework } from "./framework.js";
1245
-
1246
- protocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before app is ready
1247
-
1248
- app.whenReady().then(async () => {
1249
- const dev = !!process.env["FLUXPLUGIN_DEV"];
1250
- const root = process.env["FLUXPLUGIN_PROJECT"] ?? path.resolve(__dirname, "..");
1251
- const fw = createFramework({
1252
- pluginDirs: [path.join(root, "plugins"), path.join(app.getPath("userData"), "plugins")], // bundled + user-installed
1253
- dataDir: path.join(app.getPath("userData"), "plugins-data"),
1254
- dev,
1255
- });
1256
- registerPluginProtocol(fw, protocol);
1257
- attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });
1258
- await fw.start();
1259
-
1260
- const win = new BrowserWindow({
1261
- width: 1100,
1262
- height: 760,
1263
- webPreferences: { preload: path.join(__dirname, "preload.cjs"), contextIsolation: true, sandbox: true, nodeIntegration: false },
1264
- });
1265
- win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
1266
- win.webContents.on("will-navigate", (e) => e.preventDefault());
1267
- await win.loadFile(path.join(__dirname, "index.html"));
1268
- app.on("before-quit", () => void fw.stop());
1269
- });
1270
- app.on("window-all-closed", () => app.quit());
1271
- `,
1272
- "src/preload.ts": `import { contextBridge, ipcRenderer } from "electron";
1273
- import { exposeFluxBridge } from "fluxplugin/preload";
1274
-
1275
- exposeFluxBridge(contextBridge, ipcRenderer); // the only thing renderer code receives: window.__fluxplugin
1276
- `,
1277
- "src/web.ts": `// Browser mode (no Electron): \`fluxplugin dev --web\`.
1278
- import path from "node:path";
1279
- import { serveWeb } from "fluxplugin/main";
1280
- import { createFramework } from "./framework.js";
1281
-
1282
- const root = process.env["FLUXPLUGIN_PROJECT"] ?? process.cwd();
1283
- const fw = createFramework({
1284
- pluginDirs: [path.join(root, "plugins")],
1285
- dataDir: path.join(root, ".flux-data"),
1286
- dev: true,
1287
- assetUrl: (id, rel) => \`/@plugins/\${id}/\${rel.replace(/^\\.\\//, "")}\`,
1288
- });
1289
- await fw.start();
1290
- const srv = await serveWeb(fw, { distDir: path.join(root, "dist"), port: Number(process.env["PORT"] ?? 5173) });
1291
- console.log(\`${v.name} \u2192 http://127.0.0.1:\${srv.port}\`);
1292
- `,
1293
- "src/renderer.tsx": renderer(v.name),
1294
- "README.md": `# ${v.name}
1295
-
1296
- Created with [fluxplugin](https://www.npmjs.com/package/fluxplugin).
1297
-
1298
- \`\`\`bash
1299
- npm install
1300
- npm run dev:web # in a browser, no Electron needed
1301
- npm run dev # in Electron (hot reload for plugins)
1302
- npm run build && npm start
1303
- npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
1304
- \`\`\`
1305
-
1306
- * \`src/renderer.tsx\` \u2014 your React UI. \`<PluginSlot name="\u2026"/>\` and extension points are where plugins plug in.
1307
- * \`src/framework.ts\` \u2014 host-side setup (permissions policy, host operations exposed to plugins, hooks).
1308
- * \`plugins/\` \u2014 plugins loaded at startup; users can also install \`.tgz\` plugins from the Plugins page.
1309
- `
1311
+ "README.md": readme(v.name)
1310
1312
  };
1313
+ for (const [rel, content] of Object.entries(HOST_APP_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name);
1311
1314
  if (withSample) {
1312
1315
  const sample = TEMPLATES.dashboard(varsFromName("hello"));
1313
1316
  for (const [rel, content] of Object.entries(sample)) {
@@ -1317,97 +1320,35 @@ npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
1317
1320
  }
1318
1321
  return files;
1319
1322
  }
1320
- var renderer = (app) => `import React from "react";
1321
- import { createRoot } from "react-dom/client";
1322
- import * as jsxRuntime from "react/jsx-runtime";
1323
- import * as ReactDOMClient from "react-dom/client";
1324
- import * as flux from "fluxplugin";
1325
- import * as fluxReact from "fluxplugin/react";
1326
- import { s, type FluxBridge } from "fluxplugin";
1327
- import {
1328
- Breadcrumbs, CommandPalette, Link, PluginProvider, PluginRoutes, PluginSlot, RendererFramework, StyleHost,
1329
- baseCss, createHttpBridge, installSharedModules, useExtensionItems, useKeybindings, useMenu,
1330
- } from "fluxplugin/react";
1331
- import { PluginManagerUI } from "fluxplugin/manager-ui";
1332
-
1333
- // One copy of React and of the runtime, shared with plugin bundles (they import these from globalThis).
1334
- installSharedModules({
1335
- react: React, "react/jsx-runtime": jsxRuntime, "react/jsx-dev-runtime": jsxRuntime, "react-dom/client": ReactDOMClient,
1336
- fluxplugin: flux, "fluxplugin/react": fluxReact,
1337
- });
1338
-
1339
- // Electron: the preload bridge. Browser mode: HTTP bridge to \`fluxplugin dev --web\`.
1340
- const bridge: FluxBridge = (window as unknown as { __fluxplugin?: FluxBridge }).__fluxplugin ?? createHttpBridge();
1341
- const framework = new RendererFramework({ bridge });
1323
+ var readme = (name) => `# ${name}
1342
1324
 
1343
- // Declare extension points BEFORE plugins start. Plugins contribute validated data to them.
1344
- framework.manager.extensionPoints.declare({
1345
- name: "dashboard",
1346
- contributions: { toolbar: s.object({ id: s.string(), label: s.string(), command: s.string().optional() }) },
1347
- });
1348
-
1349
- const css = baseCss + \`
1350
- body{background:var(--flux-bg);color:var(--flux-fg)}
1351
- .app{display:grid;grid-template-columns:220px 1fr;min-height:100vh}
1352
- .side{border-right:1px solid var(--flux-border);padding:16px;background:var(--flux-surface)}
1353
- .side a{display:block;padding:7px 10px;border-radius:8px;color:inherit;text-decoration:none;margin:2px 0}
1354
- .side a[data-active]{background:var(--flux-accent);color:#fff}.side h1{font-size:16px;margin:0 0 14px}
1355
- .main{padding:24px;max-width:860px}.row{display:flex;gap:8px;margin:12px 0}
1356
- .btn{border:1px solid var(--flux-border);background:var(--flux-bg);color:inherit;border-radius:8px;padding:6px 12px;cursor:pointer}
1357
- .hint{color:var(--flux-muted);font-size:12px}\`;
1358
-
1359
- function Dashboard() {
1360
- const tools = useExtensionItems<{ id: string; label: string; command?: string }>("dashboard", "toolbar");
1361
- const [out, setOut] = React.useState("");
1362
- const create = async (name: string) => {
1363
- try { setOut(JSON.stringify(await bridge.invoke("host.item.create", { name }))); }
1364
- catch (e) { setOut("blocked: " + (e as Error).message); }
1365
- };
1366
- return (
1367
- <div>
1368
- <h2>Dashboard</h2>
1369
- <PluginSlot name="dashboard.before" />
1370
- <p className="hint">These buttons are contributed by plugins through a host-declared extension point.</p>
1371
- <div className="row" data-testid="toolbar">
1372
- {tools.map((t) => <button key={t.id} className="btn" onClick={() => t.command && void framework.manager.commands.execute(t.command)}>{t.label}</button>)}
1373
- </div>
1374
- <div className="row">
1375
- <button className="btn" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>
1376
- <button className="btn" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>
1377
- </div>
1378
- <pre data-testid="hook-result" className="hint">{out}</pre>
1379
- <PluginSlot name="dashboard.after" />
1380
- </div>
1381
- );
1382
- }
1325
+ Created with [fluxplugin](https://www.npmjs.com/package/fluxplugin).
1383
1326
 
1384
- function Shell() {
1385
- useKeybindings();
1386
- const { items, run } = useMenu("sidebar");
1387
- return (
1388
- <div className="app">
1389
- <aside className="side">
1390
- <h1>${"${APP}"} <PluginSlot name="header.badges" /></h1>
1391
- <Link to="/">Dashboard</Link>
1392
- {items.map((i) => <a key={i.id} href={"#" + i.path} onClick={(e) => { e.preventDefault(); run(i); }}>{i.label}</a>)}
1393
- <Link to="/plugins">Plugins</Link>
1394
- <p className="hint">Ctrl/Cmd+K: commands</p>
1395
- </aside>
1396
- <main className="main"><Breadcrumbs /><PluginRoutes /></main>
1397
- <CommandPalette />
1398
- </div>
1399
- );
1400
- }
1327
+ \`\`\`bash
1328
+ npm install
1329
+ npm run dev:web # in a browser, no Electron needed
1330
+ npm run dev # in Electron (plugins hot-reload)
1331
+ npm run build && npm start
1332
+ npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
1333
+ \`\`\`
1401
1334
 
1402
- framework.manager.routes.register({ path: "/", component: Dashboard, meta: { title: "Dashboard" } }, "host");
1403
- framework.manager.routes.register({ path: "/plugins", component: PluginManagerUI, meta: { title: "Plugins", breadcrumb: "Plugins" } }, "host");
1335
+ ## Structure (everything lives in src/app)
1404
1336
 
1405
- await framework.start();
1406
- (window as unknown as Record<string, unknown>)["__framework"] = framework;
1407
- createRoot(document.getElementById("root")!).render(
1408
- <PluginProvider framework={framework}><style>{css}</style><StyleHost /><Shell /></PluginProvider>,
1409
- );
1410
- `.replace("${APP}", app);
1337
+ \`\`\`
1338
+ src/app/
1339
+ shared/ constants.ts, types.ts names used by both processes (depends on nothing)
1340
+ main/ index.ts (Electron entry) \xB7 web.ts (browser entry) \xB7 framework.ts \xB7 operations.ts \xB7 window.ts \xB7 paths.ts
1341
+ preload/ index.ts exposes window.__fluxplugin
1342
+ renderer/
1343
+ index.tsx \xB7 index.html entry
1344
+ bootstrap.ts bridge, shared modules, framework start (infrastructure)
1345
+ extension-points.ts \xB7 routes.ts what plugins may extend / host pages
1346
+ providers/ shell/ pages/ UI layers
1347
+ hooks/ services/ app logic (services know the IPC channels, hooks serve the UI)
1348
+ styles/app.css
1349
+ plugins/ plugins loaded at startup (users can also install .tgz from the Plugins page)
1350
+ \`\`\`
1351
+ `;
1411
1352
 
1412
1353
  // src/cli/cli.ts
1413
1354
  var HELP = `fluxplugin <command>
@@ -1497,6 +1438,7 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
1497
1438
  }
1498
1439
  const dispose = await buildHost(cwd, { watch: true });
1499
1440
  const { child, mode } = startApp(cwd, { web: f.web, dev: true, port: f.port ? Number(f.port) : void 0 });
1441
+ child.on("error", (e) => console.error(`error: ${e.message}`));
1500
1442
  log(`running in ${mode} mode \u2014 edit a plugin and it reloads; Ctrl+C to stop`);
1501
1443
  const stop = () => {
1502
1444
  child.kill();
@@ -91,7 +91,7 @@ async function buildPlugin(root, watch = false, log2 = console.log) {
91
91
 
92
92
  // src/cli/host.ts
93
93
  import { spawn } from "node:child_process";
94
- import { promises as fs3 } from "node:fs";
94
+ import { existsSync, promises as fs3, statSync } from "node:fs";
95
95
  import { createRequire } from "node:module";
96
96
  import path4 from "node:path";
97
97
  import { build as build2, context as context2 } from "esbuild";
@@ -114,17 +114,25 @@ async function loadConfig(flags, cwd = process.cwd()) {
114
114
  // src/cli/host.ts
115
115
  var exists2 = (f) => fs3.access(f).then(() => true, () => false);
116
116
  async function isHostProject(cwd) {
117
- return await exists2(path4.join(cwd, "fluxplugin.config.json")) || await exists2(path4.join(cwd, "src/renderer.tsx")) && await exists2(path4.join(cwd, "src/framework.ts"));
117
+ if (await exists2(path4.join(cwd, "fluxplugin.config.json"))) return true;
118
+ return await exists2(path4.join(cwd, "src/app/renderer/index.tsx")) || await exists2(path4.join(cwd, "src/renderer.tsx"));
118
119
  }
119
120
  var nodeBanner = { js: "import { createRequire as __r } from 'node:module'; const require = __r(import.meta.url);" };
121
+ var pick = (cwd, ...rels) => {
122
+ for (const r of rels) if (existsSync(path4.join(cwd, r))) return path4.join(cwd, r);
123
+ return path4.join(cwd, rels[0]);
124
+ };
120
125
  function hostBuilds(cwd, outDir, dev) {
121
126
  const common = { absWorkingDir: cwd, bundle: true, sourcemap: dev, logLevel: "warning", jsx: "automatic", define: { "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production") } };
122
- const entry = (f) => path4.join(cwd, "src", f);
127
+ const renderer = pick(cwd, "src/app/renderer/index.tsx", "src/renderer.tsx");
128
+ const main = pick(cwd, "src/app/main/index.ts", "src/main.ts");
129
+ const preload = pick(cwd, "src/app/preload/index.ts", "src/preload.ts");
130
+ const web = pick(cwd, "src/app/main/web.ts", "src/web.ts");
123
131
  return [
124
- { ...common, entryPoints: [entry("renderer.tsx")], outfile: path4.join(outDir, "renderer.js"), platform: "browser", format: "esm", target: "chrome120" },
125
- { ...common, entryPoints: [entry("main.ts")], outfile: path4.join(outDir, "main.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
126
- { ...common, entryPoints: [entry("preload.ts")], outfile: path4.join(outDir, "preload.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
127
- { ...common, entryPoints: [entry("web.ts")], outfile: path4.join(outDir, "web.mjs"), platform: "node", format: "esm", target: "node20", external: ["electron"], banner: nodeBanner }
132
+ { ...common, entryPoints: [renderer], outfile: path4.join(outDir, "renderer.js"), platform: "browser", format: "esm", target: "chrome120" },
133
+ { ...common, entryPoints: [main], outfile: path4.join(outDir, "main.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
134
+ { ...common, entryPoints: [preload], outfile: path4.join(outDir, "preload.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
135
+ { ...common, entryPoints: [web], outfile: path4.join(outDir, "web.mjs"), platform: "node", format: "esm", target: "node20", external: ["electron"], banner: nodeBanner }
128
136
  ];
129
137
  }
130
138
  async function pluginDirs(cwd, pluginsDir) {
@@ -140,11 +148,12 @@ async function buildHost(cwd, opts = {}) {
140
148
  const cfg = await loadConfig({}, cwd);
141
149
  const outDir = path4.resolve(cwd, cfgFile.outDir ?? "dist");
142
150
  await fs3.mkdir(outDir, { recursive: true });
143
- await fs3.copyFile(path4.join(cwd, "src/index.html"), path4.join(outDir, "index.html"));
151
+ await fs3.copyFile(pick(cwd, "src/app/renderer/index.html", "src/index.html"), path4.join(outDir, "index.html"));
144
152
  const disposers = [];
145
153
  const opts2 = hostBuilds(cwd, outDir, !!opts.watch);
146
154
  if (opts.watch) {
147
155
  const ctxs = await Promise.all(opts2.map((o) => context2(o)));
156
+ await Promise.all(ctxs.map((c) => c.rebuild()));
148
157
  await Promise.all(ctxs.map((c) => c.watch()));
149
158
  disposers.push(async () => void await Promise.all(ctxs.map((c) => c.dispose())));
150
159
  } else for (const o of opts2) await build2(o);
@@ -161,17 +170,115 @@ async function buildHost(cwd, opts = {}) {
161
170
  function electronBinary(cwd) {
162
171
  try {
163
172
  const p = createRequire(path4.join(cwd, "package.json"))("electron");
164
- return typeof p === "string" ? p : void 0;
173
+ if (typeof p === "string" && existsSync(p)) return { bin: p };
174
+ return { error: "the Electron binary was not found" };
175
+ } catch (e) {
176
+ return { error: e.message.split("\n")[0] };
177
+ }
178
+ }
179
+ function needsNoSandbox(bin) {
180
+ if (process.platform !== "linux") return false;
181
+ if (process.getuid?.() === 0) return true;
182
+ try {
183
+ const st = statSync(path4.join(path4.dirname(bin), "chrome-sandbox"));
184
+ return !(st.uid === 0 && (st.mode & 2048) !== 0);
165
185
  } catch {
166
- return void 0;
186
+ return false;
167
187
  }
168
188
  }
169
189
  function startApp(cwd, opts = {}) {
170
- const bin = opts.web ? void 0 : electronBinary(cwd);
190
+ const log2 = opts.log ?? console.log;
171
191
  const env = { ...process.env, FLUXPLUGIN_PROJECT: cwd, ...opts.dev ? { FLUXPLUGIN_DEV: "1" } : {}, ...opts.port ? { PORT: String(opts.port) } : {} };
172
- if (bin) return { child: spawn(bin, [path4.join(cwd, "dist/main.cjs")], { cwd, env, stdio: "inherit" }), mode: "electron" };
173
- return { child: spawn(process.execPath, [path4.join(cwd, "dist/web.mjs")], { cwd, env, stdio: "inherit" }), mode: "web" };
192
+ if (opts.web) return { child: spawn(process.execPath, [path4.join(cwd, "dist/web.mjs")], { cwd, env, stdio: "inherit" }), mode: "web" };
193
+ const { bin, error } = electronBinary(cwd);
194
+ if (!bin) throw new Error(`cannot start Electron: ${error}.
195
+ Install it with: npm install -D electron (then re-run)
196
+ or run in a browser: fluxplugin ${opts.dev ? "dev" : "start"} --web`);
197
+ const args = [...process.env["FLUXPLUGIN_ELECTRON_ARGS"]?.split(" ").filter(Boolean) ?? [], path4.join(cwd, "dist/main.cjs")];
198
+ if (needsNoSandbox(bin)) {
199
+ args.unshift("--no-sandbox");
200
+ log2("! Linux: Electron's sandbox helper is not configured (or you run as root) \u2192 starting with --no-sandbox.\n To fix permanently: sudo chown root node_modules/electron/dist/chrome-sandbox && sudo chmod 4755 node_modules/electron/dist/chrome-sandbox");
201
+ }
202
+ const started = Date.now();
203
+ const child = spawn(bin, args, { cwd, env, stdio: "inherit" });
204
+ child.on("exit", (code) => {
205
+ if (code && Date.now() - started < 5e3) log2(`Electron exited with code ${code} right after starting. Scroll up for its error, or try: fluxplugin dev --web`);
206
+ });
207
+ return { child, mode: "electron" };
208
+ }
209
+
210
+ // src/cli/host-files.generated.ts
211
+ var HOST_APP_FILES = {
212
+ "src/app/main/framework.ts": `import { MainFramework } from "fluxplugin/main";
213
+ import { CHANNELS } from "../shared/constants";
214
+ import { registerOperations } from "./operations";
215
+
216
+ export interface FrameworkOptions {
217
+ pluginDirs: string[];
218
+ dataDir: string;
219
+ dev?: boolean;
220
+ assetUrl?: (id: string, rel: string) => string;
221
+ }
222
+
223
+ /** Creates the plugin framework. Shared by the Electron entry (index.ts) and the browser entry (web.ts). */
224
+ export function createFramework(opts: FrameworkOptions): MainFramework {
225
+ const fw = new MainFramework({
226
+ pluginDirs: opts.pluginDirs,
227
+ dataDir: opts.dataDir,
228
+ hotReload: opts.dev,
229
+ assetUrl: opts.assetUrl,
230
+ requireApproval: false, // true \u2192 users must approve each plugin's permissions
231
+ allowedRendererChannels: [CHANNELS.createItem],
232
+ });
233
+ registerOperations(fw);
234
+ return fw;
235
+ }
236
+ `,
237
+ "src/app/main/index.ts": '// Electron entry (main process). Keep it thin: wiring only, logic lives in the other files of this folder.\nimport { app, ipcMain, protocol, webContents } from "electron";\nimport path from "node:path";\nimport { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";\nimport { createFramework } from "./framework";\nimport { pluginDirs, projectRoot } from "./paths";\nimport { createMainWindow } from "./window";\n\nprotocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before the app is ready\n\napp.whenReady().then(async () => {\n const fw = createFramework({\n pluginDirs: pluginDirs(projectRoot(__dirname), app.getPath("userData")),\n dataDir: path.join(app.getPath("userData"), "plugins-data"),\n dev: !!process.env["FLUXPLUGIN_DEV"],\n });\n registerPluginProtocol(fw, protocol);\n attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });\n await fw.start();\n createMainWindow(__dirname);\n app.on("before-quit", () => void fw.stop());\n});\n\napp.on("window-all-closed", () => app.quit());\n',
238
+ "src/app/main/operations.ts": 'import { s } from "fluxplugin";\nimport type { MainFramework } from "fluxplugin/main";\nimport { CHANNELS, OPERATIONS } from "../shared/constants";\nimport type { Item } from "../shared/types";\n\n/**\n * Host operations. Each one runs through the hook pipeline, so plugins can validate, normalize or veto it\n * (before.item.create, transform.item.create, ...) without touching this file.\n */\nexport function registerOperations(fw: MainFramework): void {\n fw.manager.ipc.handle(\n CHANNELS.createItem,\n (input: { name: string }) =>\n fw.manager.hooks.run(OPERATIONS.createItem, input, (i) => {\n const item: Item = { id: Math.random().toString(36).slice(2, 8), name: i.name };\n fw.manager.events.emit("item.created", item, "host");\n return item as unknown as Record<string, unknown>;\n }),\n "host",\n { input: s.object({ name: s.string() }) },\n );\n}\n',
239
+ "src/app/main/paths.ts": 'import path from "node:path";\n\n/** Project root: set by `fluxplugin dev|start`; falls back to the folder above dist/ (packaged app). */\nexport function projectRoot(distDir: string): string {\n return process.env["FLUXPLUGIN_PROJECT"] ?? path.resolve(distDir, "..");\n}\n\n/** Plugins bundled with the app (read-only) and plugins installed by the user (writable). */\nexport function pluginDirs(root: string, userData: string): string[] {\n return [path.join(root, "plugins"), path.join(userData, "plugins")];\n}\n',
240
+ "src/app/main/web.ts": '// Browser entry: `fluxplugin dev --web` (no Electron needed).\nimport path from "node:path";\nimport { serveWeb } from "fluxplugin/main";\nimport { APP_NAME } from "../shared/constants";\nimport { createFramework } from "./framework";\n\nconst root = process.env["FLUXPLUGIN_PROJECT"] ?? process.cwd();\nconst fw = createFramework({\n pluginDirs: [path.join(root, "plugins")],\n dataDir: path.join(root, ".flux-data"),\n dev: true,\n assetUrl: (id, rel) => "/@plugins/" + id + "/" + rel.replace("./", ""),\n});\nawait fw.start();\nconst srv = await serveWeb(fw, { distDir: path.join(root, "dist"), port: Number(process.env["PORT"] ?? 5173) });\nconsole.log(APP_NAME + " -> http://127.0.0.1:" + srv.port);\n',
241
+ "src/app/main/window.ts": 'import { BrowserWindow } from "electron";\nimport path from "node:path";\n\nexport function createMainWindow(distDir: string): BrowserWindow {\n const win = new BrowserWindow({\n width: 1100,\n height: 760,\n show: false,\n webPreferences: {\n preload: path.join(distDir, "preload.cjs"),\n contextIsolation: true,\n sandbox: true,\n nodeIntegration: false,\n },\n });\n win.once("ready-to-show", () => win.show());\n // The app never navigates away and never opens new windows.\n win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));\n win.webContents.on("will-navigate", (e) => e.preventDefault());\n void win.loadFile(path.join(distDir, "index.html"));\n return win;\n}\n',
242
+ "src/app/preload/index.ts": 'import { contextBridge, ipcRenderer } from "electron";\nimport { exposeFluxBridge } from "fluxplugin/preload";\n\n// The only thing renderer code receives from the preload: window.__fluxplugin\nexposeFluxBridge(contextBridge, ipcRenderer);\n',
243
+ "src/app/renderer/App.tsx": 'import type { FluxBridge } from "fluxplugin";\nimport type { RendererFramework } from "fluxplugin/react";\nimport { AppProviders } from "./providers/AppProviders";\nimport { AppShell } from "./shell/AppShell";\n\nexport function App({ framework, bridge }: { framework: RendererFramework; bridge: FluxBridge }) {\n return (\n <AppProviders framework={framework} bridge={bridge}>\n <AppShell />\n </AppProviders>\n );\n}\n',
244
+ "src/app/renderer/bootstrap.ts": 'import React from "react";\nimport * as jsxRuntime from "react/jsx-runtime";\nimport * as ReactDOMClient from "react-dom/client";\nimport * as flux from "fluxplugin";\nimport * as fluxReact from "fluxplugin/react";\nimport type { FluxBridge } from "fluxplugin";\nimport { RendererFramework, createHttpBridge, installSharedModules } from "fluxplugin/react";\nimport { declareExtensionPoints } from "./extension-points";\nimport { registerHostRoutes } from "./routes";\n\n/**\n * Infrastructure layer: connects to the main process, shares singletons with plugin bundles,\n * declares what plugins may extend, registers the host\'s own routes, then starts the plugins.\n */\nexport async function bootstrap(): Promise<{ framework: RendererFramework; bridge: FluxBridge }> {\n // One copy of React and of the runtime: plugin bundles import these from globalThis.\n installSharedModules({\n react: React,\n "react/jsx-runtime": jsxRuntime,\n "react/jsx-dev-runtime": jsxRuntime,\n "react-dom/client": ReactDOMClient,\n fluxplugin: flux,\n "fluxplugin/react": fluxReact,\n });\n\n // Electron: the preload bridge. Browser mode: HTTP bridge to `fluxplugin dev --web`.\n const bridge: FluxBridge = (window as unknown as { __fluxplugin?: FluxBridge }).__fluxplugin ?? createHttpBridge();\n const framework = new RendererFramework({ bridge });\n\n declareExtensionPoints(framework); // BEFORE plugins start\n registerHostRoutes(framework);\n await framework.start();\n return { framework, bridge };\n}\n',
245
+ "src/app/renderer/extension-points.ts": 'import { s } from "fluxplugin";\nimport type { RendererFramework } from "fluxplugin/react";\nimport { EXTENSION_POINTS } from "../shared/constants";\n\n/** Declare what plugins may contribute. Contributions are validated against these schemas. */\nexport function declareExtensionPoints(framework: RendererFramework): void {\n framework.manager.extensionPoints.declare({\n name: EXTENSION_POINTS.dashboard,\n contributions: {\n toolbar: s.object({ id: s.string(), label: s.string(), command: s.string().optional() }),\n },\n });\n}\n',
246
+ "src/app/renderer/hooks/useCreateItem.ts": 'import { useState } from "react";\nimport { useBridge } from "../providers/AppProviders";\nimport { itemsApi } from "../services/items";\n\n/** Calls the host operation (which plugins may rewrite or veto through hooks) and exposes the outcome. */\nexport function useCreateItem() {\n const bridge = useBridge();\n const [result, setResult] = useState("");\n const create = async (name: string) => {\n try {\n setResult(JSON.stringify(await itemsApi.create(bridge, name)));\n } catch (e) {\n setResult("blocked: " + (e as Error).message);\n }\n };\n return { create, result };\n}\n',
247
+ "src/app/renderer/index.html": `<!doctype html>
248
+ <html>
249
+ <head>
250
+ <meta charset="utf-8" />
251
+ <title>__APP_NAME__</title>
252
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; connect-src 'self'; img-src 'self' data:" />
253
+ <link rel="stylesheet" href="./renderer.css" />
254
+ </head>
255
+ <body>
256
+ <div id="root"></div>
257
+ <script type="module" src="./renderer.js"></script>
258
+ </body>
259
+ </html>
260
+ `,
261
+ "src/app/renderer/index.tsx": '// Renderer entry. Order matters: styles \u2192 bootstrap (infrastructure) \u2192 mount (UI).\nimport "./styles/app.css";\nimport { createRoot } from "react-dom/client";\nimport { bootstrap } from "./bootstrap";\nimport { App } from "./App";\n\nconst { framework, bridge } = await bootstrap();\ncreateRoot(document.getElementById("root")!).render(<App framework={framework} bridge={bridge} />);\n',
262
+ "src/app/renderer/pages/DashboardPage.tsx": 'import { PluginSlot, useExtensionItems, usePluginFramework } from "fluxplugin/react";\nimport { EXTENSION_POINTS, SLOTS } from "../../shared/constants";\nimport type { ToolbarAction } from "../../shared/types";\nimport { useCreateItem } from "../hooks/useCreateItem";\n\nexport function DashboardPage() {\n const framework = usePluginFramework();\n const actions = useExtensionItems<ToolbarAction>(EXTENSION_POINTS.dashboard, "toolbar");\n const { create, result } = useCreateItem();\n return (\n <div>\n <h2>Dashboard</h2>\n <PluginSlot name={SLOTS.dashboardBefore} />\n <p className="hint">These buttons are contributed by plugins through a host-declared extension point.</p>\n <div className="row" data-testid="toolbar">\n {actions.map((a) => (\n <button key={a.id} className="btn" onClick={() => a.command && void framework.manager.commands.execute(a.command)}>\n {a.label}\n </button>\n ))}\n </div>\n <div className="row">\n <button className="btn" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>\n <button className="btn" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>\n </div>\n <pre data-testid="hook-result" className="hint">{result}</pre>\n <PluginSlot name={SLOTS.dashboardAfter} />\n </div>\n );\n}\n',
263
+ "src/app/renderer/pages/PluginsPage.tsx": 'import { PluginManagerUI } from "fluxplugin/manager-ui";\n\n/** Ready-made plugin manager (enable/disable, permissions, settings, logs, install). Replace with your own UI if you like. */\nexport function PluginsPage() {\n return <PluginManagerUI />;\n}\n',
264
+ "src/app/renderer/providers/AppProviders.tsx": 'import { createContext, useContext, type ReactNode } from "react";\nimport type { FluxBridge } from "fluxplugin";\nimport { PluginProvider, StyleHost, baseCss, type RendererFramework } from "fluxplugin/react";\n\nconst BridgeContext = createContext<FluxBridge | null>(null);\n\n/** Gives pages access to the main-process bridge without importing globals. */\nexport function useBridge(): FluxBridge {\n const b = useContext(BridgeContext);\n if (!b) throw new Error("useBridge must be used inside <AppProviders>");\n return b;\n}\n\nexport function AppProviders({ framework, bridge, children }: { framework: RendererFramework; bridge: FluxBridge; children: ReactNode }) {\n return (\n <PluginProvider framework={framework}>\n <BridgeContext.Provider value={bridge}>\n <style>{baseCss}</style>\n <StyleHost />\n {children}\n </BridgeContext.Provider>\n </PluginProvider>\n );\n}\n',
265
+ "src/app/renderer/routes.ts": `import type { RendererFramework } from "fluxplugin/react";
266
+ import { DashboardPage } from "./pages/DashboardPage";
267
+ import { PluginsPage } from "./pages/PluginsPage";
268
+
269
+ /** The host's own pages (owner "host"). Plugins add theirs at runtime through the same registry. */
270
+ export function registerHostRoutes(framework: RendererFramework): void {
271
+ framework.manager.routes.register({ path: "/", component: DashboardPage, meta: { title: "Dashboard" } }, "host");
272
+ framework.manager.routes.register({ path: "/plugins", component: PluginsPage, meta: { title: "Plugins", breadcrumb: "Plugins" } }, "host");
174
273
  }
274
+ `,
275
+ "src/app/renderer/services/items.ts": 'import type { FluxBridge } from "fluxplugin";\nimport { CHANNELS } from "../../shared/constants";\nimport type { Item } from "../../shared/types";\n\n/** Service layer: the only place that knows the IPC channel names. UI code calls these functions. */\nexport const itemsApi = {\n create: (bridge: FluxBridge, name: string) => bridge.invoke(CHANNELS.createItem, { name }) as Promise<Item>,\n};\n',
276
+ "src/app/renderer/shell/AppShell.tsx": 'import { Breadcrumbs, CommandPalette, PluginRoutes, useKeybindings } from "fluxplugin/react";\nimport { Sidebar } from "./Sidebar";\n\n/** Layout of the whole window: sidebar + routed content + command palette. */\nexport function AppShell() {\n useKeybindings(); // runs commands bound to keyboard shortcuts (Mod+Shift+R, ...)\n return (\n <div className="app">\n <Sidebar />\n <main className="main">\n <Breadcrumbs />\n <PluginRoutes />\n </main>\n <CommandPalette />\n </div>\n );\n}\n',
277
+ "src/app/renderer/shell/Sidebar.tsx": 'import { Link, PluginSlot, useMenu } from "fluxplugin/react";\nimport { APP_NAME, SLOTS } from "../../shared/constants";\n\nexport function Sidebar() {\n const { items, run } = useMenu("sidebar"); // entries added by plugins (routes with meta.menu, or menus.register)\n return (\n <aside className="side">\n <h1>\n {APP_NAME} <PluginSlot name={SLOTS.sidebarHeader} />\n </h1>\n <Link to="/">Dashboard</Link>\n {items.map((i) => (\n <a key={i.id} href={"#" + i.path} onClick={(e) => (e.preventDefault(), run(i))}>\n {i.label}\n </a>\n ))}\n <Link to="/plugins">Plugins</Link>\n <p className="hint">Ctrl/Cmd+K: commands</p>\n </aside>\n );\n}\n',
278
+ "src/app/renderer/styles/app.css": "body { margin: 0; font: 14px system-ui; background: var(--flux-bg); color: var(--flux-fg); }\n.app { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }\n.side { border-right: 1px solid var(--flux-border); padding: 16px; background: var(--flux-surface); }\n.side h1 { font-size: 16px; margin: 0 0 14px; }\n.side a { display: block; padding: 7px 10px; border-radius: 8px; color: inherit; text-decoration: none; margin: 2px 0; }\n.side a[data-active] { background: var(--flux-accent); color: #fff; }\n.main { padding: 24px; max-width: 860px; }\n.row { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }\n.btn { border: 1px solid var(--flux-border); background: var(--flux-bg); color: inherit; border-radius: 8px; padding: 6px 12px; cursor: pointer; }\n.hint { color: var(--flux-muted); font-size: 12px; }\n",
279
+ "src/app/shared/constants.ts": '/** Names shared by the main process and the renderer. No imports here: this layer depends on nothing. */\nexport const APP_NAME = "__APP_NAME__";\n\n/** IPC channels the host exposes to the renderer (also allow-listed in main/framework.ts). */\nexport const CHANNELS = {\n createItem: "host.item.create",\n} as const;\n\n/** Declarative extension points the host offers to plugins (see renderer/extension-points.ts). */\nexport const EXTENSION_POINTS = {\n dashboard: "dashboard",\n} as const;\n\n/** UI slots where plugins can inject components (see <PluginSlot name=... />). */\nexport const SLOTS = {\n sidebarHeader: "header.badges",\n dashboardBefore: "dashboard.before",\n dashboardAfter: "dashboard.after",\n} as const;\n\n/** Hook pipeline operations plugins can intercept: before./after./transform./validate.<operation>. */\nexport const OPERATIONS = {\n createItem: "item.create",\n} as const;\n',
280
+ "src/app/shared/types.ts": 'export interface Item {\n id: string;\n name: string;\n}\n\n/** Shape of a contribution to the "dashboard" extension point (validated in renderer/extension-points.ts). */\nexport interface ToolbarAction {\n id: string;\n label: string;\n command?: string;\n}\n'
281
+ };
175
282
 
176
283
  // src/cli/templates.ts
177
284
  var pkg = (v) => JSON.stringify(
@@ -400,113 +507,9 @@ function hostFiles(v, withSample = true) {
400
507
  2
401
508
  ),
402
509
  ".gitignore": "node_modules\ndist\n.flux-data\n*.tgz\n",
403
- "src/index.html": `<!doctype html>
404
- <html><head><meta charset="utf-8"><title>${v.name}</title>
405
- <meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; connect-src 'self'; img-src 'self' data:">
406
- <style>body{margin:0;font:14px system-ui}</style></head>
407
- <body><div id="root"></div><script type="module" src="./renderer.js"></script></body></html>
408
- `,
409
- "src/framework.ts": `import { MainFramework } from "fluxplugin/main";
410
- import { s } from "fluxplugin";
411
-
412
- /** Shared by Electron (main.ts) and browser mode (web.ts). Put host-side setup here. */
413
- export function createFramework(opts: { pluginDirs: string[]; dataDir: string; dev?: boolean; assetUrl?: (id: string, rel: string) => string }) {
414
- const fw = new MainFramework({
415
- pluginDirs: opts.pluginDirs,
416
- dataDir: opts.dataDir,
417
- hotReload: opts.dev,
418
- assetUrl: opts.assetUrl,
419
- requireApproval: false, // set true to make users approve each plugin's permissions
420
- allowedRendererChannels: ["host.item.create"],
421
- });
422
-
423
- // A host operation wrapped in the hook pipeline: plugins can validate, normalize or veto it
424
- // (before./transform./after.item.create) without changing this file.
425
- fw.manager.ipc.handle(
426
- "host.item.create",
427
- (input: { name: string }) =>
428
- fw.manager.hooks.run("item.create", input, (i) => {
429
- const item = { id: Math.random().toString(36).slice(2, 8), name: i.name };
430
- fw.manager.events.emit("item.created", item, "host");
431
- return item as Record<string, unknown>;
432
- }),
433
- "host",
434
- { input: s.object({ name: s.string() }) },
435
- );
436
- return fw;
437
- }
438
- `,
439
- "src/main.ts": `// Electron main process.
440
- import { app, BrowserWindow, ipcMain, protocol, webContents } from "electron";
441
- import path from "node:path";
442
- import { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";
443
- import { createFramework } from "./framework.js";
444
-
445
- protocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before app is ready
446
-
447
- app.whenReady().then(async () => {
448
- const dev = !!process.env["FLUXPLUGIN_DEV"];
449
- const root = process.env["FLUXPLUGIN_PROJECT"] ?? path.resolve(__dirname, "..");
450
- const fw = createFramework({
451
- pluginDirs: [path.join(root, "plugins"), path.join(app.getPath("userData"), "plugins")], // bundled + user-installed
452
- dataDir: path.join(app.getPath("userData"), "plugins-data"),
453
- dev,
454
- });
455
- registerPluginProtocol(fw, protocol);
456
- attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });
457
- await fw.start();
458
-
459
- const win = new BrowserWindow({
460
- width: 1100,
461
- height: 760,
462
- webPreferences: { preload: path.join(__dirname, "preload.cjs"), contextIsolation: true, sandbox: true, nodeIntegration: false },
463
- });
464
- win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
465
- win.webContents.on("will-navigate", (e) => e.preventDefault());
466
- await win.loadFile(path.join(__dirname, "index.html"));
467
- app.on("before-quit", () => void fw.stop());
468
- });
469
- app.on("window-all-closed", () => app.quit());
470
- `,
471
- "src/preload.ts": `import { contextBridge, ipcRenderer } from "electron";
472
- import { exposeFluxBridge } from "fluxplugin/preload";
473
-
474
- exposeFluxBridge(contextBridge, ipcRenderer); // the only thing renderer code receives: window.__fluxplugin
475
- `,
476
- "src/web.ts": `// Browser mode (no Electron): \`fluxplugin dev --web\`.
477
- import path from "node:path";
478
- import { serveWeb } from "fluxplugin/main";
479
- import { createFramework } from "./framework.js";
480
-
481
- const root = process.env["FLUXPLUGIN_PROJECT"] ?? process.cwd();
482
- const fw = createFramework({
483
- pluginDirs: [path.join(root, "plugins")],
484
- dataDir: path.join(root, ".flux-data"),
485
- dev: true,
486
- assetUrl: (id, rel) => \`/@plugins/\${id}/\${rel.replace(/^\\.\\//, "")}\`,
487
- });
488
- await fw.start();
489
- const srv = await serveWeb(fw, { distDir: path.join(root, "dist"), port: Number(process.env["PORT"] ?? 5173) });
490
- console.log(\`${v.name} \u2192 http://127.0.0.1:\${srv.port}\`);
491
- `,
492
- "src/renderer.tsx": renderer(v.name),
493
- "README.md": `# ${v.name}
494
-
495
- Created with [fluxplugin](https://www.npmjs.com/package/fluxplugin).
496
-
497
- \`\`\`bash
498
- npm install
499
- npm run dev:web # in a browser, no Electron needed
500
- npm run dev # in Electron (hot reload for plugins)
501
- npm run build && npm start
502
- npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
503
- \`\`\`
504
-
505
- * \`src/renderer.tsx\` \u2014 your React UI. \`<PluginSlot name="\u2026"/>\` and extension points are where plugins plug in.
506
- * \`src/framework.ts\` \u2014 host-side setup (permissions policy, host operations exposed to plugins, hooks).
507
- * \`plugins/\` \u2014 plugins loaded at startup; users can also install \`.tgz\` plugins from the Plugins page.
508
- `
510
+ "README.md": readme(v.name)
509
511
  };
512
+ for (const [rel, content] of Object.entries(HOST_APP_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name);
510
513
  if (withSample) {
511
514
  const sample = TEMPLATES.dashboard(varsFromName("hello"));
512
515
  for (const [rel, content] of Object.entries(sample)) {
@@ -516,97 +519,35 @@ npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
516
519
  }
517
520
  return files;
518
521
  }
519
- var renderer = (app) => `import React from "react";
520
- import { createRoot } from "react-dom/client";
521
- import * as jsxRuntime from "react/jsx-runtime";
522
- import * as ReactDOMClient from "react-dom/client";
523
- import * as flux from "fluxplugin";
524
- import * as fluxReact from "fluxplugin/react";
525
- import { s, type FluxBridge } from "fluxplugin";
526
- import {
527
- Breadcrumbs, CommandPalette, Link, PluginProvider, PluginRoutes, PluginSlot, RendererFramework, StyleHost,
528
- baseCss, createHttpBridge, installSharedModules, useExtensionItems, useKeybindings, useMenu,
529
- } from "fluxplugin/react";
530
- import { PluginManagerUI } from "fluxplugin/manager-ui";
531
-
532
- // One copy of React and of the runtime, shared with plugin bundles (they import these from globalThis).
533
- installSharedModules({
534
- react: React, "react/jsx-runtime": jsxRuntime, "react/jsx-dev-runtime": jsxRuntime, "react-dom/client": ReactDOMClient,
535
- fluxplugin: flux, "fluxplugin/react": fluxReact,
536
- });
537
-
538
- // Electron: the preload bridge. Browser mode: HTTP bridge to \`fluxplugin dev --web\`.
539
- const bridge: FluxBridge = (window as unknown as { __fluxplugin?: FluxBridge }).__fluxplugin ?? createHttpBridge();
540
- const framework = new RendererFramework({ bridge });
541
-
542
- // Declare extension points BEFORE plugins start. Plugins contribute validated data to them.
543
- framework.manager.extensionPoints.declare({
544
- name: "dashboard",
545
- contributions: { toolbar: s.object({ id: s.string(), label: s.string(), command: s.string().optional() }) },
546
- });
522
+ var readme = (name) => `# ${name}
547
523
 
548
- const css = baseCss + \`
549
- body{background:var(--flux-bg);color:var(--flux-fg)}
550
- .app{display:grid;grid-template-columns:220px 1fr;min-height:100vh}
551
- .side{border-right:1px solid var(--flux-border);padding:16px;background:var(--flux-surface)}
552
- .side a{display:block;padding:7px 10px;border-radius:8px;color:inherit;text-decoration:none;margin:2px 0}
553
- .side a[data-active]{background:var(--flux-accent);color:#fff}.side h1{font-size:16px;margin:0 0 14px}
554
- .main{padding:24px;max-width:860px}.row{display:flex;gap:8px;margin:12px 0}
555
- .btn{border:1px solid var(--flux-border);background:var(--flux-bg);color:inherit;border-radius:8px;padding:6px 12px;cursor:pointer}
556
- .hint{color:var(--flux-muted);font-size:12px}\`;
557
-
558
- function Dashboard() {
559
- const tools = useExtensionItems<{ id: string; label: string; command?: string }>("dashboard", "toolbar");
560
- const [out, setOut] = React.useState("");
561
- const create = async (name: string) => {
562
- try { setOut(JSON.stringify(await bridge.invoke("host.item.create", { name }))); }
563
- catch (e) { setOut("blocked: " + (e as Error).message); }
564
- };
565
- return (
566
- <div>
567
- <h2>Dashboard</h2>
568
- <PluginSlot name="dashboard.before" />
569
- <p className="hint">These buttons are contributed by plugins through a host-declared extension point.</p>
570
- <div className="row" data-testid="toolbar">
571
- {tools.map((t) => <button key={t.id} className="btn" onClick={() => t.command && void framework.manager.commands.execute(t.command)}>{t.label}</button>)}
572
- </div>
573
- <div className="row">
574
- <button className="btn" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>
575
- <button className="btn" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>
576
- </div>
577
- <pre data-testid="hook-result" className="hint">{out}</pre>
578
- <PluginSlot name="dashboard.after" />
579
- </div>
580
- );
581
- }
524
+ Created with [fluxplugin](https://www.npmjs.com/package/fluxplugin).
582
525
 
583
- function Shell() {
584
- useKeybindings();
585
- const { items, run } = useMenu("sidebar");
586
- return (
587
- <div className="app">
588
- <aside className="side">
589
- <h1>${"${APP}"} <PluginSlot name="header.badges" /></h1>
590
- <Link to="/">Dashboard</Link>
591
- {items.map((i) => <a key={i.id} href={"#" + i.path} onClick={(e) => { e.preventDefault(); run(i); }}>{i.label}</a>)}
592
- <Link to="/plugins">Plugins</Link>
593
- <p className="hint">Ctrl/Cmd+K: commands</p>
594
- </aside>
595
- <main className="main"><Breadcrumbs /><PluginRoutes /></main>
596
- <CommandPalette />
597
- </div>
598
- );
599
- }
526
+ \`\`\`bash
527
+ npm install
528
+ npm run dev:web # in a browser, no Electron needed
529
+ npm run dev # in Electron (plugins hot-reload)
530
+ npm run build && npm start
531
+ npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
532
+ \`\`\`
600
533
 
601
- framework.manager.routes.register({ path: "/", component: Dashboard, meta: { title: "Dashboard" } }, "host");
602
- framework.manager.routes.register({ path: "/plugins", component: PluginManagerUI, meta: { title: "Plugins", breadcrumb: "Plugins" } }, "host");
534
+ ## Structure (everything lives in src/app)
603
535
 
604
- await framework.start();
605
- (window as unknown as Record<string, unknown>)["__framework"] = framework;
606
- createRoot(document.getElementById("root")!).render(
607
- <PluginProvider framework={framework}><style>{css}</style><StyleHost /><Shell /></PluginProvider>,
608
- );
609
- `.replace("${APP}", app);
536
+ \`\`\`
537
+ src/app/
538
+ shared/ constants.ts, types.ts names used by both processes (depends on nothing)
539
+ main/ index.ts (Electron entry) \xB7 web.ts (browser entry) \xB7 framework.ts \xB7 operations.ts \xB7 window.ts \xB7 paths.ts
540
+ preload/ index.ts exposes window.__fluxplugin
541
+ renderer/
542
+ index.tsx \xB7 index.html entry
543
+ bootstrap.ts bridge, shared modules, framework start (infrastructure)
544
+ extension-points.ts \xB7 routes.ts what plugins may extend / host pages
545
+ providers/ shell/ pages/ UI layers
546
+ hooks/ services/ app logic (services know the IPC channels, hooks serve the UI)
547
+ styles/app.css
548
+ plugins/ plugins loaded at startup (users can also install .tgz from the Plugins page)
549
+ \`\`\`
550
+ `;
610
551
 
611
552
  // src/cli/cli.ts
612
553
  var HELP = `fluxplugin <command>
@@ -696,6 +637,7 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
696
637
  }
697
638
  const dispose = await buildHost(cwd, { watch: true });
698
639
  const { child, mode } = startApp(cwd, { web: f.web, dev: true, port: f.port ? Number(f.port) : void 0 });
640
+ child.on("error", (e) => console.error(`error: ${e.message}`));
699
641
  log(`running in ${mode} mode \u2014 edit a plugin and it reloads; Ctrl+C to stop`);
700
642
  const stop = () => {
701
643
  child.kill();
@@ -0,0 +1 @@
1
+ export declare const HOST_APP_FILES: Record<string, string>;
@@ -5,11 +5,12 @@ export declare function buildHost(cwd: string, opts?: {
5
5
  watch?: boolean;
6
6
  log?: (m: string) => void;
7
7
  }): Promise<() => Promise<void>>;
8
- /** Starts the app: Electron when available (unless `web`), otherwise browser mode. Resolves when it exits. */
8
+ /** Starts the app: Electron by default, browser mode with `web`. Throws a readable error if Electron cannot run. */
9
9
  export declare function startApp(cwd: string, opts?: {
10
10
  web?: boolean;
11
11
  dev?: boolean;
12
12
  port?: number;
13
+ log?: (m: string) => void;
13
14
  }): {
14
15
  child: ChildProcess;
15
16
  mode: "electron" | "web";
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "fluxplugin",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Plugin architecture for Electron + React + TypeScript. Build apps that others can extend: routes, menus, commands, UI slots, IPC, hooks, permissions — with a CLI.",
5
5
  "license": "MIT",
6
- "author": "Cellsflux",
7
6
  "keywords": [
8
7
  "electron",
9
8
  "react",