fluxplugin 1.0.1 → 1.0.3

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.
Files changed (62) hide show
  1. package/README.md +22 -9
  2. package/dist/{node/chunks/chunk-XPLGAEBH.js → browser/chunks/chunk-TL63576R.js} +106 -81
  3. package/dist/browser/chunks/{chunk-ZZ25CRMR.js → chunk-VXQJIDXW.js} +479 -6
  4. package/dist/browser/index.js +5 -1
  5. package/dist/browser/manager-ui.js +93 -18
  6. package/dist/browser/react.js +34 -4
  7. package/dist/cli.js +869 -174
  8. package/dist/node/chunks/chunk-3FH6NAJG.js +38 -0
  9. package/dist/node/chunks/{chunk-BZ7NZF7I.js → chunk-C7VWZZOX.js} +259 -44
  10. package/dist/{browser/chunks/chunk-Q75AOCXD.js → node/chunks/chunk-EVX2O65A.js} +185 -2
  11. package/dist/node/chunks/chunk-WSFCRVEQ.js +9 -0
  12. package/dist/node/chunks/tailwind-TJQVTBG5.js +9 -0
  13. package/dist/node/cli-api.js +562 -129
  14. package/dist/node/index.js +6 -1
  15. package/dist/node/main.js +19 -2
  16. package/dist/node/preload.js +2 -1
  17. package/dist/types/cli/build.d.ts +1 -1
  18. package/dist/types/cli/config.d.ts +3 -0
  19. package/dist/types/cli/host-files.generated.d.ts +1 -0
  20. package/dist/types/cli/host-templates.d.ts +3 -0
  21. package/dist/types/cli/host.d.ts +8 -0
  22. package/dist/types/cli/package.d.ts +18 -0
  23. package/dist/types/cli/publish.d.ts +23 -0
  24. package/dist/types/cli/store.d.ts +7 -0
  25. package/dist/types/cli/tailwind.d.ts +10 -0
  26. package/dist/types/cli/templates.d.ts +2 -0
  27. package/dist/types/core/index.d.ts +1 -0
  28. package/dist/types/core/manager.d.ts +2 -0
  29. package/dist/types/core/manifest.d.ts +9 -0
  30. package/dist/types/core/notifications.d.ts +48 -0
  31. package/dist/types/core/plugin.d.ts +7 -0
  32. package/dist/types/core/protocol.d.ts +27 -0
  33. package/dist/types/main/framework.d.ts +7 -1
  34. package/dist/types/main/github-registry.d.ts +49 -0
  35. package/dist/types/main/index.d.ts +2 -0
  36. package/dist/types/main/registry.d.ts +30 -0
  37. package/dist/types/main/stores.d.ts +29 -0
  38. package/dist/types/react/base-css.d.ts +1 -1
  39. package/dist/types/react/icons.d.ts +12 -0
  40. package/dist/types/react/index.d.ts +5 -0
  41. package/dist/types/react/markdown.d.ts +8 -0
  42. package/dist/types/react/os.d.ts +5 -0
  43. package/dist/types/react/runtime.d.ts +2 -1
  44. package/dist/types/react/theme.d.ts +53 -0
  45. package/dist/types/react/titlebar.d.ts +28 -0
  46. package/docs/CUSTOMIZING.md +257 -0
  47. package/docs/PLUGIN-AUTHORS.md +36 -9
  48. package/docs/RELEASE.md +1 -1
  49. package/docs/SECURITY.md +5 -0
  50. package/docs/STORE.md +123 -0
  51. package/docs/TROUBLESHOOTING.md +35 -0
  52. package/docs/screenshots/command-palette.png +0 -0
  53. package/docs/screenshots/dashboard.png +0 -0
  54. package/docs/screenshots/electron-plugin-page.png +0 -0
  55. package/docs/screenshots/electron-titlebar.png +0 -0
  56. package/docs/screenshots/plugin-details.png +0 -0
  57. package/docs/screenshots/plugin-manager.png +0 -0
  58. package/docs/screenshots/plugin-video.png +0 -0
  59. package/docs/screenshots/stats.png +0 -0
  60. package/docs/screenshots/store-available.png +0 -0
  61. package/docs/screenshots/tailwind-dark.png +0 -0
  62. package/package.json +1 -1
@@ -1,24 +1,32 @@
1
1
  import {
2
+ DEFAULT_STORE,
3
+ DEFAULT_TOPIC,
2
4
  JsonStateFile,
3
5
  LocalRegistry,
4
6
  PluginInstaller,
5
7
  computeIntegrity,
6
8
  copyDir,
9
+ copyPluginRuntime,
10
+ createRegistries,
7
11
  createTarGz,
8
12
  extractTarGz,
9
13
  generateSigningKeyPair,
14
+ readTarGz,
10
15
  signManifest,
11
16
  verifyPlugin
12
- } from "./chunks/chunk-BZ7NZF7I.js";
17
+ } from "./chunks/chunk-C7VWZZOX.js";
13
18
  import {
14
19
  validateManifest
15
- } from "./chunks/chunk-XPLGAEBH.js";
20
+ } from "./chunks/chunk-EVX2O65A.js";
21
+ import {
22
+ compileTailwind
23
+ } from "./chunks/chunk-3FH6NAJG.js";
24
+ import "./chunks/chunk-WSFCRVEQ.js";
16
25
 
17
26
  // src/cli/cli.ts
18
27
  import { parseArgs } from "node:util";
19
- import { promises as fs4 } from "node:fs";
20
- import path5 from "node:path";
21
- import crypto from "node:crypto";
28
+ import { promises as fs7 } from "node:fs";
29
+ import path8 from "node:path";
22
30
 
23
31
  // src/cli/build.ts
24
32
  import { promises as fs } from "node:fs";
@@ -53,12 +61,20 @@ var exists = (f) => fs.access(f).then(() => true, () => false);
53
61
  async function firstExisting(root, names) {
54
62
  for (const n of names) if (await exists(path2.join(root, n))) return path2.join(root, n);
55
63
  }
56
- async function buildOptions(root) {
64
+ async function buildOptions(root, dev = false) {
57
65
  const raw = JSON.parse(await fs.readFile(path2.join(root, "plugin.json"), "utf8"));
58
66
  const m = validateManifest(raw).manifest;
59
67
  if (!m) throw new Error("invalid plugin.json (run `fluxplugin validate`)");
60
68
  const out = [];
61
- const common = { bundle: true, sourcemap: true, logLevel: "warning", plugins: [sharedModulesPlugin()], jsx: "automatic", loader: { ".module.css": "local-css" } };
69
+ const common = {
70
+ bundle: true,
71
+ sourcemap: dev,
72
+ // sourcemaps only while developing; a built/published plugin never has them
73
+ logLevel: "warning",
74
+ plugins: [sharedModulesPlugin()],
75
+ jsx: "automatic",
76
+ loader: { ".module.css": "local-css" }
77
+ };
62
78
  if (m.main) {
63
79
  const entry = await firstExisting(root, ["src/main.ts", "src/main.js"]);
64
80
  if (!entry) throw new Error("manifest declares `main` but src/main.ts was not found");
@@ -70,27 +86,30 @@ async function buildOptions(root) {
70
86
  out.push({ ...common, entryPoints: [entry], outfile: path2.join(root, m.renderer), platform: "browser", format: "esm", target: "chrome120" });
71
87
  }
72
88
  for (const css of m.styles ?? []) {
89
+ if (path2.basename(css) === "tailwind.css") continue;
73
90
  const entry = path2.join(root, "src/styles", path2.basename(css));
74
91
  if (await exists(entry)) out.push({ ...common, plugins: [], entryPoints: [entry], outfile: path2.join(root, css) });
75
92
  }
76
93
  return out;
77
94
  }
78
95
  async function buildPlugin(root, watch = false, log2 = console.log) {
79
- const opts = await buildOptions(root);
96
+ const opts = await buildOptions(root, watch);
97
+ const twIn = path2.join(root, "src/styles/tailwind.css");
98
+ const stopTw = await exists(twIn) ? compileTailwind({ cwd: root, input: twIn, output: path2.join(root, "dist/tailwind.css"), watch, searchFrom: [process.cwd(), path2.dirname(path2.dirname(root))] }, log2) : () => {
99
+ };
80
100
  if (!watch) {
81
101
  for (const o of opts) await build(o);
82
102
  log2(`built ${opts.length} bundle(s) in ${root}`);
83
- return async () => {
84
- };
103
+ return async () => stopTw();
85
104
  }
86
105
  const ctxs = await Promise.all(opts.map((o) => context(o)));
87
106
  await Promise.all(ctxs.map((c) => c.watch()));
88
107
  log2(`watching ${root} (${ctxs.length} bundles)`);
89
- return async () => void await Promise.all(ctxs.map((c) => c.dispose()));
108
+ return async () => (stopTw(), void await Promise.all(ctxs.map((c) => c.dispose())));
90
109
  }
91
110
 
92
111
  // src/cli/host.ts
93
- import { spawn } from "node:child_process";
112
+ import { spawn, spawnSync } from "node:child_process";
94
113
  import { existsSync, promises as fs3, statSync } from "node:fs";
95
114
  import { createRequire } from "node:module";
96
115
  import path4 from "node:path";
@@ -107,7 +126,8 @@ async function loadConfig(flags, cwd = process.cwd()) {
107
126
  }
108
127
  return {
109
128
  pluginsDir: path3.resolve(cwd, flags.dir ?? file.pluginsDir ?? "plugins"),
110
- dataDir: path3.resolve(cwd, flags.data ?? file.dataDir ?? ".flux-data")
129
+ dataDir: path3.resolve(cwd, flags.data ?? file.dataDir ?? ".flux-data"),
130
+ stores: Array.isArray(file.stores) && file.stores.length ? file.stores : [DEFAULT_STORE]
111
131
  };
112
132
  }
113
133
 
@@ -150,6 +170,11 @@ async function buildHost(cwd, opts = {}) {
150
170
  await fs3.mkdir(outDir, { recursive: true });
151
171
  await fs3.copyFile(pick(cwd, "src/app/renderer/index.html", "src/index.html"), path4.join(outDir, "index.html"));
152
172
  const disposers = [];
173
+ const twIn = path4.join(cwd, "src/app/renderer/styles/tailwind.css");
174
+ if (await exists2(twIn)) {
175
+ const stop = compileTailwind({ cwd, input: twIn, output: path4.join(outDir, "tailwind.css"), watch: opts.watch }, log2);
176
+ disposers.push(async () => stop());
177
+ }
153
178
  const opts2 = hostBuilds(cwd, outDir, !!opts.watch);
154
179
  if (opts.watch) {
155
180
  const ctxs = await Promise.all(opts2.map((o) => context2(o)));
@@ -176,6 +201,22 @@ function electronBinary(cwd) {
176
201
  return { error: e.message.split("\n")[0] };
177
202
  }
178
203
  }
204
+ function ensureElectron(cwd, log2 = console.log) {
205
+ const first = electronBinary(cwd);
206
+ if (first.bin) return first;
207
+ let pkgDir;
208
+ try {
209
+ pkgDir = path4.dirname(createRequire(path4.join(cwd, "package.json")).resolve("electron/package.json"));
210
+ } catch {
211
+ return { error: "Electron is not installed in this project" };
212
+ }
213
+ const installJs = path4.join(pkgDir, "install.js");
214
+ if (!existsSync(installJs)) return first;
215
+ log2("Electron's binary is missing (your package manager blocked its install script) \u2192 downloading it now\u2026");
216
+ const r = spawnSync(process.execPath, [installJs], { cwd: pkgDir, stdio: "inherit" });
217
+ if (r.status !== 0) return { error: "the Electron download failed (network or proxy?). Retry, or set ELECTRON_MIRROR" };
218
+ return electronBinary(cwd);
219
+ }
179
220
  function needsNoSandbox(bin) {
180
221
  if (process.platform !== "linux") return false;
181
222
  if (process.getuid?.() === 0) return true;
@@ -190,10 +231,10 @@ function startApp(cwd, opts = {}) {
190
231
  const log2 = opts.log ?? console.log;
191
232
  const env = { ...process.env, FLUXPLUGIN_PROJECT: cwd, ...opts.dev ? { FLUXPLUGIN_DEV: "1" } : {}, ...opts.port ? { PORT: String(opts.port) } : {} };
192
233
  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);
234
+ const { bin, error } = ensureElectron(cwd, log2);
194
235
  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`);
236
+ Try: npm install -D electron then fluxplugin doctor
237
+ Or run in a browser: fluxplugin ${opts.dev ? "dev" : "start"} --web`);
197
238
  const args = [...process.env["FLUXPLUGIN_ELECTRON_ARGS"]?.split(" ").filter(Boolean) ?? [], path4.join(cwd, "dist/main.cjs")];
198
239
  if (needsNoSandbox(bin)) {
199
240
  args.unshift("--no-sandbox");
@@ -209,39 +250,52 @@ function startApp(cwd, opts = {}) {
209
250
 
210
251
  // src/cli/host-files.generated.ts
211
252
  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',
253
+ "src/app/main/framework.ts": 'import { MainFramework, type StoreSpec } from "fluxplugin/main";\nimport config from "../../../fluxplugin.config.json"; // bundled into the app at build time\nimport { CHANNELS } from "../shared/constants";\nimport { registerOperations } from "./operations";\n\nexport interface FrameworkOptions {\n pluginDirs: string[];\n dataDir: string;\n dev?: boolean;\n assetUrl?: (id: string, rel: string) => string;\n}\n\n/** Creates the plugin framework. Shared by the Electron entry (index.ts) and the browser entry (web.ts). */\nexport function createFramework(opts: FrameworkOptions): MainFramework {\n const fw = new MainFramework({\n pluginDirs: opts.pluginDirs,\n dataDir: opts.dataDir,\n hotReload: opts.dev,\n assetUrl: opts.assetUrl,\n // Where "Plugins \u2192 Available" looks. Default: GitHub (every repo that published with `fluxplugin publish`).\n // Edit "stores" in fluxplugin.config.json to add your own store (github catalog, http, local) \u2014 see docs/STORE.md.\n stores: config.stores as StoreSpec[],\n requireApproval: false, // true \u2192 users must approve each plugin\'s permissions\n allowedRendererChannels: [CHANNELS.createItem, CHANNELS.setTitleBarOverlay],\n });\n registerOperations(fw);\n return fw;\n}\n',
254
+ "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, Menu, protocol, webContents } from "electron";\nimport path from "node:path";\nimport { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";\nimport { APP_NAME } from "../shared/constants";\nimport { createFramework } from "./framework";\nimport { pluginDirs, projectRoot } from "./paths";\nimport { createMainWindow } from "./window";\nimport { registerWindowApi } from "./window-api";\n\n// Own data folder per app. In dev Electron is started as `electron dist/main.cjs` and would otherwise be called "Electron",\n// sharing plugin state (enabled/disabled, permissions, settings) with every other app run that way.\napp.setName(APP_NAME);\n\nprotocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before the app is ready\n\napp.whenReady().then(async () => {\n const dev = !!process.env["FLUXPLUGIN_DEV"];\n // The menu bar is drawn by the renderer (<TitleBar />). Windows/Linux: no native menu. macOS keeps a minimal one\n // so the system shortcuts (copy/paste/quit/hide) keep working.\n Menu.setApplicationMenu(process.platform === "darwin" ? Menu.buildFromTemplate([{ role: "appMenu" }, { role: "editMenu" }, { role: "windowMenu" }]) : null);\n\n const fw = createFramework({\n pluginDirs: pluginDirs(projectRoot(__dirname), app.getPath("userData")),\n dataDir: path.join(app.getPath("userData"), "plugins-data"),\n dev,\n });\n registerPluginProtocol(fw, protocol);\n attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });\n await fw.start();\n\n const win = createMainWindow(__dirname);\n registerWindowApi(fw, win);\n if (dev) win.webContents.on("before-input-event", (_e, input) => input.key === "F12" && input.type === "keyDown" && win.webContents.toggleDevTools());\n app.on("before-quit", () => void fw.stop());\n});\n\napp.on("window-all-closed", () => app.quit());\n',
238
255
  "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
256
  "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
257
  "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',
258
+ "src/app/main/window-api.ts": 'import type { BrowserWindow } from "electron";\nimport { s } from "fluxplugin";\nimport type { MainFramework } from "fluxplugin/main";\nimport { CHANNELS, TITLEBAR_HEIGHT } from "../shared/constants";\n\n/** Host operations that need the BrowserWindow. Add your own (maximize, always-on-top, \u2026) the same way. */\nexport function registerWindowApi(fw: MainFramework, win: BrowserWindow): void {\n fw.manager.ipc.handle(\n CHANNELS.setTitleBarOverlay,\n (i: { color: string; symbolColor: string }) => {\n // Only Windows/Linux have the native-controls overlay; on macOS the traffic lights need no colouring.\n if (process.platform !== "darwin" && !win.isDestroyed()) {\n try {\n win.setTitleBarOverlay({ color: i.color, symbolColor: i.symbolColor, height: TITLEBAR_HEIGHT });\n } catch {\n /* colour not parseable: keep the previous one */\n }\n }\n return true;\n },\n "host",\n { input: s.object({ color: s.string(), symbolColor: s.string() }) },\n );\n}\n',
259
+ "src/app/main/window.ts": `import { BrowserWindow, shell } from "electron";
260
+ import path from "node:path";
261
+ import { TITLEBAR_HEIGHT } from "../shared/constants";
262
+
263
+ /**
264
+ * Frameless window with a custom title bar drawn by the renderer (<TitleBar />).
265
+ * - macOS: "hiddenInset" keeps the native traffic-light buttons (the bar reserves space for them).
266
+ * - Windows/Linux: "hidden" + titleBarOverlay keeps the native minimize/maximize/close buttons on the right.
267
+ */
268
+ export function createMainWindow(distDir: string): BrowserWindow {
269
+ const mac = process.platform === "darwin";
270
+ const win = new BrowserWindow({
271
+ width: 1180,
272
+ height: 780,
273
+ minWidth: 760,
274
+ minHeight: 480,
275
+ show: false,
276
+ titleBarStyle: mac ? "hiddenInset" : "hidden",
277
+ ...(mac ? {} : { titleBarOverlay: { color: "#f8fafc", symbolColor: "#0f172a", height: TITLEBAR_HEIGHT } }),
278
+ webPreferences: {
279
+ preload: path.join(distDir, "preload.cjs"),
280
+ contextIsolation: true,
281
+ sandbox: true,
282
+ nodeIntegration: false,
283
+ },
284
+ });
285
+ win.once("ready-to-show", () => win.show());
286
+ // The app never navigates away; https links open in the user's browser, everything else is blocked.
287
+ win.webContents.setWindowOpenHandler(({ url }) => {
288
+ if (/^https:\\/\\//.test(url)) void shell.openExternal(url);
289
+ return { action: "deny" };
290
+ });
291
+ win.webContents.on("will-navigate", (e) => e.preventDefault());
292
+ void win.loadFile(path.join(distDir, "index.html"));
293
+ return win;
294
+ }
295
+ `,
242
296
  "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
297
  "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',
298
+ "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, declareHostPoints, installSharedModules } from "fluxplugin/react";\nimport { declareExtensionPoints } from "./extension-points";\nimport { registerHostMenus } from "./menus";\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 declareHostPoints(framework); // built-in "theme" and "titlebar" (search providers) extension points\n declareExtensionPoints(framework); // BEFORE plugins start\n registerHostRoutes(framework);\n registerHostMenus(framework);\n await framework.start();\n return { framework, bridge };\n}\n',
245
299
  "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
300
  "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
301
  "src/app/renderer/index.html": `<!doctype html>
@@ -249,7 +303,7 @@ export function createFramework(opts: FrameworkOptions): MainFramework {
249
303
  <head>
250
304
  <meta charset="utf-8" />
251
305
  <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:" />
306
+ <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: fluxplugin:; frame-src https://www.youtube-nocookie.com https://player.vimeo.com" />
253
307
  <link rel="stylesheet" href="./renderer.css" />
254
308
  </head>
255
309
  <body>
@@ -259,9 +313,35 @@ export function createFramework(opts: FrameworkOptions): MainFramework {
259
313
  </html>
260
314
  `,
261
315
  "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',
316
+ "src/app/renderer/menus.ts": `import type { RendererFramework } from "fluxplugin/react";
317
+
318
+ /**
319
+ * The host's own application menu (the bar in the title bar). Plugins add entries to the same registry, either as
320
+ * new top-level menus or as extra children \u2014 see docs/CUSTOMIZING.md. Shortcuts shown come from the commands' keybinding.
321
+ */
322
+ export function registerHostMenus(framework: RendererFramework): void {
323
+ const { menus, commands } = framework.manager;
324
+ commands.register({ id: "host.palette", title: "Command palette", category: "View", keybinding: "Mod+K", execute: () => window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true })) }, "host");
325
+ commands.register({ id: "host.reload", title: "Reload window", category: "View", keybinding: "Mod+R", execute: () => location.reload() }, "host");
326
+ commands.register({ id: "host.goHome", title: "Go to dashboard", category: "Go", execute: () => (location.hash = "#/") }, "host");
327
+ commands.register({ id: "host.goPlugins", title: "Manage plugins", category: "Go", execute: () => (location.hash = "#/plugins") }, "host");
328
+ commands.register({ id: "host.docs", title: "Documentation", category: "Help", execute: () => void window.open("https://www.npmjs.com/package/fluxplugin", "_blank") }, "host");
329
+
330
+ menus.register({ id: "app.go", location: "application", label: "Go", order: 10, children: [
331
+ { id: "app.go.home", label: "Dashboard", command: "host.goHome" },
332
+ { id: "app.go.plugins", label: "Plugins", command: "host.goPlugins" },
333
+ ] }, "host");
334
+ menus.register({ id: "app.view", location: "application", label: "View", order: 20, children: [
335
+ { id: "app.view.palette", label: "Command palette", command: "host.palette" },
336
+ { id: "app.view.sep", label: "", separator: true },
337
+ { id: "app.view.reload", label: "Reload", command: "host.reload" },
338
+ ] }, "host");
339
+ menus.register({ id: "app.help", location: "application", label: "Help", order: 90, children: [{ id: "app.help.docs", label: "Documentation", command: "host.docs" }] }, "host");
340
+ }
341
+ `,
342
+ "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 <div className="row">\n <button className="btn" data-testid="notify" onClick={() => framework.manager.notifications.push({ title: "Hello from the host", body: "Plugins can do this too.", level: "success" })}>Show a notification</button>\n </div>\n <pre data-testid="hook-result" className="hint">{result}</pre>\n <PluginSlot name={SLOTS.dashboardAfter} />\n </div>\n );\n}\n',
263
343
  "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',
344
+ "src/app/renderer/providers/AppProviders.tsx": 'import { createContext, useCallback, useContext, type ReactNode } from "react";\nimport type { FluxBridge } from "fluxplugin";\nimport { PluginProvider, StyleHost, ThemeProvider, ToastHost, baseCss, type RendererFramework } from "fluxplugin/react";\nimport { setTitleBarOverlay } from "../services/window";\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 // Keep the native window buttons in sync with the theme (light / dark / plugin themes).\n const onResolved = useCallback((r: { surface: string; fg: string }) => r.surface && setTitleBarOverlay(bridge, r.surface, r.fg), [bridge]);\n return (\n <PluginProvider framework={framework}>\n <BridgeContext.Provider value={bridge}>\n <style>{baseCss}</style>\n <StyleHost />\n <ThemeProvider onResolved={onResolved}>\n {children}\n <ToastHost />\n </ThemeProvider>\n </BridgeContext.Provider>\n </PluginProvider>\n );\n}\n',
265
345
  "src/app/renderer/routes.ts": `import type { RendererFramework } from "fluxplugin/react";
266
346
  import { DashboardPage } from "./pages/DashboardPage";
267
347
  import { PluginsPage } from "./pages/PluginsPage";
@@ -273,12 +353,35 @@ export function registerHostRoutes(framework: RendererFramework): void {
273
353
  }
274
354
  `,
275
355
  "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',
356
+ "src/app/renderer/services/window.ts": 'import type { FluxBridge } from "fluxplugin";\nimport { CHANNELS } from "../../shared/constants";\n\n/** Colours the native window buttons (Windows/Linux). A no-op error in browser mode is ignored on purpose. */\nexport function setTitleBarOverlay(bridge: FluxBridge, color: string, symbolColor: string): void {\n void bridge.invoke(CHANNELS.setTitleBarOverlay, { color, symbolColor }).catch(() => undefined);\n}\n',
357
+ "src/app/renderer/shell/AppShell.tsx": 'import { Breadcrumbs, CommandPalette, PluginRoutes, PluginSlot, TitleBar, useKeybindings } from "fluxplugin/react";\nimport { APP_NAME, SLOTS } from "../../shared/constants";\nimport { Sidebar } from "./Sidebar";\n\n/** Whole window: custom title bar (menus, search, theme, notifications) \u2192 sidebar + routed content. */\nexport function AppShell() {\n useKeybindings(); // runs commands bound to keyboard shortcuts (Mod+Shift+R, ...)\n return (\n <div className="app">\n <TitleBar title={APP_NAME} logo={<PluginSlot name={SLOTS.brandBadges} />} searchPlaceholder="Search commands, pages\u2026" />\n <div className="body">\n <Sidebar />\n <main className="main">\n <Breadcrumbs />\n <PluginRoutes />\n </main>\n </div>\n <CommandPalette />\n </div>\n );\n}\n',
358
+ "src/app/renderer/shell/Sidebar.tsx": 'import { Link, useMenu } from "fluxplugin/react";\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 <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',
359
+ "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-rows: var(--flux-titlebar-h, 40px) 1fr; min-height: 100vh; }\n.body { display: grid; grid-template-columns: 220px 1fr; min-height: 0; }\n.side { border-right: 1px solid var(--flux-border); padding: 16px; background: var(--flux-surface); }\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; overflow: auto; }\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",
360
+ "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/** Height of the custom title bar in px (must match --flux-titlebar-h in CSS). */\nexport const TITLEBAR_HEIGHT = 40;\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 /** Colours the native window buttons (Windows/Linux overlay) to match the current theme. */\n setTitleBarOverlay: "host.window.setTitleBarOverlay",\n} as const;\n\n/** Declarative extension points the host offers to plugins (see renderer/extension-points.ts).\n * The built-in "theme" and "titlebar" points are declared by declareHostPoints(). */\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 brandBadges: "header.badges",\n titlebarLeft: "titlebar.left",\n titlebarRight: "titlebar.right",\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
361
  "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
362
  };
363
+ var HOST_TAILWIND_FILES = {
364
+ "src/app/renderer/index.html": `<!doctype html>
365
+ <html>
366
+ <head>
367
+ <meta charset="utf-8" />
368
+ <title>__APP_NAME__</title>
369
+ <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: fluxplugin:; frame-src https://www.youtube-nocookie.com https://player.vimeo.com" />
370
+ <link rel="stylesheet" href="./renderer.css" />
371
+ <link rel="stylesheet" href="./tailwind.css" />
372
+ </head>
373
+ <body>
374
+ <div id="root"></div>
375
+ <script type="module" src="./renderer.js"></script>
376
+ </body>
377
+ </html>
378
+ `,
379
+ "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="text-xs text-muted">These buttons are contributed by plugins through a host-declared extension point.</p>\n <div className="my-3 flex flex-wrap gap-2" data-testid="toolbar">\n {actions.map((a) => (\n <button key={a.id} className="cursor-pointer rounded-lg border border-border bg-bg px-3 py-1.5 hover:bg-surface" onClick={() => a.command && void framework.manager.commands.execute(a.command)}>\n {a.label}\n </button>\n ))}\n </div>\n <div className="my-3 flex flex-wrap gap-2">\n <button className="cursor-pointer rounded-lg border border-border bg-bg px-3 py-1.5 hover:bg-surface" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>\n <button className="cursor-pointer rounded-lg border border-border bg-bg px-3 py-1.5 hover:bg-surface" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>\n </div>\n <div className="my-3 flex flex-wrap gap-2">\n <button className="cursor-pointer rounded-lg border border-border bg-bg px-3 py-1.5 hover:bg-surface" data-testid="notify" onClick={() => framework.manager.notifications.push({ title: "Hello from the host", body: "Plugins can do this too.", level: "success" })}>Show a notification</button>\n </div>\n <pre data-testid="hook-result" className="text-xs text-muted">{result}</pre>\n <PluginSlot name={SLOTS.dashboardAfter} />\n </div>\n );\n}\n',
380
+ "src/app/renderer/shell/AppShell.tsx": 'import {\n Breadcrumbs,\n CommandPalette,\n PluginRoutes,\n PluginSlot,\n TitleBar,\n useKeybindings,\n} from "fluxplugin/react";\n\nimport { APP_NAME, SLOTS } from "../../shared/constants";\nimport { Sidebar } from "./Sidebar";\n\n/** App shell with a fixed sidebar and independently scrollable content. */\nexport function AppShell() {\n useKeybindings();\n\n return (\n <div className="app grid h-screen grid-rows-[40px_minmax(0,1fr)] overflow-hidden">\n {/* Title bar */}\n <header className="z-50 min-w-0">\n <TitleBar\n title={APP_NAME}\n logo={<PluginSlot name={SLOTS.brandBadges} />}\n searchPlaceholder="Search commands, pages\u2026"\n />\n </header>\n\n {/* Application body */}\n <div className="body grid min-h-0 min-w-0 grid-cols-[220px_minmax(0,1fr)] overflow-hidden">\n {/* Fixed sidebar: never scrolls with the main content */}\n <aside className="h-full min-h-0 overflow-hidden border-r border-transparent">\n <Sidebar />\n </aside>\n\n {/* Only this area scrolls */}\n <main className="main min-h-0 min-w-0 overflow-y-auto overflow-x-hidden p-6">\n <div className="mx-auto max-w-3xl">\n <Breadcrumbs />\n\n <PluginRoutes />\n </div>\n </main>\n </div>\n\n {/* Command palette */}\n <CommandPalette />\n </div>\n );\n}\n',
381
+ "src/app/renderer/shell/Sidebar.tsx": 'import { Link, useMenu } from "fluxplugin/react";\nimport { useEffect, useState } from "react";\n\nconst link =\n "block rounded-lg px-2.5 py-1.5 no-underline text-fg data-[active]:bg-accent data-[active]:text-white";\n\nexport function Sidebar() {\n const { items, run } = useMenu("sidebar");\n const [activePath, setActivePath] = useState(() => {\n const hash = window.location.hash.slice(1);\n return hash || window.location.pathname;\n });\n\n useEffect(() => {\n const updateActivePath = () => {\n setActivePath(window.location.hash.slice(1) || window.location.pathname);\n };\n\n window.addEventListener("hashchange", updateActivePath);\n window.addEventListener("popstate", updateActivePath);\n\n return () => {\n window.removeEventListener("hashchange", updateActivePath);\n window.removeEventListener("popstate", updateActivePath);\n };\n }, []);\n\n const isActive = (path: string) => {\n const normalize = (value: string) =>\n value.replace(/^#/, "").replace(/\\/+$/, "") || "/";\n\n return normalize(activePath) === normalize(path);\n };\n\n return (\n <aside className="side h-full overflow-hidden border-r border-border bg-surface p-4">\n <nav className="flex flex-col gap-1">\n <Link\n to="/"\n data-active={isActive("/") ? "" : undefined}\n aria-current={isActive("/") ? "page" : undefined}\n className={link}\n >\n Dashboard\n </Link>\n\n {items.map((i) => (\n <a\n key={i.id}\n className={link}\n data-active={isActive(i.path!) ? "" : undefined}\n aria-current={isActive(i.path!) ? "page" : undefined}\n href={"#" + i.path}\n onClick={(e) => {\n e.preventDefault();\n setActivePath(i.path!);\n run(i);\n }}\n >\n {i.label}\n </a>\n ))}\n\n <Link\n to="/plugins"\n data-active={isActive("/plugins") ? "" : undefined}\n aria-current={isActive("/plugins") ? "page" : undefined}\n className={link}\n >\n Plugins\n </Link>\n </nav>\n\n <p className="mt-3 text-xs text-muted">Ctrl/Cmd+K: commands</p>\n </aside>\n );\n}\n',
382
+ "src/app/renderer/styles/app.css": "body { margin: 0; font: 14px system-ui; background: var(--flux-bg); color: var(--flux-fg); }\n",
383
+ "src/app/renderer/styles/tailwind.css": '/* Tailwind v4, compiled by `fluxplugin dev|build` to dist/tailwind.css.\n Colors come from the theme variables, so Tailwind utilities follow light / dark / plugin themes automatically:\n use bg-surface, text-fg, border-border, text-muted, bg-accent \u2026 */\n@import "tailwindcss";\n\n/* Plugins are shipped as built JS: scan their dist so the classes they use are generated. Plugins that users install later\n should ship their own precompiled CSS (fluxplugin plugin new my-plugin --tailwindcss). */\n@source "../../../../plugins/*/dist";\n\n@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));\n\n@theme inline {\n --color-bg: var(--flux-bg);\n --color-fg: var(--flux-fg);\n --color-surface: var(--flux-surface);\n --color-border: var(--flux-border);\n --color-accent: var(--flux-accent);\n --color-muted: var(--flux-muted);\n}\n'
384
+ };
282
385
 
283
386
  // src/cli/templates.ts
284
387
  var pkg = (v) => JSON.stringify(
@@ -287,7 +390,7 @@ var pkg = (v) => JSON.stringify(
287
390
  version: "1.0.0",
288
391
  private: true,
289
392
  type: "module",
290
- scripts: { dev: "fluxplugin dev", build: "fluxplugin build", package: "fluxplugin package", validate: "fluxplugin validate" },
393
+ scripts: { dev: "fluxplugin dev", build: "fluxplugin build", package: "fluxplugin package", validate: "fluxplugin validate", release: "fluxplugin publish" },
291
394
  devDependencies: { fluxplugin: "^1.0.0", "@types/react": "^19.0.0", react: "^19.0.0", typescript: "^5.6.0" }
292
395
  },
293
396
  null,
@@ -323,6 +426,14 @@ function basic(v) {
323
426
  version: "1.0.0",
324
427
  description: `${v.name} plugin`,
325
428
  author: "Your name",
429
+ license: "MIT",
430
+ repository: "https://github.com/YOUR_NAME/${v.slug}",
431
+ // fluxplugin publish releases here (replace YOUR_NAME)
432
+ icon: "./assets/icon.svg",
433
+ readme: "./README.md",
434
+ categories: ["Productivity"],
435
+ keywords: ["${v.slug}"],
436
+ links: {},
326
437
  main: "./dist/main.mjs",
327
438
  renderer: "./dist/renderer.js",
328
439
  styles: ["./dist/index.css"],
@@ -373,17 +484,8 @@ export default definePlugin({
373
484
  });
374
485
  `,
375
486
  "src/styles/index.css": styles(v),
376
- "README.md": `# ${v.name}
377
-
378
- Created with \`fluxplugin create\`.
379
-
380
- \`\`\`bash
381
- pnpm install
382
- pnpm dev # rebuild on change (the host hot-reloads it)
383
- pnpm build
384
- pnpm package # produces ${v.id}-1.0.0.tgz
385
- \`\`\`
386
- `
487
+ "assets/icon.svg": iconSvg(v),
488
+ "README.md": readme(v)
387
489
  };
388
490
  }
389
491
  function dashboard(v) {
@@ -391,7 +493,7 @@ function dashboard(v) {
391
493
  const m = JSON.parse(files["plugin.json"]);
392
494
  m.activationEvents = ["onStartup"];
393
495
  m.contributes = {};
394
- m.permissions = ["commands", "routes", "menus", "ui", "styles"];
496
+ m.permissions = ["commands", "routes", "menus", "ui", "styles", "notifications"];
395
497
  files["plugin.json"] = JSON.stringify(m, null, 2);
396
498
  files["src/renderer.tsx"] = `import { definePlugin } from "fluxplugin";
397
499
  import { usePluginState } from "fluxplugin/react";
@@ -414,9 +516,21 @@ function ${v.Pascal}Page() {
414
516
  export default definePlugin({
415
517
  routes: [{ path: "/${v.slug}", lazy: async () => ({ default: ${v.Pascal}Page }), meta: { title: "${v.name}", breadcrumb: "${v.name}", menu: { label: "${v.name}" } } }],
416
518
  components: [{ slot: "dashboard.after", component: ${v.Pascal}Widget, priority: 10 }],
417
- // Data contribution to the host's "dashboard" extension point (validated by the host's schema).
418
- extensions: [{ point: "dashboard", contribution: { toolbar: [{ id: "${v.slug}-hello", label: "${v.name}: hello", command: "${v.slug}.hello" }] } }],
419
519
  commands: [{ id: "${v.slug}.hello", title: "${v.name}: say hello", category: "${v.name}", execute: () => console.log("Hello from ${v.name}!") }],
520
+ // Application menu: adds a top-level "${v.name}" menu to the title bar (File/Edit-style). Shortcuts come from the command's keybinding.
521
+ menus: [{ id: "${v.slug}.menu", location: "application", label: "${v.name}", order: 50,
522
+ children: [{ id: "${v.slug}.menu.open", label: "Open ${v.name}", path: "/${v.slug}" }, { id: "${v.slug}.menu.hello", label: "Say hello", command: "${v.slug}.hello" }] }],
523
+ // Title-bar search: your results appear next to commands and pages.
524
+ extensions: [
525
+ { point: "dashboard", contribution: { toolbar: [{ id: "${v.slug}-hello", label: "${v.name}: hello", command: "${v.slug}.hello" }] } },
526
+ { point: "titlebar", contribution: { searchProviders: [{ id: "${v.slug}.search", label: "${v.name}",
527
+ search: (q: string) => ("${v.slug}".includes(q.toLowerCase()) ? [{ id: "${v.slug}.r1", title: "Open ${v.name}", subtitle: "page", run: () => { location.hash = "#/${v.slug}"; } }] : []) }] } },
528
+ ],
529
+ // A theme: tokens are CSS variables; the user picks it in the title bar.
530
+ // { point: "theme", contribution: { themes: [{ id: "${v.slug}.ocean", label: "Ocean", dark: true, tokens: { "--flux-bg": "#06202b", "--flux-surface": "#0a2c3a", "--flux-fg": "#e6f6ff", "--flux-border": "#14465a", "--flux-accent": "#22d3ee" } }] } },
531
+ onActivate(ctx) {
532
+ ctx.notifications.push({ title: "${v.name} is ready", body: "Open the title-bar bell to see this notification.", level: "success" });
533
+ },
420
534
  });
421
535
  `;
422
536
  return files;
@@ -475,6 +589,75 @@ function varsFromName(name) {
475
589
  const Pascal = short.split(/[^a-z0-9]+/i).filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join("");
476
590
  return { id, name: short.split("-").map((p) => p[0].toUpperCase() + p.slice(1)).join(" "), slug: short.replace(/-/g, "_"), Pascal };
477
591
  }
592
+ function iconSvg(v) {
593
+ let h = 0;
594
+ for (const c of v.id) h = (h * 31 + c.charCodeAt(0)) % 360;
595
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="22" fill="hsl(${h} 70% 48%)"/><text x="48" y="64" font-family="system-ui,sans-serif" font-size="52" font-weight="700" text-anchor="middle" fill="#fff">${v.name[0].toUpperCase()}</text></svg>
596
+ `;
597
+ }
598
+ function readme(v) {
599
+ return `# ${v.name}
600
+
601
+ One or two sentences that say what this plugin does and who it is for.
602
+
603
+ ## What you get
604
+
605
+ - A **page** at \`/${v.slug}\` in the sidebar
606
+ - A **widget** on the dashboard
607
+ - A **command** you can run from the command palette (Ctrl/Cmd+K)
608
+
609
+ ## How to use it
610
+
611
+ 1. Enable the plugin in **Plugins**.
612
+ 2. Open **${v.name}** in the sidebar.
613
+ 3. Configure it in the plugin's *Settings* section.
614
+
615
+ ## Permissions
616
+
617
+ | Permission | Why |
618
+ | --- | --- |
619
+ | \`routes\`, \`menus\` | adds its page and sidebar entry |
620
+ | \`ui\` | adds a dashboard widget |
621
+ | \`commands\` | registers its commands |
622
+
623
+ ## Video & screenshots
624
+
625
+ Add them in \`plugin.json\` and they appear on this page:
626
+
627
+ \`\`\`json
628
+ "video": "https://www.youtube.com/watch?v=YOUR_VIDEO_ID",
629
+ "banner": "./assets/banner.png",
630
+ "screenshots": ["./assets/shot-1.png", "./assets/shot-2.png"],
631
+ "links": { "docs": "https://example.com/docs", "support": "https://example.com/support" }
632
+ \`\`\`
633
+
634
+ ## Changelog
635
+
636
+ - **1.0.0** \u2014 first release
637
+ `;
638
+ }
639
+ function withTailwind(files, v) {
640
+ const m = JSON.parse(files["plugin.json"]);
641
+ m.styles = ["./dist/index.css", "./dist/tailwind.css"];
642
+ files["plugin.json"] = JSON.stringify(m, null, 2);
643
+ const pkg2 = JSON.parse(files["package.json"]);
644
+ pkg2.devDependencies = { ...pkg2.devDependencies, tailwindcss: "^4.0.0", "@tailwindcss/cli": "^4.0.0" };
645
+ files["package.json"] = JSON.stringify(pkg2, null, 2);
646
+ files["src/styles/tailwind.css"] = `/* Utilities only: the host owns the preflight/reset. Colors follow the host theme. */
647
+ @import "tailwindcss/theme.css" layer(theme);
648
+ @import "tailwindcss/utilities.css" layer(utilities);
649
+
650
+ @theme inline {
651
+ --color-surface: var(--flux-surface);
652
+ --color-border: var(--flux-border);
653
+ --color-accent: var(--flux-accent);
654
+ --color-muted: var(--flux-muted);
655
+ --color-fg: var(--flux-fg);
656
+ }
657
+ `;
658
+ files["src/renderer.tsx"] = files["src/renderer.tsx"].split(`className="${v.slug}-card"`).join('className="rounded-xl border border-border bg-surface p-4"');
659
+ return files;
660
+ }
478
661
 
479
662
  // src/cli/host-templates.ts
480
663
  function hostFiles(v, withSample = true) {
@@ -495,31 +678,36 @@ function hostFiles(v, withSample = true) {
495
678
  typecheck: "tsc --noEmit"
496
679
  },
497
680
  dependencies: { fluxplugin: v.fluxSpec, react: "^19.0.0", "react-dom": "^19.0.0" },
498
- devDependencies: { electron: "^33.0.0", typescript: "~5.9.3", "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0" }
681
+ devDependencies: { electron: v.electron ?? "^44.5.1", typescript: "~5.9.3", "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", ...v.tailwind ? { tailwindcss: "^4.0.0", "@tailwindcss/cli": "^4.0.0" } : {} },
682
+ // npm 11.16+/12 and pnpm 10 block dependency install scripts unless allowed; Electron needs its postinstall to download the binary.
683
+ allowScripts: { electron: true, esbuild: true },
684
+ pnpm: { onlyBuiltDependencies: ["electron", "esbuild"] }
499
685
  },
500
686
  null,
501
687
  2
502
688
  ),
503
- "fluxplugin.config.json": JSON.stringify({ pluginsDir: "plugins", outDir: "dist" }, null, 2),
689
+ // "stores" is used by the CLI (fluxplugin search/install) AND by your app (Plugins → Available). Add your own store here.
690
+ "fluxplugin.config.json": JSON.stringify({ pluginsDir: "plugins", outDir: "dist", stores: [{ type: "github", topic: "fluxplugin-plugin" }] }, null, 2),
504
691
  "tsconfig.json": JSON.stringify(
505
- { compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: true, lib: ["ES2022", "DOM", "DOM.Iterable"], types: ["node"] }, include: ["src"] },
692
+ { compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: true, resolveJsonModule: true, lib: ["ES2022", "DOM", "DOM.Iterable"], types: ["node"] }, include: ["src"] },
506
693
  null,
507
694
  2
508
695
  ),
509
696
  ".gitignore": "node_modules\ndist\n.flux-data\n*.tgz\n",
510
- "README.md": readme(v.name)
697
+ "README.md": readme2(v.name)
511
698
  };
512
699
  for (const [rel, content] of Object.entries(HOST_APP_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name);
700
+ if (v.tailwind) for (const [rel, content] of Object.entries(HOST_TAILWIND_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name);
513
701
  if (withSample) {
514
702
  const sample = TEMPLATES.dashboard(varsFromName("hello"));
515
703
  for (const [rel, content] of Object.entries(sample)) {
516
- if (rel === "package.json" || rel === "tsconfig.json" || rel === "README.md") continue;
704
+ if (rel === "package.json" || rel === "tsconfig.json") continue;
517
705
  files[`plugins/hello/${rel}`] = content;
518
706
  }
519
707
  }
520
708
  return files;
521
709
  }
522
- var readme = (name) => `# ${name}
710
+ var readme2 = (name) => `# ${name}
523
711
 
524
712
  Created with [fluxplugin](https://www.npmjs.com/package/fluxplugin).
525
713
 
@@ -549,30 +737,218 @@ plugins/ plugins loaded at startup (users can also install .tgz from the P
549
737
  \`\`\`
550
738
  `;
551
739
 
740
+ // src/cli/package.ts
741
+ import crypto from "node:crypto";
742
+ import { promises as fs4 } from "node:fs";
743
+ import os from "node:os";
744
+ import path5 from "node:path";
745
+ async function packagePlugin(root, opts = {}) {
746
+ const log2 = opts.log ?? console.log;
747
+ await buildPlugin(root, false, log2);
748
+ let raw = JSON.parse(await fs4.readFile(path5.join(root, "plugin.json"), "utf8"));
749
+ const v = validateManifest(raw);
750
+ if (!v.manifest) throw new Error("invalid plugin.json: " + v.issues.map((i) => `${i.path}: ${i.message}`).join("; "));
751
+ const m = v.manifest;
752
+ raw["integrity"] = await computeIntegrity(root, m);
753
+ if (opts.signKey) raw = signManifest(raw, (await fs4.readFile(opts.signKey, "utf8")).trim());
754
+ const stage = await fs4.mkdtemp(path5.join(os.tmpdir(), "flux-pack-"));
755
+ try {
756
+ const shipped = await copyPluginRuntime(root, stage);
757
+ await fs4.writeFile(path5.join(stage, "plugin.json"), JSON.stringify(raw, null, 2));
758
+ const bad = shipped.filter((f) => /\.(map|tsx?)$/i.test(f) || f.startsWith("src/"));
759
+ if (bad.length) throw new Error(`refusing to package sources/sourcemaps: ${bad.join(", ")}`);
760
+ const buf = await createTarGz(stage);
761
+ const out = path5.resolve(opts.out ?? ".");
762
+ await fs4.mkdir(out, { recursive: true });
763
+ const file = path5.join(out, `${m.id}-${m.version}.tgz`);
764
+ await fs4.writeFile(file, buf);
765
+ const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
766
+ const descriptor = {
767
+ id: m.id,
768
+ name: m.name,
769
+ version: m.version,
770
+ description: m.description,
771
+ author: typeof m.author === "string" ? m.author : m.author?.name,
772
+ permissions: m.permissions ?? [],
773
+ file: path5.basename(file),
774
+ sha256
775
+ };
776
+ const descriptorFile = path5.join(out, `${m.id}-${m.version}.fluxplugin.json`);
777
+ await fs4.writeFile(descriptorFile, JSON.stringify(descriptor, null, 2));
778
+ log2(`packaged ${path5.relative(process.cwd(), file) || file} (${shipped.length} files: ${shipped.filter((x) => x !== "plugin.json").slice(0, 6).join(", ")}${shipped.length > 7 ? ", \u2026" : ""})
779
+ sha256 ${sha256}${opts.signKey ? "\n signed" : ""}`);
780
+ return { file, descriptorFile, sha256, manifest: m, shipped, signed: !!opts.signKey };
781
+ } finally {
782
+ await fs4.rm(stage, { recursive: true, force: true });
783
+ }
784
+ }
785
+
786
+ // src/cli/publish.ts
787
+ import { promises as fs5 } from "node:fs";
788
+ import os2 from "node:os";
789
+ import path6 from "node:path";
790
+ function parseGitHubRepo(input) {
791
+ if (!input) return void 0;
792
+ const m = /^(?:https?:\/\/github\.com\/|git@github\.com:|github:)?([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(input.trim());
793
+ return m ? `${m[1]}/${m[2]}` : void 0;
794
+ }
795
+ async function publishPlugin(root, o = {}) {
796
+ const log2 = o.log ?? console.log;
797
+ const manifest = JSON.parse(await fs5.readFile(path6.join(root, "plugin.json"), "utf8"));
798
+ const repo = parseGitHubRepo(o.repo) ?? parseGitHubRepo(typeof manifest["repository"] === "string" ? manifest["repository"] : manifest["repository"]?.url);
799
+ if (!repo || /YOUR_NAME/i.test(JSON.stringify(manifest["repository"] ?? ""))) {
800
+ throw new Error('no GitHub repository: set "repository": "https://github.com/<you>/<repo>" in plugin.json (or pass --repo owner/name)');
801
+ }
802
+ const problems = [];
803
+ if (!manifest["description"]) problems.push("description is missing");
804
+ if (!manifest["icon"]) problems.push('"icon" is missing');
805
+ if (!manifest["license"]) problems.push('"license" is missing');
806
+ if (problems.length) throw new Error("not ready for the store: " + problems.join("; "));
807
+ for (const rec of ["screenshots", "video", "banner"]) if (!manifest[rec] || Array.isArray(manifest[rec]) && !manifest[rec].length) log2(` tip: add "${rec}" to plugin.json for a better store page`);
808
+ const out = await fs5.mkdtemp(path6.join(os2.tmpdir(), "flux-publish-"));
809
+ try {
810
+ const pkg2 = await packagePlugin(root, { out, signKey: o.signKey, log: log2 });
811
+ const tag = `v${pkg2.manifest.version}`;
812
+ if (o.dryRun) {
813
+ log2(`
814
+ [dry run] would create release ${tag} in github.com/${repo} with:
815
+ - ${path6.basename(pkg2.file)}
816
+ - ${path6.basename(pkg2.descriptorFile)}
817
+ and add the topic "${o.topic ?? DEFAULT_TOPIC}"`);
818
+ return { repo, version: pkg2.manifest.version, id: pkg2.manifest.id };
819
+ }
820
+ const token = o.token ?? process.env["GITHUB_TOKEN"] ?? process.env["GH_TOKEN"];
821
+ if (!token) throw new Error("no GitHub token. Create one at https://github.com/settings/tokens (repo scope, or fine-grained: Contents + Administration read/write) and set GITHUB_TOKEN, or pass --token");
822
+ const api = (o.apiBase ?? "https://api.github.com").replace(/\/$/, "");
823
+ const f = o.fetchImpl ?? fetch;
824
+ const gh = async (method, url, body, extra = {}, raw) => {
825
+ const res = await f(url, { method, headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "user-agent": "fluxplugin", ...body ? { "content-type": "application/json" } : {}, ...extra }, body: raw ?? (body ? JSON.stringify(body) : void 0) });
826
+ let json = null;
827
+ try {
828
+ json = await res.json();
829
+ } catch {
830
+ }
831
+ return { status: res.status, json };
832
+ };
833
+ const info = await gh("GET", `${api}/repos/${repo}`);
834
+ if (info.status === 404) throw new Error(`repository github.com/${repo} was not found (or your token cannot see it)`);
835
+ if (info.status === 401) throw new Error("GitHub rejected the token (401)");
836
+ if (info.json?.permissions && !info.json.permissions.push) throw new Error(`your token cannot write to ${repo}`);
837
+ const notes = `${pkg2.manifest.name} ${pkg2.manifest.version}
838
+
839
+ ${pkg2.manifest.description ?? ""}
840
+
841
+ Permissions: ${(pkg2.manifest.permissions ?? []).join(", ") || "none"}
842
+
843
+ Install: \`fluxplugin install ${pkg2.manifest.id}\` or from the Plugins page of any app using the fluxplugin store.`;
844
+ const rel = await gh("POST", `${api}/repos/${repo}/releases`, { tag_name: tag, name: `${pkg2.manifest.name} ${pkg2.manifest.version}`, body: notes, draft: false, prerelease: /-/.test(pkg2.manifest.version) });
845
+ if (rel.status === 422) throw new Error(`version ${pkg2.manifest.version} is already published (release ${tag} exists). Bump "version" in plugin.json and publish again.`);
846
+ if (rel.status >= 300) throw new Error(`could not create the release (${rel.status}): ${rel.json?.message ?? "unknown error"}`);
847
+ const upload = String(rel.json.upload_url).replace(/\{\?[^}]*\}$/, "");
848
+ for (const [file, type] of [[pkg2.file, "application/gzip"], [pkg2.descriptorFile, "application/json"]]) {
849
+ const up = await gh("POST", `${upload}?name=${encodeURIComponent(path6.basename(file))}`, void 0, { "content-type": type }, new Uint8Array(await fs5.readFile(file)));
850
+ if (up.status >= 300) throw new Error(`upload of ${path6.basename(file)} failed (${up.status}): ${up.json?.message ?? ""}`);
851
+ }
852
+ const topics = await gh("GET", `${api}/repos/${repo}/topics`);
853
+ const names = topics.json?.names ?? [];
854
+ const topic = o.topic ?? DEFAULT_TOPIC;
855
+ if (!names.includes(topic)) {
856
+ const put = await gh("PUT", `${api}/repos/${repo}/topics`, { names: [...names, topic] });
857
+ if (put.status >= 300) log2(` ! could not add the topic "${topic}" (${put.status}): add it yourself in the repository settings (About \u2192 Topics) so the store can find your plugin.`);
858
+ }
859
+ log2(`
860
+ \u2714 published ${pkg2.manifest.id}@${pkg2.manifest.version}
861
+ release: ${rel.json.html_url ?? `https://github.com/${repo}/releases/tag/${tag}`}
862
+ users install it with: fluxplugin install ${pkg2.manifest.id}
863
+ (new GitHub topics can take a few minutes to show up in search)`);
864
+ return { release: rel.json.html_url, repo, version: pkg2.manifest.version, id: pkg2.manifest.id };
865
+ } finally {
866
+ await fs5.rm(out, { recursive: true, force: true });
867
+ }
868
+ }
869
+
870
+ // src/cli/store.ts
871
+ import crypto2 from "node:crypto";
872
+ import { promises as fs6 } from "node:fs";
873
+ import path7 from "node:path";
874
+ var INDEX = "index.json";
875
+ async function storeInit(dir) {
876
+ await fs6.mkdir(dir, { recursive: true });
877
+ const idx = path7.join(dir, INDEX);
878
+ if (await fs6.stat(idx).catch(() => null)) throw new Error(`${idx} already exists`);
879
+ await fs6.writeFile(idx, JSON.stringify({ plugins: [] }, null, 2));
880
+ await fs6.writeFile(path7.join(dir, "README.md"), `# My plugin store
881
+
882
+ A store is a folder with \`index.json\` and the plugin packages (\`.tgz\`).
883
+
884
+ \`\`\`bash
885
+ fluxplugin package ./my-plugin --out . # build a package
886
+ fluxplugin store add com.acme.my-plugin-1.0.0.tgz --dir . --base-url https://plugins.acme.com/
887
+ \`\`\`
888
+
889
+ Host this folder anywhere that serves static files:
890
+ - **GitHub Pages** (or a repo's raw files): add \`{ "type": "github", "repo": "acme/plugin-store" }\` to your app's stores
891
+ - **Any web server / S3 / CDN**: add \`{ "type": "http", "url": "https://plugins.acme.com/" }\`
892
+ - **A shared folder**: \`{ "type": "local", "path": "./my-store" }\`
893
+ `);
894
+ }
895
+ async function storeAdd(pkgFile, dir, baseUrl) {
896
+ const buf = await fs6.readFile(pkgFile);
897
+ const entries = await readTarGz(buf);
898
+ const pj = entries.find((e) => e.type === "file" && /^[^/]+\/plugin\.json$/.test(e.name));
899
+ if (!pj) throw new Error("not a fluxplugin package (no plugin.json)");
900
+ const v = validateManifest(JSON.parse(pj.data.toString("utf8")));
901
+ if (!v.manifest) throw new Error("invalid plugin.json in the package: " + v.issues.map((i) => i.message).join("; "));
902
+ const bad = entries.filter((e) => e.type === "file" && /\.(map|tsx?)$/i.test(e.name));
903
+ if (bad.length) throw new Error(`the package contains sources/sourcemaps (${bad[0].name}); rebuild it with "fluxplugin package"`);
904
+ const m = v.manifest;
905
+ const sha256 = crypto2.createHash("sha256").update(buf).digest("hex");
906
+ await fs6.mkdir(dir, { recursive: true });
907
+ const fileName = path7.basename(pkgFile);
908
+ if (path7.resolve(pkgFile) !== path7.resolve(dir, fileName)) await fs6.copyFile(pkgFile, path7.join(dir, fileName));
909
+ const idxFile = path7.join(dir, INDEX);
910
+ const idx = JSON.parse(await fs6.readFile(idxFile, "utf8").catch(() => '{"plugins":[]}'));
911
+ const url = baseUrl ? new URL(fileName, baseUrl.endsWith("/") ? baseUrl : baseUrl + "/").href : fileName;
912
+ const entry = { id: m.id, name: m.name, version: m.version, description: m.description, author: typeof m.author === "string" ? m.author : m.author?.name, permissions: m.permissions ?? [], url, sha256, homepage: m.homepage };
913
+ idx.plugins = [...idx.plugins.filter((p) => !(p.id === m.id && p.version === m.version)), entry].sort((a, b) => a.id.localeCompare(b.id) || a.version.localeCompare(b.version, void 0, { numeric: true }));
914
+ await fs6.writeFile(idxFile, JSON.stringify(idx, null, 2));
915
+ return { id: m.id, version: m.version, sha256 };
916
+ }
917
+
552
918
  // src/cli/cli.ts
553
919
  var HELP = `fluxplugin <command>
554
920
 
555
- init [dir] create a new app (Electron + React) that supports plugins
921
+ init [dir] [--tailwindcss] create a new app (Electron + React) that supports plugins
922
+ doctor check Node, Electron, esbuild, Tailwind and fix what it can
556
923
  dev [--web] run your app with plugin hot reload (Electron, or a browser with --web)
557
924
  build bundle your app (in an app folder) or a plugin (in a plugin folder)
558
925
  start [--web] run the built app
559
- plugin new <name> [-t basic|dashboard|backend] scaffold a plugin (alias: create)
926
+ plugin new <name> [-t basic|dashboard|backend] [--tailwindcss] scaffold a plugin (alias: create)
560
927
  validate [dir] check a plugin manifest, entries and integrity
928
+ publish [dir] [--repo o/r] [--dry-run] publish a plugin to the store (GitHub release + topic)
929
+ search [query] search the configured stores
930
+ install <id[@version]|dir|.tgz> install a plugin from a store, a folder or a package
931
+ store init <dir> create your own store (index.json) \u2014 host it anywhere
932
+ store add <pkg.tgz> [--dir d] [--base-url url] add a package to your store
561
933
  package [dir] [--out d] [--sign-key file] build + integrity (+ signature) + .tgz
562
934
  install <dir|.tgz> [--dir plugins] uninstall <id> enable <id> disable <id> list
563
935
  keygen [--out name] create an ed25519 signing key pair
564
936
  tailwind [--dir plugins] [--out file] generate @source lines for Tailwind v4`;
565
937
  var log = (...a) => console.log(...a);
566
938
  async function readManifest(root) {
567
- return JSON.parse(await fs4.readFile(path5.join(root, "plugin.json"), "utf8"));
939
+ return JSON.parse(await fs7.readFile(path8.join(root, "plugin.json"), "utf8"));
568
940
  }
569
941
  async function run(argv) {
570
942
  const { values: f, positionals: pos } = parseArgs({
571
943
  args: argv,
572
944
  allowPositionals: true,
573
- options: { template: { type: "string", short: "t" }, out: { type: "string" }, dir: { type: "string" }, data: { type: "string" }, "sign-key": { type: "string" }, help: { type: "boolean", short: "h" }, web: { type: "boolean" }, port: { type: "string" }, name: { type: "string" }, "no-sample": { type: "boolean" } }
945
+ options: { template: { type: "string", short: "t" }, out: { type: "string" }, dir: { type: "string" }, data: { type: "string" }, "sign-key": { type: "string" }, help: { type: "boolean", short: "h" }, web: { type: "boolean" }, port: { type: "string" }, name: { type: "string" }, "no-sample": { type: "boolean" }, tailwindcss: { type: "boolean" }, repo: { type: "string" }, token: { type: "string" }, "dry-run": { type: "boolean" }, "base-url": { type: "string" } }
574
946
  });
575
947
  let [cmd, arg] = pos;
948
+ if (cmd === "store") {
949
+ cmd = "store:" + (pos[1] ?? "");
950
+ arg = pos[2];
951
+ }
576
952
  if (cmd === "plugin") {
577
953
  const sub = pos[1];
578
954
  if (sub !== "new") throw new Error("usage: fluxplugin plugin new <name>");
@@ -580,20 +956,20 @@ async function run(argv) {
580
956
  arg = pos[2];
581
957
  }
582
958
  if (!cmd || f.help) return log(HELP), cmd ? 0 : 1;
583
- const root = path5.resolve(arg ?? ".");
959
+ const root = path8.resolve(arg ?? ".");
584
960
  try {
585
961
  switch (cmd) {
586
962
  case "init": {
587
- const target = path5.resolve(arg ?? ".");
588
- const entries = await fs4.readdir(target).catch(() => []);
963
+ const target = path8.resolve(arg ?? ".");
964
+ const entries = await fs7.readdir(target).catch(() => []);
589
965
  if (entries.length) throw new Error(`${target} is not empty`);
590
- const name = (f.name ?? path5.basename(target)).toLowerCase().replace(/[^a-z0-9-_.]+/g, "-");
591
- const files = hostFiles({ name, fluxSpec: process.env["FLUXPLUGIN_SPEC"] ?? `^${await ownVersion()}` }, !f["no-sample"]);
966
+ const name = (f.name ?? path8.basename(target)).toLowerCase().replace(/[^a-z0-9-_.]+/g, "-");
967
+ const files = hostFiles({ name, fluxSpec: process.env["FLUXPLUGIN_SPEC"] ?? `^${await ownVersion()}`, electron: await latestElectron(), tailwind: !!f.tailwindcss }, !f["no-sample"]);
592
968
  for (const [rel2, content] of Object.entries(files)) {
593
- await fs4.mkdir(path5.dirname(path5.join(target, rel2)), { recursive: true });
594
- await fs4.writeFile(path5.join(target, rel2), content);
969
+ await fs7.mkdir(path8.dirname(path8.join(target, rel2)), { recursive: true });
970
+ await fs7.writeFile(path8.join(target, rel2), content);
595
971
  }
596
- const rel = path5.relative(process.cwd(), target) || ".";
972
+ const rel = path8.relative(process.cwd(), target) || ".";
597
973
  log(`\u2714 created ${name} in ${target}
598
974
 
599
975
  Next steps:
@@ -609,15 +985,17 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
609
985
  if (!arg) throw new Error("usage: fluxplugin plugin new <name> [-t basic|dashboard|backend]");
610
986
  const tpl = f.template ?? "basic";
611
987
  if (!(tpl in TEMPLATES)) throw new Error(`unknown template "${tpl}"`);
612
- const base = await isHostProject(process.cwd()) && !path5.isAbsolute(arg) && !arg.includes("/") ? path5.join((await loadConfig({}, process.cwd())).pluginsDir, arg) : path5.resolve(arg);
613
- if (await fs4.stat(base).catch(() => null)) throw new Error(`${base} already exists`);
614
- const files = TEMPLATES[tpl](varsFromName(path5.basename(base)));
988
+ const base = await isHostProject(process.cwd()) && !path8.isAbsolute(arg) && !arg.includes("/") ? path8.join((await loadConfig({}, process.cwd())).pluginsDir, arg) : path8.resolve(arg);
989
+ if (await fs7.stat(base).catch(() => null)) throw new Error(`${base} already exists`);
990
+ const vars = varsFromName(path8.basename(base));
991
+ let files = TEMPLATES[tpl](vars);
992
+ if (f.tailwindcss) files = withTailwind(files, vars);
615
993
  for (const [rel, content] of Object.entries(files)) {
616
- await fs4.mkdir(path5.dirname(path5.join(base, rel)), { recursive: true });
617
- await fs4.writeFile(path5.join(base, rel), content);
994
+ await fs7.mkdir(path8.dirname(path8.join(base, rel)), { recursive: true });
995
+ await fs7.writeFile(path8.join(base, rel), content);
618
996
  }
619
997
  log(`\u2714 created ${tpl} plugin in ${base}
620
- next: fluxplugin build ${path5.relative(process.cwd(), base)}`);
998
+ next: fluxplugin build ${path8.relative(process.cwd(), base)}`);
621
999
  return 0;
622
1000
  }
623
1001
  case "build": {
@@ -649,30 +1027,57 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
649
1027
  }
650
1028
  case "start": {
651
1029
  const cwd = process.cwd();
652
- if (!await fs4.stat(path5.join(cwd, "dist/index.html")).catch(() => null)) throw new Error("nothing built yet: run `fluxplugin build` first");
1030
+ if (!await fs7.stat(path8.join(cwd, "dist/index.html")).catch(() => null)) throw new Error("nothing built yet: run `fluxplugin build` first");
653
1031
  const { child } = startApp(cwd, { web: f.web, port: f.port ? Number(f.port) : void 0 });
654
1032
  return await new Promise((r) => child.on("exit", (c) => r(c ?? 0)));
655
1033
  }
1034
+ case "doctor":
1035
+ return await doctor(process.cwd());
656
1036
  case "validate":
657
- return validate(root);
1037
+ return await validate(root);
658
1038
  case "package":
659
- return pack(root, f.out, f["sign-key"]);
1039
+ return await packagePlugin(root, { out: f.out, signKey: f["sign-key"] }), 0;
1040
+ case "publish":
1041
+ await publishPlugin(root, { repo: f.repo, token: f.token, signKey: f["sign-key"], dryRun: f["dry-run"] });
1042
+ return 0;
1043
+ case "search": {
1044
+ const cfg = await loadConfig(f);
1045
+ const regs = createRegistries(cfg.stores);
1046
+ const res = await Promise.allSettled(regs.map((r) => r.search(arg)));
1047
+ let n = 0;
1048
+ res.forEach((r, i) => {
1049
+ if (r.status === "rejected") return void log(`! ${regs[i].name}: ${r.reason.message}`);
1050
+ for (const e of r.value) n++, log(`${e.id}@${e.version} ${e.name}${e.description ? " \u2014 " + e.description : ""} [${e.source}]`);
1051
+ });
1052
+ if (!n) log("no plugins found");
1053
+ return 0;
1054
+ }
1055
+ case "store:init":
1056
+ await storeInit(path8.resolve(arg ?? "store"));
1057
+ log(`\u2714 store created in ${path8.resolve(arg ?? "store")} (see its README.md)`);
1058
+ return 0;
1059
+ case "store:add": {
1060
+ if (!arg) throw new Error("usage: store add <package.tgz> [--dir store] [--base-url https://\u2026]");
1061
+ const r = await storeAdd(path8.resolve(arg), path8.resolve(f.dir ?? "store"), f["base-url"]);
1062
+ log(`\u2714 added ${r.id}@${r.version} to ${path8.resolve(f.dir ?? "store")}/index.json`);
1063
+ return 0;
1064
+ }
660
1065
  case "keygen": {
661
1066
  const k = generateSigningKeyPair();
662
1067
  const base = f.out ?? "flux-signing";
663
- await fs4.writeFile(`${base}.private.key`, k.privateKey, { mode: 384 });
664
- await fs4.writeFile(`${base}.public.key`, k.publicKey);
1068
+ await fs7.writeFile(`${base}.private.key`, k.privateKey, { mode: 384 });
1069
+ await fs7.writeFile(`${base}.public.key`, k.publicKey);
665
1070
  log(`wrote ${base}.private.key (keep secret) and ${base}.public.key (distribute to hosts as a trusted key)`);
666
1071
  return 0;
667
1072
  }
668
1073
  case "tailwind": {
669
1074
  const cfg = await loadConfig(f);
670
- const names = (await fs4.readdir(cfg.pluginsDir).catch(() => [])).sort();
671
- const lines = names.map((n) => `@source "${path5.join(cfg.pluginsDir, n, "src").replace(/\\/g, "/")}";`);
1075
+ const names = (await fs7.readdir(cfg.pluginsDir).catch(() => [])).sort();
1076
+ const lines = names.map((n) => `@source "${path8.join(cfg.pluginsDir, n, "src").replace(/\\/g, "/")}";`);
672
1077
  const css = `/* generated by fluxplugin tailwind */
673
1078
  ${lines.join("\n")}
674
1079
  `;
675
- if (f.out) await fs4.writeFile(f.out, css);
1080
+ if (f.out) await fs7.writeFile(f.out, css);
676
1081
  else log(css);
677
1082
  return 0;
678
1083
  }
@@ -681,7 +1086,7 @@ ${lines.join("\n")}
681
1086
  case "enable":
682
1087
  case "disable":
683
1088
  case "list":
684
- return manage(cmd, arg, f);
1089
+ return await manage(cmd, arg, f);
685
1090
  default:
686
1091
  log(HELP);
687
1092
  return 1;
@@ -699,7 +1104,7 @@ async function validate(root) {
699
1104
  v.warnings.forEach((w) => log(`! ${w}`));
700
1105
  if (v.manifest) {
701
1106
  for (const rel of [v.manifest.main, v.manifest.renderer, ...v.manifest.styles ?? []].filter(Boolean))
702
- if (!await fs4.stat(path5.join(root, rel)).catch(() => null)) log(`\u2716 missing file ${rel} (run build)`), bad = true;
1107
+ if (!await fs7.stat(path8.join(root, rel)).catch(() => null)) log(`\u2716 missing file ${rel} (run build)`), bad = true;
703
1108
  try {
704
1109
  await verifyPlugin(root, raw, v.manifest);
705
1110
  } catch (e) {
@@ -710,47 +1115,38 @@ async function validate(root) {
710
1115
  log(bad ? "invalid" : `\u2714 ${v.manifest?.id}@${v.manifest?.version} is valid`);
711
1116
  return bad ? 1 : 0;
712
1117
  }
713
- async function pack(root, out = ".", keyFile) {
714
- await buildPlugin(root);
715
- let raw = await readManifest(root);
716
- const m = validateManifest(raw).manifest;
717
- if (!m) throw new Error("invalid manifest");
718
- raw["integrity"] = await computeIntegrity(root, m);
719
- if (keyFile) raw = signManifest(raw, (await fs4.readFile(keyFile, "utf8")).trim());
720
- const stage = await fs4.mkdtemp(path5.join((await import("node:os")).tmpdir(), "flux-pack-"));
721
- await copyDir(root, stage);
722
- await fs4.rm(path5.join(stage, "src"), { recursive: true, force: true });
723
- await fs4.writeFile(path5.join(stage, "plugin.json"), JSON.stringify(raw, null, 2));
724
- const buf = await createTarGz(stage);
725
- await fs4.rm(stage, { recursive: true, force: true });
726
- await fs4.mkdir(out, { recursive: true });
727
- const file = path5.join(out, `${m.id}-${m.version}.tgz`);
728
- await fs4.writeFile(file, buf);
729
- log(`packaged ${file}
730
- sha256 ${crypto.createHash("sha256").update(buf).digest("hex")}${keyFile ? "\n signed" : ""}`);
731
- return 0;
732
- }
733
1118
  async function manage(cmd, arg, f) {
734
1119
  const cfg = await loadConfig(f);
735
- const state = new JsonStateFile(path5.join(cfg.dataDir, "plugins-state.json"));
736
- const inst = new PluginInstaller(cfg.pluginsDir, path5.join(cfg.dataDir, "backups"));
1120
+ const state = new JsonStateFile(path8.join(cfg.dataDir, "plugins-state.json"));
1121
+ const inst = new PluginInstaller(cfg.pluginsDir, path8.join(cfg.dataDir, "backups"));
737
1122
  if (cmd === "list") {
738
1123
  const { disabled } = await state.readAll();
739
1124
  for (const m of await inst.installed()) log(`${disabled.includes(m.id) ? "\u25CB" : "\u25CF"} ${m.id}@${m.version} ${m.name}`);
740
1125
  return 0;
741
1126
  }
742
1127
  if (!arg) throw new Error(`usage: ${cmd} <${cmd === "install" ? "dir|.tgz" : "id"}>`);
1128
+ if (cmd === "install" && arg && !arg.endsWith(".tgz") && !await fs7.stat(arg).catch(() => null)) {
1129
+ const [id, version] = arg.split("@");
1130
+ const store = new PluginInstaller(cfg.pluginsDir, path8.join(cfg.dataDir, "backups"), createRegistries(cfg.stores));
1131
+ const found = (await store.search(id)).filter((e) => e.id === id && (!version || e.version === version));
1132
+ if (!found.length) throw new Error(`"${arg}" was not found in your stores (${cfg.stores.map((s) => s.type).join(", ")}). Try: fluxplugin search ${id}`);
1133
+ const best = found.sort((a, b) => b.version.localeCompare(a.version, void 0, { numeric: true }))[0];
1134
+ const r = await store.install(best);
1135
+ log(`\u2714 installed ${r.id}@${r.version} from ${best.source}${r.previousVersion ? ` (was ${r.previousVersion}; rollback available)` : ""}
1136
+ permissions it asks for: ${best.permissions.join(", ") || "none"}`);
1137
+ return 0;
1138
+ }
743
1139
  if (cmd === "install") {
744
- const tmp = path5.join(cfg.dataDir, ".install", String(Date.now()));
745
- if (arg.endsWith(".tgz")) await extractTarGz(await fs4.readFile(arg), tmp);
746
- else await copyDir(path5.resolve(arg), tmp);
1140
+ const tmp = path8.join(cfg.dataDir, ".install", String(Date.now()));
1141
+ if (arg.endsWith(".tgz")) await extractTarGz(await fs7.readFile(arg), tmp);
1142
+ else await copyDir(path8.resolve(arg), tmp);
747
1143
  const v = validateManifest(await readManifest(tmp));
748
1144
  if (!v.manifest) throw new Error("invalid plugin: " + v.issues.map((i) => i.message).join("; "));
749
- const reg = new LocalRegistry(path5.dirname(tmp), "cli");
1145
+ const reg = new LocalRegistry(path8.dirname(tmp), "cli");
750
1146
  inst.addRegistry(reg);
751
1147
  const [entry] = (await reg.search(v.manifest.id)).filter((e) => e.id === v.manifest.id);
752
1148
  const r = await inst.install({ ...entry, location: tmp });
753
- await fs4.rm(tmp, { recursive: true, force: true });
1149
+ await fs7.rm(tmp, { recursive: true, force: true });
754
1150
  log(`installed ${r.id}@${r.version}${r.previousVersion ? ` (was ${r.previousVersion}; rollback available)` : ""}`);
755
1151
  } else if (cmd === "uninstall") {
756
1152
  await inst.uninstall(arg);
@@ -764,13 +1160,49 @@ async function manage(cmd, arg, f) {
764
1160
  async function ownVersion() {
765
1161
  for (const rel of ["../package.json", "../../package.json"]) {
766
1162
  try {
767
- const j = JSON.parse(await fs4.readFile(new URL(rel, import.meta.url), "utf8"));
1163
+ const j = JSON.parse(await fs7.readFile(new URL(rel, import.meta.url), "utf8"));
768
1164
  if (j.name === "fluxplugin" && j.version) return j.version;
769
1165
  } catch {
770
1166
  }
771
1167
  }
772
1168
  return "1.0.0";
773
1169
  }
1170
+ async function latestElectron() {
1171
+ try {
1172
+ const r = await fetch("https://registry.npmjs.org/electron/latest", { signal: AbortSignal.timeout(4e3) });
1173
+ const j = await r.json();
1174
+ if (j.version && /^\d+\.\d+\.\d+$/.test(j.version)) return `^${j.version}`;
1175
+ } catch {
1176
+ }
1177
+ return "^44.5.1";
1178
+ }
1179
+ async function doctor(cwd) {
1180
+ let bad = false;
1181
+ const ok = (m) => log(`\u2714 ${m}`);
1182
+ const ko = (m) => (log(`\u2716 ${m}`), bad = true);
1183
+ Number(process.versions.node.split(".")[0]) >= 20 ? ok(`Node ${process.versions.node}`) : ko(`Node ${process.versions.node} (need >= 20)`);
1184
+ if (!await isHostProject(cwd)) {
1185
+ log("! not inside a fluxplugin app folder (run this where package.json and src/app are)");
1186
+ return bad ? 1 : 0;
1187
+ }
1188
+ const e = ensureElectron(cwd, log);
1189
+ e.bin ? ok(`Electron binary: ${path8.relative(cwd, e.bin) || e.bin}`) : ko(`Electron: ${e.error}
1190
+ fix: npm install -D electron`);
1191
+ try {
1192
+ (await import("esbuild")).version ? ok("esbuild") : ko("esbuild");
1193
+ } catch {
1194
+ ko("esbuild is not available");
1195
+ }
1196
+ const hasTw = await fs7.stat(path8.join(cwd, "src/app/renderer/styles/tailwind.css")).then(() => true, () => false);
1197
+ if (hasTw) {
1198
+ const { resolveTailwindCli } = await import("./chunks/tailwind-TJQVTBG5.js");
1199
+ resolveTailwindCli([cwd]) ? ok("Tailwind CLI") : ko("Tailwind CLI missing: npm install -D tailwindcss @tailwindcss/cli");
1200
+ }
1201
+ const pkg2 = JSON.parse(await fs7.readFile(path8.join(cwd, "package.json"), "utf8").catch(() => "{}"));
1202
+ if (!pkg2.allowScripts?.["electron"]) log(`! package.json has no "allowScripts": {"electron": true}. npm 12 blocks Electron's postinstall without it (fluxplugin downloads the binary for you, but add it for clean installs).`);
1203
+ log(bad ? "\nSome checks failed." : "\nAll good.");
1204
+ return bad ? 1 : 0;
1205
+ }
774
1206
  export {
775
1207
  SHARED_MODULES,
776
1208
  TEMPLATES,
@@ -781,5 +1213,6 @@ export {
781
1213
  run,
782
1214
  sharedModulesPlugin,
783
1215
  startApp,
784
- varsFromName
1216
+ varsFromName,
1217
+ withTailwind
785
1218
  };