fluxplugin 1.0.2 → 1.0.4

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 (50) hide show
  1. package/README.md +15 -12
  2. package/dist/browser/chunks/chunk-AV4KI7LI.js +1792 -0
  3. package/dist/browser/chunks/{chunk-TL63576R.js → chunk-OAYWYU5O.js} +48 -11
  4. package/dist/browser/index.js +3 -1
  5. package/dist/browser/manager-ui.js +386 -185
  6. package/dist/browser/react.js +46 -2
  7. package/dist/cli.js +3622 -257
  8. package/dist/node/chunks/{chunk-EVX2O65A.js → chunk-SDCGUBEW.js} +49 -11
  9. package/dist/node/chunks/{chunk-C7VWZZOX.js → chunk-ZLA5XUXN.js} +175 -51
  10. package/dist/node/cli-api.js +742 -76
  11. package/dist/node/index.js +3 -1
  12. package/dist/node/main.js +8 -2
  13. package/dist/node/preload.js +1 -1
  14. package/dist/types/cli/build.d.ts +5 -0
  15. package/dist/types/cli/cli.d.ts +2 -0
  16. package/dist/types/cli/dist.d.ts +18 -0
  17. package/dist/types/cli/host-templates.d.ts +2 -0
  18. package/dist/types/cli/store.d.ts +13 -0
  19. package/dist/types/cli/templates.d.ts +2 -0
  20. package/dist/types/core/manifest.d.ts +15 -1
  21. package/dist/types/core/protocol.d.ts +18 -5
  22. package/dist/types/core/registry.d.ts +11 -0
  23. package/dist/types/core/routes.d.ts +3 -1
  24. package/dist/types/main/adapters.d.ts +3 -0
  25. package/dist/types/main/assets.d.ts +8 -0
  26. package/dist/types/main/github-registry.d.ts +2 -1
  27. package/dist/types/main/index.d.ts +1 -0
  28. package/dist/types/main/registry.d.ts +55 -18
  29. package/dist/types/main/stores.d.ts +7 -1
  30. package/dist/types/manager-ui/index.d.ts +35 -3
  31. package/dist/types/react/base-css.d.ts +6 -2
  32. package/dist/types/react/http-bridge.d.ts +5 -0
  33. package/dist/types/react/i18n.d.ts +33 -0
  34. package/dist/types/react/index.d.ts +5 -0
  35. package/dist/types/react/markdown.d.ts +2 -1
  36. package/dist/types/react/media.d.ts +33 -0
  37. package/dist/types/react/settings.d.ts +6 -0
  38. package/dist/types/react/sidebar.d.ts +42 -0
  39. package/dist/types/react/theme.d.ts +42 -4
  40. package/dist/types/react/ui.d.ts +42 -0
  41. package/docs/CUSTOMIZING.md +56 -0
  42. package/docs/STORE.md +19 -0
  43. package/docs/screenshots/plugin-page.png +0 -0
  44. package/docs/screenshots/settings-appearance.png +0 -0
  45. package/docs/screenshots/site-guide-dark.png +0 -0
  46. package/docs/screenshots/site-home.png +0 -0
  47. package/docs/screenshots/site-mobile.png +0 -0
  48. package/docs/screenshots/site-search.png +0 -0
  49. package/package.json +1 -1
  50. package/dist/browser/chunks/chunk-VXQJIDXW.js +0 -1232
@@ -3,6 +3,7 @@ import {
3
3
  DEFAULT_TOPIC,
4
4
  JsonStateFile,
5
5
  LocalRegistry,
6
+ MainFramework,
6
7
  PluginInstaller,
7
8
  computeIntegrity,
8
9
  copyDir,
@@ -14,10 +15,10 @@ import {
14
15
  readTarGz,
15
16
  signManifest,
16
17
  verifyPlugin
17
- } from "./chunks/chunk-C7VWZZOX.js";
18
+ } from "./chunks/chunk-ZLA5XUXN.js";
18
19
  import {
19
20
  validateManifest
20
- } from "./chunks/chunk-EVX2O65A.js";
21
+ } from "./chunks/chunk-SDCGUBEW.js";
21
22
  import {
22
23
  compileTailwind
23
24
  } from "./chunks/chunk-3FH6NAJG.js";
@@ -25,11 +26,11 @@ import "./chunks/chunk-WSFCRVEQ.js";
25
26
 
26
27
  // src/cli/cli.ts
27
28
  import { parseArgs } from "node:util";
28
- import { promises as fs7 } from "node:fs";
29
- import path8 from "node:path";
29
+ import { promises as fs8 } from "node:fs";
30
+ import path9 from "node:path";
30
31
 
31
32
  // src/cli/build.ts
32
- import { promises as fs } from "node:fs";
33
+ import { existsSync, promises as fs, watch as fsWatch } from "node:fs";
33
34
  import path2 from "node:path";
34
35
  import { build, context } from "esbuild";
35
36
 
@@ -73,7 +74,19 @@ async function buildOptions(root, dev = false) {
73
74
  logLevel: "warning",
74
75
  plugins: [sharedModulesPlugin()],
75
76
  jsx: "automatic",
76
- loader: { ".module.css": "local-css" }
77
+ loader: {
78
+ ".module.css": "local-css",
79
+ // Images and fonts imported by the plugin (or its libraries) are inlined, so dist/ stays self-contained.
80
+ ".png": "dataurl",
81
+ ".jpg": "dataurl",
82
+ ".jpeg": "dataurl",
83
+ ".gif": "dataurl",
84
+ ".webp": "dataurl",
85
+ ".svg": "dataurl",
86
+ ".woff": "dataurl",
87
+ ".woff2": "dataurl",
88
+ ".ttf": "dataurl"
89
+ }
77
90
  };
78
91
  if (m.main) {
79
92
  const entry = await firstExisting(root, ["src/main.ts", "src/main.js"]);
@@ -99,18 +112,69 @@ async function buildPlugin(root, watch = false, log2 = console.log) {
99
112
  };
100
113
  if (!watch) {
101
114
  for (const o of opts) await build(o);
115
+ await ensureRendererCss(root);
116
+ await hintMissingDeps(root, log2);
102
117
  log2(`built ${opts.length} bundle(s) in ${root}`);
103
118
  return async () => stopTw();
104
119
  }
105
120
  const ctxs = await Promise.all(opts.map((o) => context(o)));
121
+ await Promise.all(ctxs.map((c) => c.rebuild()));
122
+ await ensureRendererCss(root);
106
123
  await Promise.all(ctxs.map((c) => c.watch()));
107
124
  log2(`watching ${root} (${ctxs.length} bundles)`);
108
125
  return async () => (stopTw(), void await Promise.all(ctxs.map((c) => c.dispose())));
109
126
  }
127
+ async function ensureRendererCss(root) {
128
+ if (!await exists(path2.join(root, "dist/renderer.css"))) return;
129
+ const file = path2.join(root, "plugin.json");
130
+ const raw = JSON.parse(await fs.readFile(file, "utf8"));
131
+ const styles2 = raw.styles ?? [];
132
+ if (styles2.some((s) => s.replace(/^\.\//, "") === "dist/renderer.css")) return;
133
+ raw.styles = [...styles2, "./dist/renderer.css"];
134
+ await fs.writeFile(file, JSON.stringify(raw, null, 2));
135
+ }
136
+ async function hintMissingDeps(root, log2) {
137
+ try {
138
+ const pkg2 = JSON.parse(await fs.readFile(path2.join(root, "package.json"), "utf8"));
139
+ if (Object.keys(pkg2.dependencies ?? {}).length && !await exists(path2.join(root, "node_modules"))) log2(`! ${path2.basename(root)} lists dependencies but has no node_modules: run "npm install" in ${root}`);
140
+ } catch {
141
+ }
142
+ }
143
+ async function linkPluginToHost(root, hostDir, log2 = console.log) {
144
+ const manifest = JSON.parse(await fs.readFile(path2.join(root, "plugin.json"), "utf8"));
145
+ const target = path2.join(path2.resolve(hostDir), "plugins", manifest.id);
146
+ const sync = async () => {
147
+ try {
148
+ const copied = await copyPluginRuntime(root, target);
149
+ const keep = new Set(copied);
150
+ const walk = async (rel) => {
151
+ for (const e of await fs.readdir(path2.join(target, rel), { withFileTypes: true })) {
152
+ const r = rel ? `${rel}/${e.name}` : e.name;
153
+ if (e.isDirectory()) await walk(r);
154
+ else if (!keep.has(r)) await fs.rm(path2.join(target, r), { force: true });
155
+ }
156
+ };
157
+ await walk("");
158
+ log2(`\u21AA synced ${copied.length} files to ${path2.relative(process.cwd(), target) || target}`);
159
+ } catch (e) {
160
+ log2(`! sync failed: ${e.message}`);
161
+ }
162
+ };
163
+ await sync();
164
+ let timer;
165
+ const watchers = [path2.join(root, "dist"), path2.join(root, "assets"), root].filter((d) => existsSync(d)).map(
166
+ (d) => fsWatch(d, { recursive: d !== root }, (_e, name) => {
167
+ if (d === root && name !== "plugin.json" && name !== "README.md") return;
168
+ clearTimeout(timer);
169
+ timer = setTimeout(() => void sync(), 250);
170
+ })
171
+ );
172
+ return () => watchers.forEach((w) => w.close());
173
+ }
110
174
 
111
175
  // src/cli/host.ts
112
176
  import { spawn, spawnSync } from "node:child_process";
113
- import { existsSync, promises as fs3, statSync } from "node:fs";
177
+ import { existsSync as existsSync2, promises as fs3, statSync } from "node:fs";
114
178
  import { createRequire } from "node:module";
115
179
  import path4 from "node:path";
116
180
  import { build as build2, context as context2 } from "esbuild";
@@ -139,7 +203,7 @@ async function isHostProject(cwd) {
139
203
  }
140
204
  var nodeBanner = { js: "import { createRequire as __r } from 'node:module'; const require = __r(import.meta.url);" };
141
205
  var pick = (cwd, ...rels) => {
142
- for (const r of rels) if (existsSync(path4.join(cwd, r))) return path4.join(cwd, r);
206
+ for (const r of rels) if (existsSync2(path4.join(cwd, r))) return path4.join(cwd, r);
143
207
  return path4.join(cwd, rels[0]);
144
208
  };
145
209
  function hostBuilds(cwd, outDir, dev) {
@@ -195,7 +259,7 @@ async function buildHost(cwd, opts = {}) {
195
259
  function electronBinary(cwd) {
196
260
  try {
197
261
  const p = createRequire(path4.join(cwd, "package.json"))("electron");
198
- if (typeof p === "string" && existsSync(p)) return { bin: p };
262
+ if (typeof p === "string" && existsSync2(p)) return { bin: p };
199
263
  return { error: "the Electron binary was not found" };
200
264
  } catch (e) {
201
265
  return { error: e.message.split("\n")[0] };
@@ -211,7 +275,7 @@ function ensureElectron(cwd, log2 = console.log) {
211
275
  return { error: "Electron is not installed in this project" };
212
276
  }
213
277
  const installJs = path4.join(pkgDir, "install.js");
214
- if (!existsSync(installJs)) return first;
278
+ if (!existsSync2(installJs)) return first;
215
279
  log2("Electron's binary is missing (your package manager blocked its install script) \u2192 downloading it now\u2026");
216
280
  const r = spawnSync(process.execPath, [installJs], { cwd: pkgDir, stdio: "inherit" });
217
281
  if (r.status !== 0) return { error: "the Electron download failed (network or proxy?). Retry, or set ELECTRON_MIRROR" };
@@ -250,10 +314,398 @@ function startApp(cwd, opts = {}) {
250
314
 
251
315
  // src/cli/host-files.generated.ts
252
316
  var HOST_APP_FILES = {
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',
317
+ "electron-builder.yml": '# Produces the installer/executable: `npm run dist` (current OS) or `npm run dist -- --win --mac --linux`.\nappId: com.example.__APP_ID__\nproductName: __APP_NAME__\ndirectories:\n output: release\nfiles:\n - dist/**/*\n - package.json\n - "!**/*.map"\n# Plugins are copied by `fluxplugin dist` into .flux-build/plugins with runtime files only (no src, no .ts, no sourcemaps).\nextraResources:\n - from: .flux-build/plugins\n to: plugins\nasar: true\nwin:\n target: [nsis]\nnsis:\n oneClick: false\n allowToChangeInstallationDirectory: true\n # Plugins the user installed live in the app\'s data folder; this removes them with the app when it is uninstalled.\n deleteAppDataOnUninstall: true\nmac:\n target: [dmg, zip]\n category: public.app-category.productivity\nlinux:\n target: [AppImage, deb]\n category: Utility\n',
318
+ "plugins/appearance/README.md": "# Appearance\n\nMake the app yours. This plugin adds an **Appearance** page to *Settings* and four extra themes.\n\n## What you can change\n\n- **Mode** \u2014 System, Light or Dark.\n- **Theme** \u2014 Midnight, Sunset, Forest or high-contrast Graphite.\n- **Accent color** \u2014 seven colors, or the theme's own.\n- **Text** \u2014 font family, any font installed on your computer, size and line spacing.\n- **Layout** \u2014 compact or comfortable spacing, corner roundness, translucency and reduced motion.\n\n> [!TIP]\n> Type the name of any font installed on your computer in *Your own font*. If it is missing, the next font in the list is used.\n\n## How it works\n\n| Setting | Stored as |\n| --- | --- |\n| Theme and mode | the app's theme variables |\n| Fonts, size, spacing | CSS variables on the page |\n\nYour choices are saved on this device and apply to every plugin, because plugins use the same variables.\n\n## For developers\n\nPlugins can add their own **themes** and **fonts** to the same lists \u2014 see the `theme` and `font` extension points in the documentation.\n",
319
+ "plugins/appearance/assets/icon.svg": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="22" fill="hsl(280 72% 50%)"/><text x="48" y="64" font-family="system-ui,sans-serif" font-size="52" font-weight="700" text-anchor="middle" fill="#fff">A</text></svg>\n',
320
+ "plugins/appearance/plugin.json": '{\n "id": "com.fluxplugin.appearance",\n "name": "Appearance",\n "version": "1.0.0",\n "description": "Themes, accent colors, fonts, text size, density and more \\u2014 everything about how the app looks.",\n "author": "fluxplugin",\n "license": "MIT",\n "icon": "./assets/icon.svg",\n "readme": "./README.md",\n "categories": [\n "Personalization"\n ],\n "keywords": [\n "theme",\n "font",\n "accent",\n "dark mode"\n ],\n "links": {},\n "renderer": "./dist/renderer.js",\n "activationEvents": [\n "onStartup"\n ],\n "permissions": [\n "ui"\n ],\n "engines": {\n "fluxplugin": "^1.0.0"\n }\n}',
321
+ "plugins/appearance/src/renderer.tsx": `import { definePlugin } from "fluxplugin";
322
+ import { Button, Group, Row, Segmented, Switch, useAppearance, useT, useTheme, type ThemeMode } from "fluxplugin/react";
323
+ import { useState } from "react";
324
+
325
+ const ACCENTS = ["#007aff", "#34c759", "#ff9f0a", "#af52de", "#ff375f", "#5ac8fa", "#8e8e93"];
326
+
327
+ function AppearanceSection() {
328
+ const t = useT();
329
+ const { mode, setMode, themeId, setThemeId, themes } = useTheme();
330
+ const { appearance: a, setAppearance, resetAppearance, fonts } = useAppearance();
331
+ const [custom, setCustom] = useState(a.customFont);
332
+ return (
333
+ <>
334
+ <Group title={t("appearance.mode", "Mode")}>
335
+ <Row title={t("appearance.mode", "Mode")} right={
336
+ <Segmented<ThemeMode> value={(themeId ? "" : mode) as ThemeMode} onChange={setMode} label="Mode"
337
+ options={[{ value: "system", label: t("theme.system", "System") }, { value: "light", label: t("theme.light", "Light") }, { value: "dark", label: t("theme.dark", "Dark") }]} />} />
338
+ </Group>
339
+
340
+ <Group title={t("appearance.theme", "Theme")}>
341
+ <div style={{ display: "flex", gap: 12, flexWrap: "wrap", padding: 14 }}>
342
+ {[{ id: "", label: t("appearance.themeDefault", "Default"), tokens: { "--flux-bg": "#ffffff", "--flux-surface": "#f5f5f7", "--flux-accent": "#007aff" } }, ...themes].map((th) => (
343
+ <button key={th.id || "default"} type="button" className="flux-theme-card" data-on={(themeId ?? "") === th.id || undefined} data-testid={"theme-" + (th.id || "default")} onClick={() => (th.id ? setThemeId(th.id) : setMode(mode))}>
344
+ <span className="flux-theme-prev" style={{ display: "flex" }}>
345
+ <i style={{ flex: 2, background: th.tokens["--flux-bg"] }} /><i style={{ flex: 1, background: th.tokens["--flux-surface"] }} /><i style={{ flex: 1, background: th.tokens["--flux-accent"] }} />
346
+ </span>
347
+ <span className="cap">{th.label}</span>
348
+ </button>
349
+ ))}
350
+ </div>
351
+ </Group>
352
+
353
+ <Group title={t("appearance.accent", "Accent color")}>
354
+ <div className="flux-swatches" style={{ padding: 14 }}>
355
+ <button type="button" className="flux-swatch" title={t("appearance.accentDefault", "Theme default")} style={{ background: "conic-gradient(#ff3b30,#ff9f0a,#34c759,#0a84ff,#af52de,#ff3b30)" }} data-on={!a.accent || undefined} onClick={() => setAppearance({ accent: undefined })} />
356
+ {ACCENTS.map((c) => <button key={c} type="button" aria-label={c} className="flux-swatch" style={{ background: c }} data-on={a.accent === c || undefined} data-testid={"accent-" + c.slice(1)} onClick={() => setAppearance({ accent: c })} />)}
357
+ </div>
358
+ </Group>
359
+
360
+ <Group title={t("appearance.text", "Text")}>
361
+ <Row title={t("appearance.font", "Font")} right={
362
+ <select className="flux-select" value={a.fontId} data-testid="font-select" onChange={(e) => setAppearance({ fontId: e.target.value })}>{fonts.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}</select>} />
363
+ <Row title={t("appearance.customFont", "Your own font")} subtitle={t("appearance.customFontHint", "Any font installed on your computer")} right={
364
+ <input className="flux-input" style={{ width: 170 }} placeholder="e.g. Avenir Next" value={custom} data-testid="custom-font" onChange={(e) => setCustom(e.target.value)} onBlur={() => setAppearance({ customFont: custom })} onKeyDown={(e) => e.key === "Enter" && setAppearance({ customFont: custom })} />} />
365
+ <Row title={t("appearance.fontSize", "Text size")} right={<><input className="flux-range" type="range" min={12} max={20} step={1} value={a.fontSize} data-testid="font-size" onChange={(e) => setAppearance({ fontSize: Number(e.target.value) })} /><span style={{ width: 34, textAlign: "right" }}>{a.fontSize}px</span></>} />
366
+ <Row title={t("appearance.lineHeight", "Line spacing")} right={<input className="flux-range" type="range" min={1.2} max={2} step={0.05} value={a.lineHeight} onChange={(e) => setAppearance({ lineHeight: Number(e.target.value) })} />} />
367
+ <div style={{ padding: "4px 14px 14px" }}><p style={{ margin: 0 }}>{t("appearance.preview", "The quick brown fox jumps over the lazy dog.")}</p></div>
368
+ </Group>
369
+
370
+ <Group title={t("appearance.layout", "Layout")}>
371
+ <Row title={t("appearance.density", "Spacing")} right={
372
+ <Segmented value={a.density} onChange={(density) => setAppearance({ density })} label="Spacing" options={[{ value: "comfortable", label: t("appearance.comfortable", "Comfortable") }, { value: "compact", label: t("appearance.compact", "Compact") }]} />} />
373
+ <Row title={t("appearance.radius", "Corners")} right={
374
+ <Segmented value={a.radius} onChange={(radius) => setAppearance({ radius })} label="Corners" options={[{ value: "sharp", label: t("appearance.sharp", "Sharp") }, { value: "default", label: t("appearance.default", "Default") }, { value: "round", label: t("appearance.round", "Round") }]} />} />
375
+ <Row title={t("appearance.translucency", "Translucency")} right={<Switch checked={a.translucency} onChange={(translucency) => setAppearance({ translucency })} label="Translucency" />} />
376
+ <Row title={t("appearance.motion", "Reduce motion")} right={<Switch checked={a.reduceMotion} onChange={(reduceMotion) => setAppearance({ reduceMotion })} label="Reduce motion" />} />
377
+ </Group>
378
+ <Button variant="destructive" onClick={() => (resetAppearance(), setCustom(""))}>{t("appearance.reset", "Reset appearance")}</Button>
379
+ </>
380
+ );
381
+ }
382
+
383
+ export default definePlugin({
384
+ extensions: [
385
+ { point: "settings", contribution: { sections: [{ id: "appearance", title: "Appearance", icon: "palette", order: 10, component: AppearanceSection }] } },
386
+ { point: "theme", contribution: { themes: [
387
+ { id: "midnight", label: "Midnight", dark: true, tokens: { "--flux-bg": "#0b1020", "--flux-surface": "#111936", "--flux-surface-2": "#1a2447", "--flux-fg": "#e6ebff", "--flux-muted": "#8f9bc7", "--flux-border": "rgba(150,170,255,.18)", "--flux-accent": "#7c9cff", "--flux-material": "rgba(17,25,54,.72)" } },
388
+ { id: "sunset", label: "Sunset", tokens: { "--flux-bg": "#fff8f1", "--flux-surface": "#ffefe0", "--flux-surface-2": "#ffe0c7", "--flux-fg": "#3b1f12", "--flux-muted": "#9a6b53", "--flux-border": "rgba(120,60,20,.16)", "--flux-accent": "#ff6a3d", "--flux-material": "rgba(255,239,224,.8)" } },
389
+ { id: "forest", label: "Forest", dark: true, tokens: { "--flux-bg": "#0f1a14", "--flux-surface": "#162720", "--flux-surface-2": "#1f3a2e", "--flux-fg": "#e4f3ea", "--flux-muted": "#8fb3a0", "--flux-border": "rgba(150,230,190,.16)", "--flux-accent": "#34d399", "--flux-material": "rgba(22,39,32,.74)" } },
390
+ { id: "graphite", label: "Graphite (high contrast)", tokens: { "--flux-bg": "#ffffff", "--flux-surface": "#f0f0f0", "--flux-surface-2": "#dcdcdc", "--flux-fg": "#000000", "--flux-muted": "#3a3a3a", "--flux-border": "#000000", "--flux-accent": "#0040dd", "--flux-material": "rgba(240,240,240,.95)" } },
391
+ ] } },
392
+ { point: "font", contribution: { fonts: [
393
+ { id: "editorial", label: "Editorial", stack: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif' },
394
+ { id: "geometric", label: "Geometric", stack: '"Avenir Next", Avenir, "Century Gothic", Futura, "Trebuchet MS", sans-serif' },
395
+ { id: "readable", label: "Easy reading", stack: '"Atkinson Hyperlegible", Verdana, Tahoma, sans-serif' },
396
+ ] } },
397
+ ],
398
+ });
399
+ `,
400
+ "plugins/language/README.md": "# Language\n\nChoose the language of the whole interface: menus, search, notifications, settings and the plugin manager.\n\n## Languages included\n\n| Language | Code |\n| --- | --- |\n| English | `en` |\n| Fran\xE7ais | `fr` |\n| Espa\xF1ol | `es` |\n| Deutsch | `de` |\n\nOpen **Settings \u2192 Language** and pick one. The change is instant and remembered.\n\n## Add your own language\n\n1. Copy `src/messages.ts` and translate the values.\n2. Add the language to the list in `src/renderer.tsx`.\n3. Build with `fluxplugin build`.\n\n> [!NOTE]\n> Any plugin can ship translations \u2014 the app merges them. Missing words fall back to English, so a partial translation is fine.\n\n## Right-to-left languages\n\nSet `rtl: true` on a language (Arabic, Hebrew\u2026) and the whole interface mirrors automatically.\n",
401
+ "plugins/language/assets/icon.svg": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="22" fill="hsl(205 72% 50%)"/><text x="48" y="64" font-family="system-ui,sans-serif" font-size="52" font-weight="700" text-anchor="middle" fill="#fff">L</text></svg>\n',
402
+ "plugins/language/plugin.json": '{\n "id": "com.fluxplugin.language",\n "name": "Language",\n "version": "1.0.0",\n "description": "Use the app in English, French, Spanish or German \\u2014 and add your own language.",\n "author": "fluxplugin",\n "license": "MIT",\n "icon": "./assets/icon.svg",\n "readme": "./README.md",\n "categories": [\n "Personalization"\n ],\n "keywords": [\n "language",\n "translation",\n "i18n",\n "fran\\u00e7ais",\n "espa\\u00f1ol",\n "deutsch"\n ],\n "links": {},\n "renderer": "./dist/renderer.js",\n "activationEvents": [\n "onStartup"\n ],\n "permissions": [\n "ui"\n ],\n "engines": {\n "fluxplugin": "^1.0.0"\n }\n}',
403
+ "plugins/language/src/messages.ts": `/** Translations. Keys are the ones the app and its plugins pass to useT(); English is the built-in fallback. */
404
+ export const messages: Record<string, Record<string, string>> = {
405
+ "fr": {
406
+ "menu.dashboard": "Tableau de bord",
407
+ "menu.plugins": "Extensions",
408
+ "menu.settings": "R\xE9glages",
409
+ "theme.toggle": "Changer d'apparence",
410
+ "theme.system": "Syst\xE8me",
411
+ "theme.light": "Clair",
412
+ "theme.dark": "Sombre",
413
+ "search.placeholder": "Rechercher commandes, pages\u2026",
414
+ "search.label": "Rechercher",
415
+ "search.commands": "Commandes",
416
+ "search.pages": "Pages",
417
+ "search.noResults": "Aucun r\xE9sultat",
418
+ "notifications.title": "Notifications",
419
+ "notifications.clear": "Tout effacer",
420
+ "notifications.empty": "Tout est \xE0 jour",
421
+ "palette.placeholder": "Saisir une commande\u2026",
422
+ "palette.empty": "Aucune commande correspondante",
423
+ "sidebar.expand": "D\xE9velopper la barre lat\xE9rale",
424
+ "sidebar.collapse": "R\xE9duire la barre lat\xE9rale",
425
+ "settings.title": "R\xE9glages",
426
+ "settings.empty": "Aucun r\xE9glage",
427
+ "settings.general": "G\xE9n\xE9ral",
428
+ "settings.shortcuts": "Raccourcis",
429
+ "settings.appearance": "Apparence",
430
+ "settings.language": "Langue",
431
+ "settings.about": "\xC0 propos",
432
+ "settings.app": "Application",
433
+ "settings.plugins": "Extensions",
434
+ "settings.engine": "Moteur",
435
+ "settings.sidebar": "Barre lat\xE9rale",
436
+ "settings.sidebarHint": "R\xE9duit la barre lat\xE9rale en ic\xF4nes. Raccourci : Ctrl/Cmd+B.",
437
+ "settings.compactSidebar": "Barre lat\xE9rale compacte",
438
+ "plugins.title": "Extensions",
439
+ "plugins.installed": "Install\xE9es",
440
+ "plugins.discover": "D\xE9couvrir",
441
+ "plugins.updates": "Mises \xE0 jour",
442
+ "plugins.search": "Rechercher des extensions",
443
+ "plugins.none": "Aucune extension install\xE9e",
444
+ "plugins.searching": "Recherche dans les catalogues\u2026",
445
+ "plugins.upToDate": "Tout est \xE0 jour.",
446
+ "plugins.storeEmpty": "Aucune extension trouv\xE9e dans vos catalogues.",
447
+ "plugins.installedTag": "Install\xE9e",
448
+ "plugins.get": "Obtenir",
449
+ "plugins.update": "Mettre \xE0 jour",
450
+ "plugins.open": "Ouvrir",
451
+ "plugins.back": "Extensions",
452
+ "plugins.notFound": "Cette extension n'est pas install\xE9e.",
453
+ "plugins.enable": "Activer",
454
+ "plugins.enabled": "Activ\xE9e",
455
+ "plugins.disabled": "D\xE9sactiv\xE9e",
456
+ "plugins.reload": "Recharger",
457
+ "plugins.uninstall": "D\xE9sinstaller",
458
+ "plugins.video": "Vid\xE9o",
459
+ "plugins.about": "\xC0 propos",
460
+ "plugins.permissions": "Autorisations",
461
+ "plugins.permissionsFooter": "Une extension n'obtient que ce qu'elle d\xE9clare. Les acc\xE8s sensibles sont signal\xE9s.",
462
+ "plugins.noPermissions": "Aucun acc\xE8s particulier",
463
+ "plugins.sensitive": "Sensible",
464
+ "plugins.information": "Informations",
465
+ "plugins.developer": "D\xE9veloppeur",
466
+ "plugins.version": "Version",
467
+ "plugins.license": "Licence",
468
+ "plugins.dependencies": "N\xE9cessite",
469
+ "plugins.keywords": "Mots-cl\xE9s",
470
+ "plugins.settings": "R\xE9glages",
471
+ "plugins.saved": "Enregistr\xE9",
472
+ "plugins.reset": "R\xE9tablir les valeurs par d\xE9faut",
473
+ "plugins.logs": "Journal",
474
+ "plugins.noLogs": "Aucun journal",
475
+ "plugins.installedVersion": "Install\xE9e : {v}",
476
+ "state.ready": "Active",
477
+ "state.error": "Erreur",
478
+ "state.disabled": "D\xE9sactiv\xE9e",
479
+ "appearance.mode": "Mode",
480
+ "appearance.theme": "Th\xE8me",
481
+ "appearance.themeDefault": "Par d\xE9faut",
482
+ "appearance.accent": "Couleur d'accentuation",
483
+ "appearance.accentDefault": "Selon le th\xE8me",
484
+ "appearance.text": "Texte",
485
+ "appearance.font": "Police",
486
+ "appearance.customFont": "Votre propre police",
487
+ "appearance.customFontHint": "Toute police install\xE9e sur votre ordinateur",
488
+ "appearance.fontSize": "Taille du texte",
489
+ "appearance.lineHeight": "Interligne",
490
+ "appearance.preview": "Portez ce vieux whisky au juge blond qui fume.",
491
+ "appearance.layout": "Disposition",
492
+ "appearance.density": "Espacement",
493
+ "appearance.comfortable": "Confortable",
494
+ "appearance.compact": "Compact",
495
+ "appearance.radius": "Coins",
496
+ "appearance.sharp": "Droits",
497
+ "appearance.default": "Par d\xE9faut",
498
+ "appearance.round": "Arrondis",
499
+ "appearance.translucency": "Transparence",
500
+ "appearance.motion": "R\xE9duire les animations",
501
+ "appearance.reset": "R\xE9initialiser l'apparence",
502
+ "language.follow": "Suivre la langue du syst\xE8me"
503
+ },
504
+ "es": {
505
+ "menu.dashboard": "Panel",
506
+ "menu.plugins": "Extensiones",
507
+ "menu.settings": "Ajustes",
508
+ "theme.toggle": "Cambiar apariencia",
509
+ "theme.system": "Sistema",
510
+ "theme.light": "Claro",
511
+ "theme.dark": "Oscuro",
512
+ "search.placeholder": "Buscar comandos, p\xE1ginas\u2026",
513
+ "search.label": "Buscar",
514
+ "search.commands": "Comandos",
515
+ "search.pages": "P\xE1ginas",
516
+ "search.noResults": "Sin resultados",
517
+ "notifications.title": "Notificaciones",
518
+ "notifications.clear": "Borrar todo",
519
+ "notifications.empty": "Todo al d\xEDa",
520
+ "palette.placeholder": "Escribe un comando\u2026",
521
+ "palette.empty": "Ning\xFAn comando coincide",
522
+ "sidebar.expand": "Expandir barra lateral",
523
+ "sidebar.collapse": "Contraer barra lateral",
524
+ "settings.title": "Ajustes",
525
+ "settings.empty": "Sin ajustes",
526
+ "settings.general": "General",
527
+ "settings.shortcuts": "Atajos",
528
+ "settings.appearance": "Apariencia",
529
+ "settings.language": "Idioma",
530
+ "settings.about": "Acerca de",
531
+ "settings.app": "Aplicaci\xF3n",
532
+ "settings.plugins": "Extensiones",
533
+ "settings.engine": "Motor",
534
+ "settings.sidebar": "Barra lateral",
535
+ "settings.sidebarHint": "Reduce la barra lateral a iconos. Atajo: Ctrl/Cmd+B.",
536
+ "settings.compactSidebar": "Barra lateral compacta",
537
+ "plugins.title": "Extensiones",
538
+ "plugins.installed": "Instaladas",
539
+ "plugins.discover": "Descubrir",
540
+ "plugins.updates": "Actualizaciones",
541
+ "plugins.search": "Buscar extensiones",
542
+ "plugins.none": "No hay extensiones instaladas",
543
+ "plugins.searching": "Buscando en los cat\xE1logos\u2026",
544
+ "plugins.upToDate": "Todo est\xE1 actualizado.",
545
+ "plugins.storeEmpty": "No se encontraron extensiones en tus cat\xE1logos.",
546
+ "plugins.installedTag": "Instalada",
547
+ "plugins.get": "Obtener",
548
+ "plugins.update": "Actualizar",
549
+ "plugins.open": "Abrir",
550
+ "plugins.back": "Extensiones",
551
+ "plugins.notFound": "Esta extensi\xF3n no est\xE1 instalada.",
552
+ "plugins.enable": "Activar",
553
+ "plugins.enabled": "Activada",
554
+ "plugins.disabled": "Desactivada",
555
+ "plugins.reload": "Recargar",
556
+ "plugins.uninstall": "Desinstalar",
557
+ "plugins.video": "V\xEDdeo",
558
+ "plugins.about": "Descripci\xF3n",
559
+ "plugins.permissions": "Permisos",
560
+ "plugins.permissionsFooter": "Las extensiones solo obtienen lo que declaran. Los accesos sensibles se destacan.",
561
+ "plugins.noPermissions": "Sin accesos especiales",
562
+ "plugins.sensitive": "Sensible",
563
+ "plugins.information": "Informaci\xF3n",
564
+ "plugins.developer": "Desarrollador",
565
+ "plugins.version": "Versi\xF3n",
566
+ "plugins.license": "Licencia",
567
+ "plugins.dependencies": "Requiere",
568
+ "plugins.keywords": "Palabras clave",
569
+ "plugins.settings": "Ajustes",
570
+ "plugins.saved": "Guardado",
571
+ "plugins.reset": "Restablecer valores",
572
+ "plugins.logs": "Registro",
573
+ "plugins.noLogs": "Sin registros",
574
+ "plugins.installedVersion": "Instalada: {v}",
575
+ "state.ready": "Activa",
576
+ "state.error": "Error",
577
+ "state.disabled": "Desactivada",
578
+ "appearance.mode": "Modo",
579
+ "appearance.theme": "Tema",
580
+ "appearance.themeDefault": "Predeterminado",
581
+ "appearance.accent": "Color de \xE9nfasis",
582
+ "appearance.accentDefault": "Seg\xFAn el tema",
583
+ "appearance.text": "Texto",
584
+ "appearance.font": "Fuente",
585
+ "appearance.customFont": "Tu propia fuente",
586
+ "appearance.customFontHint": "Cualquier fuente instalada en tu equipo",
587
+ "appearance.fontSize": "Tama\xF1o del texto",
588
+ "appearance.lineHeight": "Interlineado",
589
+ "appearance.preview": "El veloz murci\xE9lago hind\xFA com\xEDa feliz cardillo y kiwi.",
590
+ "appearance.layout": "Disposici\xF3n",
591
+ "appearance.density": "Espaciado",
592
+ "appearance.comfortable": "C\xF3modo",
593
+ "appearance.compact": "Compacto",
594
+ "appearance.radius": "Esquinas",
595
+ "appearance.sharp": "Rectas",
596
+ "appearance.default": "Predeterminadas",
597
+ "appearance.round": "Redondeadas",
598
+ "appearance.translucency": "Transparencia",
599
+ "appearance.motion": "Reducir movimiento",
600
+ "appearance.reset": "Restablecer apariencia",
601
+ "language.follow": "Seguir el idioma del sistema"
602
+ },
603
+ "de": {
604
+ "menu.dashboard": "\xDCbersicht",
605
+ "menu.plugins": "Erweiterungen",
606
+ "menu.settings": "Einstellungen",
607
+ "theme.toggle": "Darstellung wechseln",
608
+ "theme.system": "System",
609
+ "theme.light": "Hell",
610
+ "theme.dark": "Dunkel",
611
+ "search.placeholder": "Befehle, Seiten suchen\u2026",
612
+ "search.label": "Suchen",
613
+ "search.commands": "Befehle",
614
+ "search.pages": "Seiten",
615
+ "search.noResults": "Keine Ergebnisse",
616
+ "notifications.title": "Mitteilungen",
617
+ "notifications.clear": "Alle l\xF6schen",
618
+ "notifications.empty": "Alles erledigt",
619
+ "palette.placeholder": "Befehl eingeben\u2026",
620
+ "palette.empty": "Kein passender Befehl",
621
+ "sidebar.expand": "Seitenleiste erweitern",
622
+ "sidebar.collapse": "Seitenleiste einklappen",
623
+ "settings.title": "Einstellungen",
624
+ "settings.empty": "Keine Einstellungen",
625
+ "settings.general": "Allgemein",
626
+ "settings.shortcuts": "Tastenk\xFCrzel",
627
+ "settings.appearance": "Darstellung",
628
+ "settings.language": "Sprache",
629
+ "settings.about": "Info",
630
+ "settings.app": "Anwendung",
631
+ "settings.plugins": "Erweiterungen",
632
+ "settings.engine": "Engine",
633
+ "settings.sidebar": "Seitenleiste",
634
+ "settings.sidebarHint": "Reduziert die Seitenleiste auf Symbole. K\xFCrzel: Strg/Cmd+B.",
635
+ "settings.compactSidebar": "Kompakte Seitenleiste",
636
+ "plugins.title": "Erweiterungen",
637
+ "plugins.installed": "Installiert",
638
+ "plugins.discover": "Entdecken",
639
+ "plugins.updates": "Updates",
640
+ "plugins.search": "Erweiterungen suchen",
641
+ "plugins.none": "Keine Erweiterungen installiert",
642
+ "plugins.searching": "Kataloge werden durchsucht\u2026",
643
+ "plugins.upToDate": "Alles ist aktuell.",
644
+ "plugins.storeEmpty": "Keine Erweiterungen in Ihren Katalogen gefunden.",
645
+ "plugins.installedTag": "Installiert",
646
+ "plugins.get": "Laden",
647
+ "plugins.update": "Aktualisieren",
648
+ "plugins.open": "\xD6ffnen",
649
+ "plugins.back": "Erweiterungen",
650
+ "plugins.notFound": "Diese Erweiterung ist nicht installiert.",
651
+ "plugins.enable": "Aktivieren",
652
+ "plugins.enabled": "Aktiviert",
653
+ "plugins.disabled": "Deaktiviert",
654
+ "plugins.reload": "Neu laden",
655
+ "plugins.uninstall": "Deinstallieren",
656
+ "plugins.video": "Video",
657
+ "plugins.about": "Beschreibung",
658
+ "plugins.permissions": "Berechtigungen",
659
+ "plugins.permissionsFooter": "Erweiterungen erhalten nur, was sie angeben. Sensible Zugriffe sind markiert.",
660
+ "plugins.noPermissions": "Keine besonderen Zugriffe",
661
+ "plugins.sensitive": "Sensibel",
662
+ "plugins.information": "Informationen",
663
+ "plugins.developer": "Entwickler",
664
+ "plugins.version": "Version",
665
+ "plugins.license": "Lizenz",
666
+ "plugins.dependencies": "Ben\xF6tigt",
667
+ "plugins.keywords": "Stichw\xF6rter",
668
+ "plugins.settings": "Einstellungen",
669
+ "plugins.saved": "Gespeichert",
670
+ "plugins.reset": "Auf Standard zur\xFCcksetzen",
671
+ "plugins.logs": "Protokoll",
672
+ "plugins.noLogs": "Kein Protokoll",
673
+ "plugins.installedVersion": "Installiert: {v}",
674
+ "state.ready": "Aktiv",
675
+ "state.error": "Fehler",
676
+ "state.disabled": "Deaktiviert",
677
+ "appearance.mode": "Modus",
678
+ "appearance.theme": "Thema",
679
+ "appearance.themeDefault": "Standard",
680
+ "appearance.accent": "Akzentfarbe",
681
+ "appearance.accentDefault": "Vom Thema",
682
+ "appearance.text": "Text",
683
+ "appearance.font": "Schrift",
684
+ "appearance.customFont": "Eigene Schrift",
685
+ "appearance.customFontHint": "Jede auf Ihrem Computer installierte Schrift",
686
+ "appearance.fontSize": "Textgr\xF6\xDFe",
687
+ "appearance.lineHeight": "Zeilenabstand",
688
+ "appearance.preview": "Zw\xF6lf Boxk\xE4mpfer jagen Viktor quer \xFCber den gro\xDFen Sylter Deich.",
689
+ "appearance.layout": "Layout",
690
+ "appearance.density": "Abst\xE4nde",
691
+ "appearance.comfortable": "Komfortabel",
692
+ "appearance.compact": "Kompakt",
693
+ "appearance.radius": "Ecken",
694
+ "appearance.sharp": "Eckig",
695
+ "appearance.default": "Standard",
696
+ "appearance.round": "Rund",
697
+ "appearance.translucency": "Transparenz",
698
+ "appearance.motion": "Bewegung reduzieren",
699
+ "appearance.reset": "Darstellung zur\xFCcksetzen",
700
+ "language.follow": "Systemsprache verwenden"
701
+ }
702
+ };
703
+ `,
704
+ "plugins/language/src/renderer.tsx": 'import { definePlugin } from "fluxplugin";\nimport { Group, Icon, Row, useI18n } from "fluxplugin/react";\nimport { messages } from "./messages";\n\n// To add a language: translate src/messages.ts, then add it here.\nconst languages = [\n { code: "fr", label: "French", nativeLabel: "Fran\xE7ais" },\n { code: "es", label: "Spanish", nativeLabel: "Espa\xF1ol" },\n { code: "de", label: "German", nativeLabel: "Deutsch" },\n];\n\nfunction LanguageSection() {\n const { locale, setLocale, languages: all, t } = useI18n();\n return (\n <>\n <Group title={t("settings.language", "Language")}>\n {all.map((l) => (\n <Row key={l.code} data-testid={"lang-" + l.code} title={l.nativeLabel ?? l.label} subtitle={l.label} onClick={() => setLocale(l.code)} right={locale === l.code ? <Icon name="check" size={18} /> : undefined} />\n ))}\n </Group>\n <Group>\n <Row title={t("language.follow", "Follow the system language")} onClick={() => setLocale(navigator.language)} />\n </Group>\n </>\n );\n}\n\nexport default definePlugin({\n extensions: [\n { point: "settings", contribution: { sections: [{ id: "language", title: "Language", icon: "language", order: 20, component: LanguageSection }] } },\n { point: "i18n", contribution: { languages } },\n ...Object.entries(messages).map(([locale, m]) => ({ point: "i18n", contribution: { messages: [{ locale, messages: m }] } })),\n ],\n});\n',
705
+ "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 /** Where "Get" installs plugins (default: the last of pluginDirs). */\n installDir?: 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 installDir: opts.installDir ?? opts.pluginDirs[opts.pluginDirs.length - 1],\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',
706
+ "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 { pluginPaths, 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 paths = pluginPaths(projectRoot(__dirname), app.getPath("userData"), app.isPackaged, process.resourcesPath);\n const fw = createFramework({\n pluginDirs: [paths.bundled, paths.user],\n installDir: paths.user,\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',
255
707
  "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',
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',
708
+ "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\nexport interface PluginPaths {\n /** Plugins shipped with the app: <project>/plugins in dev, <resources>/plugins in the packaged app (read-only). */\n bundled: string;\n /** Plugins installed by the user (writable): inside the app\'s data folder, removed with it when the app is uninstalled. */\n user: string;\n}\n\nexport function pluginPaths(root: string, userData: string, packaged: boolean, resourcesPath: string): PluginPaths {\n return { bundled: packaged ? path.join(resourcesPath, "plugins") : path.join(root, "plugins"), user: path.join(userData, "plugins") };\n}\n',
257
709
  "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',
258
710
  "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
711
  "src/app/main/window.ts": `import { BrowserWindow, shell } from "electron";
@@ -295,7 +747,7 @@ export function createMainWindow(distDir: string): BrowserWindow {
295
747
  `,
296
748
  "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',
297
749
  "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',
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',
750
+ "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, createStaticBridge, declareHostPoints, installSharedModules } from "fluxplugin/react";\nimport { declareExtensionPoints } from "./extension-points";\nimport { registerHostMenus } from "./menus";\nimport { registerHostSettings } from "./settings";\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 const bridge = await pickBridge();\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 registerHostSettings(framework);\n await framework.start();\n return { framework, bridge };\n}\n\n/** Electron: the preload bridge. Browser dev mode: HTTP bridge to `fluxplugin dev --web`. Static website export: bootstrap.json only. */\nasync function pickBridge(): Promise<FluxBridge> {\n const injected = (window as unknown as { __fluxplugin?: FluxBridge }).__fluxplugin;\n if (injected) return injected;\n try {\n const r = await fetch("/flux/bootstrap");\n if (r.ok && (r.headers.get("content-type") ?? "").includes("json")) return createHttpBridge();\n } catch {\n /* no server behind this page */\n }\n return createStaticBridge("./bootstrap.json");\n}\n',
299
751
  "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',
300
752
  "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',
301
753
  "src/app/renderer/index.html": `<!doctype html>
@@ -303,7 +755,7 @@ export function createMainWindow(distDir: string): BrowserWindow {
303
755
  <head>
304
756
  <meta charset="utf-8" />
305
757
  <title>__APP_NAME__</title>
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" />
758
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; font-src 'self' data: https: fluxplugin:; connect-src 'self'; img-src 'self' data: blob: https: http: file: fluxplugin:; media-src 'self' blob: data: https: file: fluxplugin:; frame-src https://www.youtube-nocookie.com https://player.vimeo.com https://www.dailymotion.com https://www.loom.com https://streamable.com" />
307
759
  <link rel="stylesheet" href="./renderer.css" />
308
760
  </head>
309
761
  <body>
@@ -325,14 +777,18 @@ export function registerHostMenus(framework: RendererFramework): void {
325
777
  commands.register({ id: "host.reload", title: "Reload window", category: "View", keybinding: "Mod+R", execute: () => location.reload() }, "host");
326
778
  commands.register({ id: "host.goHome", title: "Go to dashboard", category: "Go", execute: () => (location.hash = "#/") }, "host");
327
779
  commands.register({ id: "host.goPlugins", title: "Manage plugins", category: "Go", execute: () => (location.hash = "#/plugins") }, "host");
780
+ commands.register({ id: "host.sidebar", title: "Toggle sidebar", category: "View", keybinding: "Mod+B", execute: () => window.dispatchEvent(new Event("flux:toggle-sidebar")) }, "host");
781
+ commands.register({ id: "host.goSettings", title: "Settings", category: "Go", keybinding: "Mod+,", execute: () => (location.hash = "#/settings") }, "host");
328
782
  commands.register({ id: "host.docs", title: "Documentation", category: "Help", execute: () => void window.open("https://www.npmjs.com/package/fluxplugin", "_blank") }, "host");
329
783
 
330
784
  menus.register({ id: "app.go", location: "application", label: "Go", order: 10, children: [
331
785
  { id: "app.go.home", label: "Dashboard", command: "host.goHome" },
332
786
  { id: "app.go.plugins", label: "Plugins", command: "host.goPlugins" },
787
+ { id: "app.go.settings", label: "Settings", command: "host.goSettings" },
333
788
  ] }, "host");
334
789
  menus.register({ id: "app.view", location: "application", label: "View", order: 20, children: [
335
790
  { id: "app.view.palette", label: "Command palette", command: "host.palette" },
791
+ { id: "app.view.sidebar", label: "Toggle sidebar", command: "host.sidebar" },
336
792
  { id: "app.view.sep", label: "", separator: true },
337
793
  { id: "app.view.reload", label: "Reload", command: "host.reload" },
338
794
  ] }, "host");
@@ -340,23 +796,28 @@ export function registerHostMenus(framework: RendererFramework): void {
340
796
  }
341
797
  `,
342
798
  "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',
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',
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',
345
- "src/app/renderer/routes.ts": `import type { RendererFramework } from "fluxplugin/react";
799
+ "src/app/renderer/providers/AppProviders.tsx": 'import { createContext, useCallback, useContext, type ReactNode } from "react";\nimport type { FluxBridge } from "fluxplugin";\nimport { I18nProvider, PluginProvider, SidebarProvider, 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 <I18nProvider>\n <ThemeProvider onResolved={onResolved}>\n <SidebarProvider>\n {children}\n <ToastHost />\n </SidebarProvider>\n </ThemeProvider>\n </I18nProvider>\n </BridgeContext.Provider>\n </PluginProvider>\n );\n}\n',
800
+ "src/app/renderer/routes.ts": `import { SettingsPage, type RendererFramework } from "fluxplugin/react";
801
+ import { registerPluginManagerRoutes } from "fluxplugin/manager-ui";
346
802
  import { DashboardPage } from "./pages/DashboardPage";
347
- import { PluginsPage } from "./pages/PluginsPage";
348
803
 
349
804
  /** The host's own pages (owner "host"). Plugins add theirs at runtime through the same registry. */
350
805
  export function registerHostRoutes(framework: RendererFramework): void {
351
- framework.manager.routes.register({ path: "/", component: DashboardPage, meta: { title: "Dashboard" } }, "host");
352
- framework.manager.routes.register({ path: "/plugins", component: PluginsPage, meta: { title: "Plugins", breadcrumb: "Plugins" } }, "host");
806
+ const routes = framework.manager.routes;
807
+ routes.register({ path: "/", component: DashboardPage, meta: { title: "Dashboard" } }, "host");
808
+ // Plugin manager: list \u2192 /plugins/:id (installed) \u2192 /plugins/store/:id (store listing). Each opens in its own screen.
809
+ registerPluginManagerRoutes(framework);
810
+ // Settings: sections come from the host and from plugins (Appearance, Language\u2026).
811
+ routes.register({ path: "/settings", component: SettingsPage, meta: { title: "Settings", breadcrumb: "Settings" } }, "host");
812
+ routes.register({ path: "/settings/:section", component: SettingsPage, meta: { breadcrumb: (p) => p["section"] ?? "" } }, "host");
353
813
  }
354
814
  `,
355
815
  "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',
356
816
  "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",
817
+ "src/app/renderer/settings/index.tsx": 'import type { ComponentType } from "react";\nimport { Group, Row, Switch, useSidebar, useT, usePluginFramework, usePlugins, formatShortcut, useOS, type RendererFramework } from "fluxplugin/react";\nimport { APP_NAME } from "../../shared/constants";\n\nfunction GeneralSection() {\n const t = useT();\n const { collapsed, setCollapsed } = useSidebar();\n const plugins = usePlugins();\n return (\n <>\n <Group title={t("settings.about", "About")}>\n <Row title={t("settings.app", "Application")} right={APP_NAME} />\n <Row title={t("settings.plugins", "Plugins")} right={`${plugins.filter((p) => p.state === "ready").length} / ${plugins.length}`} />\n <Row title={t("settings.engine", "Engine")} right={navigator.userAgent.match(/(Electron|Chrome|Firefox|Safari)\\/[\\d.]+/)?.[0] ?? "\u2014"} />\n </Group>\n <Group title={t("settings.sidebar", "Sidebar")} footer={t("settings.sidebarHint", "Collapse the sidebar to icons. Shortcut: Ctrl/Cmd+B.")}>\n <Row title={t("settings.compactSidebar", "Compact sidebar")} right={<Switch checked={collapsed} onChange={setCollapsed} label="Compact sidebar" />} />\n </Group>\n </>\n );\n}\n\nfunction ShortcutsSection() {\n const fw = usePluginFramework();\n const os = useOS();\n const t = useT();\n const cmds = fw.manager.commands.list().filter((c) => c.keybinding);\n return (\n <Group title={t("settings.shortcuts", "Keyboard shortcuts")}>\n {cmds.length === 0 && <Row title="\u2014" />}\n {cmds.map((c) => <Row key={c.id} title={c.title} subtitle={c.category} right={<kbd>{formatShortcut(c.keybinding!, os)}</kbd>} />)}\n </Group>\n );\n}\n\n/** Host settings sections. Plugins contribute theirs to the same "settings" extension point. */\nexport function registerHostSettings(framework: RendererFramework): void {\n framework.manager.extensionPoints.contribute(\n "settings",\n { sections: [\n { id: "general", title: "General", icon: "settings", order: 0, component: GeneralSection as ComponentType },\n { id: "shortcuts", title: "Shortcuts", icon: "command", order: 90, component: ShortcutsSection as ComponentType },\n ] },\n "host",\n );\n}\n',
818
+ "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/** App shell with a fixed sidebar and independently scrollable content. */\nexport function AppShell() {\n useKeybindings();\n\n return (\n <div className="app">\n {/* Title bar */}\n <header className="app-titlebar">\n <TitleBar title={APP_NAME} logo={<PluginSlot name={SLOTS.brandBadges} />} searchPlaceholder="Search commands, pages\u2026" />\n </header>\n\n {/* Application body */}\n <div className="body">\n {/* Fixed sidebar: never scrolls with the main content (collapses to an icon rail) */}\n <aside className="app-side">\n <Sidebar />\n </aside>\n\n {/* Only this area scrolls */}\n <main className="main">\n <div className="main-inner">\n <Breadcrumbs />\n <PluginRoutes />\n </div>\n </main>\n </div>\n\n {/* Command palette */}\n <CommandPalette />\n </div>\n );\n}\n',
819
+ "src/app/renderer/shell/Sidebar.tsx": 'import { SidebarNav, type SidebarEntry } from "fluxplugin/react";\n\n// Host entries. Plugins add theirs between the two groups (routes with meta.menu, or menus.register({ location: "sidebar" })),\n// including sub-menus: `meta.menu.parent` or `children`. Labels are translated through `menu.<id>`.\nconst top: SidebarEntry[] = [{ id: "dashboard", label: "Dashboard", path: "/", icon: "home" }];\nconst bottom: SidebarEntry[] = [\n { id: "plugins", label: "Plugins", path: "/plugins", icon: "plug" },\n { id: "settings", label: "Settings", path: "/settings", icon: "settings" },\n];\n\nexport function Sidebar() {\n return <SidebarNav top={top} bottom={bottom} footer={<span>Ctrl/Cmd+K: commands</span>} />;\n}\n',
820
+ "src/app/renderer/styles/app.css": "body { margin: 0; }\n.app { display: grid; height: 100vh; grid-template-rows: 40px minmax(0, 1fr); overflow: hidden; }\n.app-titlebar { z-index: 50; min-width: 0; }\n.body { display: grid; min-height: 0; min-width: 0; grid-template-columns: var(--flux-sidebar-w, 232px) minmax(0, 1fr); overflow: hidden; transition: grid-template-columns .18s ease; }\n.app-side { height: 100%; min-height: 0; overflow: hidden; }\n.main { min-height: 0; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 24px 28px; }\n.main-inner { max-width: 900px; margin: 0 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 14px; cursor: pointer; font-weight: 500; }\n.btn:hover { background: var(--flux-surface); }\n.hint { color: var(--flux-muted); font-size: .86em; }\n",
360
821
  "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',
361
822
  "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'
362
823
  };
@@ -366,7 +827,7 @@ var HOST_TAILWIND_FILES = {
366
827
  <head>
367
828
  <meta charset="utf-8" />
368
829
  <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" />
830
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; font-src 'self' data: https: fluxplugin:; connect-src 'self'; img-src 'self' data: blob: https: http: file: fluxplugin:; media-src 'self' blob: data: https: file: fluxplugin:; frame-src https://www.youtube-nocookie.com https://player.vimeo.com https://www.dailymotion.com https://www.loom.com https://streamable.com" />
370
831
  <link rel="stylesheet" href="./renderer.css" />
371
832
  <link rel="stylesheet" href="./tailwind.css" />
372
833
  </head>
@@ -377,9 +838,8 @@ var HOST_TAILWIND_FILES = {
377
838
  </html>
378
839
  `,
379
840
  "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 { Breadcrumbs, CommandPalette, PluginRoutes, PluginSlot, TitleBar, useKeybindings } from "fluxplugin/react";\nimport { APP_NAME, SLOTS } from "../../shared/constants";\nimport { Sidebar } from "./Sidebar";\n\n/** Same structure as the default template, styled with Tailwind utilities. */\nexport function AppShell() {\n useKeybindings();\n return (\n <div className="app grid min-h-screen grid-rows-[40px_1fr]">\n <TitleBar title={APP_NAME} logo={<PluginSlot name={SLOTS.brandBadges} />} searchPlaceholder="Search commands, pages\u2026" />\n <div className="body grid min-h-0 grid-cols-[220px_1fr]">\n <Sidebar />\n <main className="main max-w-3xl overflow-auto p-6">\n <Breadcrumbs />\n <PluginRoutes />\n </main>\n </div>\n <CommandPalette />\n </div>\n );\n}\n',
381
- "src/app/renderer/shell/Sidebar.tsx": 'import { Link, useMenu } from "fluxplugin/react";\n\nconst link = "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 return (\n <aside className="side border-r border-border bg-surface p-4">\n <Link to="/" className={link}>Dashboard</Link>\n {items.map((i) => (\n <a key={i.id} className={link} href={"#" + i.path} onClick={(e) => (e.preventDefault(), run(i))}>{i.label}</a>\n ))}\n <Link to="/plugins" className={link}>Plugins</Link>\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",
841
+ "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..."\n />\n </header>\n\n {/* Application body (the sidebar width follows --flux-sidebar-w: it collapses to an icon rail) */}\n <div className="body grid min-h-0 min-w-0 grid-cols-[var(--flux-sidebar-w,232px)_minmax(0,1fr)] overflow-hidden transition-[grid-template-columns] duration-200">\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',
842
+ "src/app/renderer/styles/app.css": "body { margin: 0; }\n",
383
843
  "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
844
  };
385
845
 
@@ -390,7 +850,16 @@ var pkg = (v) => JSON.stringify(
390
850
  version: "1.0.0",
391
851
  private: true,
392
852
  type: "module",
393
- scripts: { dev: "fluxplugin dev", build: "fluxplugin build", package: "fluxplugin package", validate: "fluxplugin validate", release: "fluxplugin publish" },
853
+ scripts: {
854
+ dev: "fluxplugin dev",
855
+ "dev:host": "fluxplugin dev --host ../my-app # run it inside your app while you work (edit the path)",
856
+ build: "fluxplugin build",
857
+ package: "fluxplugin package",
858
+ validate: "fluxplugin validate",
859
+ release: "fluxplugin publish"
860
+ },
861
+ // Add any library here (react-markdown, chart.js, a UI kit…): it is bundled into dist/, the host app does not need it.
862
+ dependencies: {},
394
863
  devDependencies: { fluxplugin: "^1.0.0", "@types/react": "^19.0.0", react: "^19.0.0", typescript: "^5.6.0" }
395
864
  },
396
865
  null,
@@ -658,6 +1127,28 @@ function withTailwind(files, v) {
658
1127
  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
1128
  return files;
660
1129
  }
1130
+ function standaloneExtras(v) {
1131
+ return {
1132
+ ".gitignore": "node_modules\ndist\n*.tgz\n.DS_Store\n",
1133
+ "CONTRIBUTING.md": `# Developing ${v.name}
1134
+
1135
+ This is a normal React + TypeScript project. It has its own \`package.json\` and its own libraries.
1136
+
1137
+ \`\`\`bash
1138
+ npm install # your libraries + fluxplugin tooling
1139
+ npm install chart.js # add anything you need: it is bundled into dist/, the app never needs it
1140
+ npm run dev # rebuilds on save
1141
+ npm run dev:host # same, and copies the result into a host app (edit the path) which hot-reloads it
1142
+ npm run build # dist/ \u2014 this is all that ships (no src/, no .ts, no sourcemaps)
1143
+ npm run package # ${v.id}-1.0.0.tgz
1144
+ npm run release # publish to the plugin store (GitHub)
1145
+ \`\`\`
1146
+
1147
+ **Shared with the host** (never bundled twice): \`react\`, \`react-dom\`, \`fluxplugin\`, \`fluxplugin/react\`. Everything else you import is yours.
1148
+ CSS imported in the renderer (including from libraries) is emitted as \`dist/renderer.css\` and loaded automatically; images and fonts are inlined.
1149
+ `
1150
+ };
1151
+ }
661
1152
 
662
1153
  // src/cli/host-templates.ts
663
1154
  function hostFiles(v, withSample = true) {
@@ -669,16 +1160,21 @@ function hostFiles(v, withSample = true) {
669
1160
  private: true,
670
1161
  type: "module",
671
1162
  main: "dist/main.cjs",
1163
+ description: `${v.name} \u2014 built with fluxplugin`,
1164
+ author: "Your name",
672
1165
  scripts: {
673
1166
  dev: "fluxplugin dev",
674
1167
  "dev:web": "fluxplugin dev --web",
675
1168
  build: "fluxplugin build",
676
1169
  start: "fluxplugin start",
1170
+ dist: "fluxplugin dist",
1171
+ "export:web": "fluxplugin export",
1172
+ "plugins:package": "fluxplugin package --all",
677
1173
  "plugin:new": "fluxplugin plugin new",
678
1174
  typecheck: "tsc --noEmit"
679
1175
  },
680
1176
  dependencies: { fluxplugin: v.fluxSpec, react: "^19.0.0", "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" } : {} },
1177
+ devDependencies: { electron: v.electron ?? "^44.5.1", "electron-builder": "^26.0.0", 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
1178
  // npm 11.16+/12 and pnpm 10 block dependency install scripts unless allowed; Electron needs its postinstall to download the binary.
683
1179
  allowScripts: { electron: true, esbuild: true },
684
1180
  pnpm: { onlyBuiltDependencies: ["electron", "esbuild"] }
@@ -687,7 +1183,7 @@ function hostFiles(v, withSample = true) {
687
1183
  2
688
1184
  ),
689
1185
  // "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),
1186
+ "fluxplugin.config.json": JSON.stringify({ pluginsDir: "plugins", outDir: "dist", stores: v.stores ?? [{ type: "github", topic: "fluxplugin-plugin" }] }, null, 2),
691
1187
  "tsconfig.json": JSON.stringify(
692
1188
  { 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"] },
693
1189
  null,
@@ -696,7 +1192,7 @@ function hostFiles(v, withSample = true) {
696
1192
  ".gitignore": "node_modules\ndist\n.flux-data\n*.tgz\n",
697
1193
  "README.md": readme2(v.name)
698
1194
  };
699
- for (const [rel, content] of Object.entries(HOST_APP_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name);
1195
+ for (const [rel, content] of Object.entries(HOST_APP_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name).split("__APP_ID__").join(v.name.replace(/[^a-z0-9]/gi, ""));
700
1196
  if (v.tailwind) for (const [rel, content] of Object.entries(HOST_TAILWIND_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name);
701
1197
  if (withSample) {
702
1198
  const sample = TEMPLATES.dashboard(varsFromName("hello"));
@@ -914,6 +1410,136 @@ async function storeAdd(pkgFile, dir, baseUrl) {
914
1410
  await fs6.writeFile(idxFile, JSON.stringify(idx, null, 2));
915
1411
  return { id: m.id, version: m.version, sha256 };
916
1412
  }
1413
+ async function storeBuild(dir, opts = {}) {
1414
+ const log2 = opts.log ?? console.log;
1415
+ const root = path7.resolve(dir);
1416
+ const pluginsRel = opts.pluginsDir ?? (await fs6.stat(path7.join(root, "plugins")).catch(() => null) ? "plugins" : ".");
1417
+ const base = path7.join(root, pluginsRel);
1418
+ const out = path7.resolve(opts.out ?? path7.join(root, INDEX));
1419
+ const plugins = [];
1420
+ const tmpRoot = await fs6.mkdtemp(path7.join((await import("node:os")).tmpdir(), "flux-store-"));
1421
+ try {
1422
+ for (const name of (await fs6.readdir(base)).sort()) {
1423
+ const folder = path7.join(base, name);
1424
+ let raw;
1425
+ try {
1426
+ raw = JSON.parse(await fs6.readFile(path7.join(folder, "plugin.json"), "utf8"));
1427
+ } catch {
1428
+ continue;
1429
+ }
1430
+ const v = validateManifest(raw);
1431
+ if (!v.manifest) {
1432
+ log2(`! skipped ${name}: ${v.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
1433
+ continue;
1434
+ }
1435
+ const m = v.manifest;
1436
+ const stage = path7.join(tmpRoot, name);
1437
+ const files = (await copyPluginRuntime(folder, stage)).sort();
1438
+ const hashes = {};
1439
+ for (const f of files) hashes[f] = crypto2.createHash("sha256").update(await fs6.readFile(path7.join(stage, f))).digest("hex");
1440
+ const rel = (p) => p ? p.replace(/^\.\//, "") : void 0;
1441
+ const relFolder = path7.relative(root, folder).split(path7.sep).join("/") + "/";
1442
+ const readme3 = await fs6.readFile(path7.join(folder, rel(m.readme) ?? "README.md"), "utf8").then((s) => s.slice(0, 2e4), () => void 0);
1443
+ plugins.push({
1444
+ id: m.id,
1445
+ name: m.name,
1446
+ version: m.version,
1447
+ description: m.description,
1448
+ author: typeof m.author === "string" ? m.author : m.author?.name,
1449
+ permissions: m.permissions ?? [],
1450
+ folder: relFolder,
1451
+ files,
1452
+ hashes,
1453
+ homepage: m.homepage,
1454
+ license: m.license,
1455
+ icon: m.icon ? relFolder + rel(m.icon) : void 0,
1456
+ banner: m.banner ? relFolder + rel(m.banner) : void 0,
1457
+ screenshots: (m.screenshots ?? []).map((s) => relFolder + rel(s)),
1458
+ video: m.video?.startsWith("./") ? relFolder + rel(m.video) : m.video,
1459
+ categories: m.categories,
1460
+ keywords: m.keywords,
1461
+ links: m.links,
1462
+ readme: readme3
1463
+ });
1464
+ log2(` ${m.id}@${m.version} (${files.length} files)`);
1465
+ }
1466
+ } finally {
1467
+ await fs6.rm(tmpRoot, { recursive: true, force: true });
1468
+ }
1469
+ await fs6.writeFile(out, JSON.stringify({ plugins }, null, 2));
1470
+ return { count: plugins.length, file: out };
1471
+ }
1472
+
1473
+ // src/cli/dist.ts
1474
+ import { spawn as spawn2 } from "node:child_process";
1475
+ import { existsSync as existsSync3, promises as fs7 } from "node:fs";
1476
+ import { createRequire as createRequire2 } from "node:module";
1477
+ import path8 from "node:path";
1478
+ async function pluginFolders(dir) {
1479
+ const out = [];
1480
+ for (const n of (await fs7.readdir(dir).catch(() => [])).sort()) if (existsSync3(path8.join(dir, n, "plugin.json"))) out.push(path8.join(dir, n));
1481
+ return out;
1482
+ }
1483
+ async function stagePlugins(cwd, outDir, log2 = console.log) {
1484
+ const cfg = await loadConfig({}, cwd);
1485
+ await fs7.rm(outDir, { recursive: true, force: true });
1486
+ const dirs = await pluginFolders(cfg.pluginsDir);
1487
+ for (const d of dirs) {
1488
+ const files = await copyPluginRuntime(d, path8.join(outDir, path8.basename(d)));
1489
+ log2(` ${path8.basename(d)}: ${files.length} runtime files`);
1490
+ }
1491
+ await fs7.mkdir(outDir, { recursive: true });
1492
+ return dirs.length;
1493
+ }
1494
+ async function distApp(cwd, opts = {}) {
1495
+ const log2 = opts.log ?? console.log;
1496
+ log2("1/3 building the app\u2026");
1497
+ await buildHost(cwd, { log: log2 });
1498
+ log2("2/3 staging plugins (runtime files only)\u2026");
1499
+ const n = await stagePlugins(cwd, path8.join(cwd, ".flux-build/plugins"), log2);
1500
+ log2(`3/3 packaging (${n} plugin(s) bundled)\u2026`);
1501
+ let cli;
1502
+ try {
1503
+ const pj = createRequire2(path8.join(cwd, "package.json")).resolve("electron-builder/package.json");
1504
+ const bin = JSON.parse(await fs7.readFile(pj, "utf8")).bin;
1505
+ cli = path8.join(path8.dirname(pj), typeof bin === "string" ? bin : bin?.["electron-builder"] ?? "cli.js");
1506
+ } catch {
1507
+ throw new Error('electron-builder is not installed: run "npm install -D electron-builder"');
1508
+ }
1509
+ const args = [cli, "--config", existsSync3(path8.join(cwd, "electron-builder.yml")) ? "electron-builder.yml" : "package.json"];
1510
+ if (opts.dir) args.push("--dir");
1511
+ for (const t of ["win", "mac", "linux"]) if (opts[t]) args.push(`--${t}`);
1512
+ return await new Promise((resolve) => {
1513
+ const child = spawn2(process.execPath, args, { cwd, stdio: "inherit", env: { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: process.env["CSC_IDENTITY_AUTO_DISCOVERY"] ?? "false" } });
1514
+ child.on("exit", (c) => resolve(c ?? 1));
1515
+ });
1516
+ }
1517
+ async function exportWebsite(cwd, outDir, log2 = console.log) {
1518
+ await buildHost(cwd, { log: log2 });
1519
+ const out = path8.resolve(outDir);
1520
+ await fs7.rm(out, { recursive: true, force: true });
1521
+ await fs7.mkdir(out, { recursive: true });
1522
+ for (const f of ["index.html", "renderer.js", "renderer.css", "tailwind.css"]) if (existsSync3(path8.join(cwd, "dist", f))) await fs7.copyFile(path8.join(cwd, "dist", f), path8.join(out, f));
1523
+ const cfg = await loadConfig({}, cwd);
1524
+ let count = 0;
1525
+ for (const d of await pluginFolders(cfg.pluginsDir)) {
1526
+ const id = JSON.parse(await fs7.readFile(path8.join(d, "plugin.json"), "utf8")).id;
1527
+ await copyPluginRuntime(d, path8.join(out, "plugins", id));
1528
+ count++;
1529
+ }
1530
+ const tmp = await fs7.mkdtemp(path8.join((await import("node:os")).tmpdir(), "flux-export-"));
1531
+ try {
1532
+ const fw = new MainFramework({ pluginDirs: [path8.join(out, "plugins")], dataDir: tmp, assetUrl: (id, rel) => `./plugins/${id}/${rel.replace(/^\.\//, "")}` });
1533
+ await fw.manager.init();
1534
+ await fw.scan();
1535
+ await fs7.writeFile(path8.join(out, "bootstrap.json"), JSON.stringify(fw.bootstrap(), null, 2));
1536
+ } finally {
1537
+ await fs7.rm(tmp, { recursive: true, force: true });
1538
+ }
1539
+ await fs7.writeFile(path8.join(out, "README.txt"), "Static export. Serve this folder over HTTP(S) (e.g. `npx serve .`); opening index.html from disk will not work.\nOnly the browser side runs here: plugins that need main-process code are listed but their IPC calls fail.\n");
1540
+ log2(`exported ${count} plugin(s) \u2192 ${out}`);
1541
+ return { plugins: count, out };
1542
+ }
917
1543
 
918
1544
  // src/cli/cli.ts
919
1545
  var HELP = `fluxplugin <command>
@@ -923,26 +1549,31 @@ var HELP = `fluxplugin <command>
923
1549
  dev [--web] run your app with plugin hot reload (Electron, or a browser with --web)
924
1550
  build bundle your app (in an app folder) or a plugin (in a plugin folder)
925
1551
  start [--web] run the built app
926
- plugin new <name> [-t basic|dashboard|backend] [--tailwindcss] scaffold a plugin (alias: create)
1552
+ dist [--win|--mac|--linux] [--unpacked] build the app into an installer/executable (electron-builder)
1553
+ export [--out website] extract the app as a static website
1554
+ plugin new <name> [-t basic|dashboard|backend] [--tailwindcss] [--standalone] scaffold a plugin (its own project when outside an app)
1555
+ dev --host <app> (in a plugin project) rebuild on save and sync into a host app that hot-reloads it
927
1556
  validate [dir] check a plugin manifest, entries and integrity
928
1557
  publish [dir] [--repo o/r] [--dry-run] publish a plugin to the store (GitHub release + topic)
929
1558
  search [query] search the configured stores
930
1559
  install <id[@version]|dir|.tgz> install a plugin from a store, a folder or a package
931
1560
  store init <dir> create your own store (index.json) \u2014 host it anywhere
932
1561
  store add <pkg.tgz> [--dir d] [--base-url url] add a package to your store
1562
+ store build [dir] [--out index.json] build the catalog JSON from plugin folders (repo layout)
1563
+ package --all [--out release/plugins] package every plugin of the app
933
1564
  package [dir] [--out d] [--sign-key file] build + integrity (+ signature) + .tgz
934
1565
  install <dir|.tgz> [--dir plugins] uninstall <id> enable <id> disable <id> list
935
1566
  keygen [--out name] create an ed25519 signing key pair
936
1567
  tailwind [--dir plugins] [--out file] generate @source lines for Tailwind v4`;
937
1568
  var log = (...a) => console.log(...a);
938
1569
  async function readManifest(root) {
939
- return JSON.parse(await fs7.readFile(path8.join(root, "plugin.json"), "utf8"));
1570
+ return JSON.parse(await fs8.readFile(path9.join(root, "plugin.json"), "utf8"));
940
1571
  }
941
1572
  async function run(argv) {
942
1573
  const { values: f, positionals: pos } = parseArgs({
943
1574
  args: argv,
944
1575
  allowPositionals: true,
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" } }
1576
+ 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" }, all: { type: "boolean" }, win: { type: "boolean" }, mac: { type: "boolean" }, linux: { type: "boolean" }, unpacked: { type: "boolean" }, store: { type: "string" }, host: { type: "string" }, standalone: { type: "boolean" } }
946
1577
  });
947
1578
  let [cmd, arg] = pos;
948
1579
  if (cmd === "store") {
@@ -956,20 +1587,20 @@ async function run(argv) {
956
1587
  arg = pos[2];
957
1588
  }
958
1589
  if (!cmd || f.help) return log(HELP), cmd ? 0 : 1;
959
- const root = path8.resolve(arg ?? ".");
1590
+ const root = path9.resolve(arg ?? ".");
960
1591
  try {
961
1592
  switch (cmd) {
962
1593
  case "init": {
963
- const target = path8.resolve(arg ?? ".");
964
- const entries = await fs7.readdir(target).catch(() => []);
1594
+ const target = path9.resolve(arg ?? ".");
1595
+ const entries = await fs8.readdir(target).catch(() => []);
965
1596
  if (entries.length) throw new Error(`${target} is not empty`);
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"]);
1597
+ const name = (f.name ?? path9.basename(target)).toLowerCase().replace(/[^a-z0-9-_.]+/g, "-");
1598
+ const files = hostFiles({ name, fluxSpec: process.env["FLUXPLUGIN_SPEC"] ?? `^${await ownVersion()}`, electron: await latestElectron(), tailwind: !!f.tailwindcss, stores: parseStoreSpec(f.store) }, !f["no-sample"]);
968
1599
  for (const [rel2, content] of Object.entries(files)) {
969
- await fs7.mkdir(path8.dirname(path8.join(target, rel2)), { recursive: true });
970
- await fs7.writeFile(path8.join(target, rel2), content);
1600
+ await fs8.mkdir(path9.dirname(path9.join(target, rel2)), { recursive: true });
1601
+ await fs8.writeFile(path9.join(target, rel2), content);
971
1602
  }
972
- const rel = path8.relative(process.cwd(), target) || ".";
1603
+ const rel = path9.relative(process.cwd(), target) || ".";
973
1604
  log(`\u2714 created ${name} in ${target}
974
1605
 
975
1606
  Next steps:
@@ -985,17 +1616,19 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
985
1616
  if (!arg) throw new Error("usage: fluxplugin plugin new <name> [-t basic|dashboard|backend]");
986
1617
  const tpl = f.template ?? "basic";
987
1618
  if (!(tpl in TEMPLATES)) throw new Error(`unknown template "${tpl}"`);
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));
1619
+ const inHost = await isHostProject(process.cwd());
1620
+ const standalone = !!f.standalone || !inHost;
1621
+ const base = inHost && !f.standalone && !path9.isAbsolute(arg) && !arg.includes("/") && !arg.includes("\\") ? path9.join((await loadConfig({}, process.cwd())).pluginsDir, arg) : path9.resolve(arg);
1622
+ if (await fs8.stat(base).catch(() => null)) throw new Error(`${base} already exists`);
1623
+ const vars = varsFromName(path9.basename(base));
991
1624
  let files = TEMPLATES[tpl](vars);
992
1625
  if (f.tailwindcss) files = withTailwind(files, vars);
1626
+ if (standalone) files = { ...files, ...standaloneExtras(vars) };
993
1627
  for (const [rel, content] of Object.entries(files)) {
994
- await fs7.mkdir(path8.dirname(path8.join(base, rel)), { recursive: true });
995
- await fs7.writeFile(path8.join(base, rel), content);
1628
+ await fs8.mkdir(path9.dirname(path9.join(base, rel)), { recursive: true });
1629
+ await fs8.writeFile(path9.join(base, rel), content);
996
1630
  }
997
- log(`\u2714 created ${tpl} plugin in ${base}
998
- next: fluxplugin build ${path8.relative(process.cwd(), base)}`);
1631
+ log(`\u2714 created ${tpl} plugin in ${base}${standalone ? "\n\n cd " + path9.relative(process.cwd(), base) + "\n npm install\n npm run dev:host # run it inside your app while you work (edit the path in package.json)" : "\n next: fluxplugin build " + path9.relative(process.cwd(), base)}`);
999
1632
  return 0;
1000
1633
  }
1001
1634
  case "build": {
@@ -1010,6 +1643,7 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
1010
1643
  const cwd = process.cwd();
1011
1644
  if (arg || !await isHostProject(cwd)) {
1012
1645
  await buildPlugin(root, true);
1646
+ if (f.host) await linkPluginToHost(root, path9.resolve(f.host));
1013
1647
  await new Promise(() => {
1014
1648
  });
1015
1649
  }
@@ -1027,7 +1661,7 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
1027
1661
  }
1028
1662
  case "start": {
1029
1663
  const cwd = process.cwd();
1030
- if (!await fs7.stat(path8.join(cwd, "dist/index.html")).catch(() => null)) throw new Error("nothing built yet: run `fluxplugin build` first");
1664
+ if (!await fs8.stat(path9.join(cwd, "dist/index.html")).catch(() => null)) throw new Error("nothing built yet: run `fluxplugin build` first");
1031
1665
  const { child } = startApp(cwd, { web: f.web, port: f.port ? Number(f.port) : void 0 });
1032
1666
  return await new Promise((r) => child.on("exit", (c) => r(c ?? 0)));
1033
1667
  }
@@ -1035,8 +1669,32 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
1035
1669
  return await doctor(process.cwd());
1036
1670
  case "validate":
1037
1671
  return await validate(root);
1038
- case "package":
1039
- return await packagePlugin(root, { out: f.out, signKey: f["sign-key"] }), 0;
1672
+ case "package": {
1673
+ if (f.all) {
1674
+ const cfg = await loadConfig(f);
1675
+ let n = 0;
1676
+ for (const d of (await fs8.readdir(cfg.pluginsDir).catch(() => [])).sort()) {
1677
+ if (!await fs8.stat(path9.join(cfg.pluginsDir, d, "plugin.json")).catch(() => null)) continue;
1678
+ await packagePlugin(path9.join(cfg.pluginsDir, d), { out: f.out ?? "release/plugins", signKey: f["sign-key"] });
1679
+ n++;
1680
+ }
1681
+ log(`\u2714 packaged ${n} plugin(s) in ${f.out ?? "release/plugins"}`);
1682
+ return 0;
1683
+ }
1684
+ await packagePlugin(root, { out: f.out, signKey: f["sign-key"] });
1685
+ return 0;
1686
+ }
1687
+ case "dist":
1688
+ return await distApp(process.cwd(), { win: f.win, mac: f.mac, linux: f.linux, dir: f.unpacked });
1689
+ case "export":
1690
+ await exportWebsite(process.cwd(), f.out ?? "website");
1691
+ return 0;
1692
+ case "store:build": {
1693
+ const r = await storeBuild(path9.resolve(arg ?? "."), { out: f.out });
1694
+ log(`\u2714 catalog with ${r.count} plugin(s) written to ${r.file}
1695
+ commit it with the plugin folders; point a store at the repo: { "type": "github", "repo": "owner/name" }`);
1696
+ return 0;
1697
+ }
1040
1698
  case "publish":
1041
1699
  await publishPlugin(root, { repo: f.repo, token: f.token, signKey: f["sign-key"], dryRun: f["dry-run"] });
1042
1700
  return 0;
@@ -1053,31 +1711,31 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
1053
1711
  return 0;
1054
1712
  }
1055
1713
  case "store:init":
1056
- await storeInit(path8.resolve(arg ?? "store"));
1057
- log(`\u2714 store created in ${path8.resolve(arg ?? "store")} (see its README.md)`);
1714
+ await storeInit(path9.resolve(arg ?? "store"));
1715
+ log(`\u2714 store created in ${path9.resolve(arg ?? "store")} (see its README.md)`);
1058
1716
  return 0;
1059
1717
  case "store:add": {
1060
1718
  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`);
1719
+ const r = await storeAdd(path9.resolve(arg), path9.resolve(f.dir ?? "store"), f["base-url"]);
1720
+ log(`\u2714 added ${r.id}@${r.version} to ${path9.resolve(f.dir ?? "store")}/index.json`);
1063
1721
  return 0;
1064
1722
  }
1065
1723
  case "keygen": {
1066
1724
  const k = generateSigningKeyPair();
1067
1725
  const base = f.out ?? "flux-signing";
1068
- await fs7.writeFile(`${base}.private.key`, k.privateKey, { mode: 384 });
1069
- await fs7.writeFile(`${base}.public.key`, k.publicKey);
1726
+ await fs8.writeFile(`${base}.private.key`, k.privateKey, { mode: 384 });
1727
+ await fs8.writeFile(`${base}.public.key`, k.publicKey);
1070
1728
  log(`wrote ${base}.private.key (keep secret) and ${base}.public.key (distribute to hosts as a trusted key)`);
1071
1729
  return 0;
1072
1730
  }
1073
1731
  case "tailwind": {
1074
1732
  const cfg = await loadConfig(f);
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, "/")}";`);
1733
+ const names = (await fs8.readdir(cfg.pluginsDir).catch(() => [])).sort();
1734
+ const lines = names.map((n) => `@source "${path9.join(cfg.pluginsDir, n, "src").replace(/\\/g, "/")}";`);
1077
1735
  const css = `/* generated by fluxplugin tailwind */
1078
1736
  ${lines.join("\n")}
1079
1737
  `;
1080
- if (f.out) await fs7.writeFile(f.out, css);
1738
+ if (f.out) await fs8.writeFile(f.out, css);
1081
1739
  else log(css);
1082
1740
  return 0;
1083
1741
  }
@@ -1104,7 +1762,7 @@ async function validate(root) {
1104
1762
  v.warnings.forEach((w) => log(`! ${w}`));
1105
1763
  if (v.manifest) {
1106
1764
  for (const rel of [v.manifest.main, v.manifest.renderer, ...v.manifest.styles ?? []].filter(Boolean))
1107
- if (!await fs7.stat(path8.join(root, rel)).catch(() => null)) log(`\u2716 missing file ${rel} (run build)`), bad = true;
1765
+ if (!await fs8.stat(path9.join(root, rel)).catch(() => null)) log(`\u2716 missing file ${rel} (run build)`), bad = true;
1108
1766
  try {
1109
1767
  await verifyPlugin(root, raw, v.manifest);
1110
1768
  } catch (e) {
@@ -1117,17 +1775,17 @@ async function validate(root) {
1117
1775
  }
1118
1776
  async function manage(cmd, arg, f) {
1119
1777
  const cfg = await loadConfig(f);
1120
- const state = new JsonStateFile(path8.join(cfg.dataDir, "plugins-state.json"));
1121
- const inst = new PluginInstaller(cfg.pluginsDir, path8.join(cfg.dataDir, "backups"));
1778
+ const state = new JsonStateFile(path9.join(cfg.dataDir, "plugins-state.json"));
1779
+ const inst = new PluginInstaller(cfg.pluginsDir, path9.join(cfg.dataDir, "backups"));
1122
1780
  if (cmd === "list") {
1123
1781
  const { disabled } = await state.readAll();
1124
1782
  for (const m of await inst.installed()) log(`${disabled.includes(m.id) ? "\u25CB" : "\u25CF"} ${m.id}@${m.version} ${m.name}`);
1125
1783
  return 0;
1126
1784
  }
1127
1785
  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)) {
1786
+ if (cmd === "install" && arg && !arg.endsWith(".tgz") && !await fs8.stat(arg).catch(() => null)) {
1129
1787
  const [id, version] = arg.split("@");
1130
- const store = new PluginInstaller(cfg.pluginsDir, path8.join(cfg.dataDir, "backups"), createRegistries(cfg.stores));
1788
+ const store = new PluginInstaller(cfg.pluginsDir, path9.join(cfg.dataDir, "backups"), createRegistries(cfg.stores));
1131
1789
  const found = (await store.search(id)).filter((e) => e.id === id && (!version || e.version === version));
1132
1790
  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
1791
  const best = found.sort((a, b) => b.version.localeCompare(a.version, void 0, { numeric: true }))[0];
@@ -1137,16 +1795,16 @@ async function manage(cmd, arg, f) {
1137
1795
  return 0;
1138
1796
  }
1139
1797
  if (cmd === "install") {
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);
1798
+ const tmp = path9.join(cfg.dataDir, ".install", String(Date.now()));
1799
+ if (arg.endsWith(".tgz")) await extractTarGz(await fs8.readFile(arg), tmp);
1800
+ else await copyDir(path9.resolve(arg), tmp);
1143
1801
  const v = validateManifest(await readManifest(tmp));
1144
1802
  if (!v.manifest) throw new Error("invalid plugin: " + v.issues.map((i) => i.message).join("; "));
1145
- const reg = new LocalRegistry(path8.dirname(tmp), "cli");
1803
+ const reg = new LocalRegistry(path9.dirname(tmp), "cli");
1146
1804
  inst.addRegistry(reg);
1147
1805
  const [entry] = (await reg.search(v.manifest.id)).filter((e) => e.id === v.manifest.id);
1148
1806
  const r = await inst.install({ ...entry, location: tmp });
1149
- await fs7.rm(tmp, { recursive: true, force: true });
1807
+ await fs8.rm(tmp, { recursive: true, force: true });
1150
1808
  log(`installed ${r.id}@${r.version}${r.previousVersion ? ` (was ${r.previousVersion}; rollback available)` : ""}`);
1151
1809
  } else if (cmd === "uninstall") {
1152
1810
  await inst.uninstall(arg);
@@ -1160,7 +1818,7 @@ async function manage(cmd, arg, f) {
1160
1818
  async function ownVersion() {
1161
1819
  for (const rel of ["../package.json", "../../package.json"]) {
1162
1820
  try {
1163
- const j = JSON.parse(await fs7.readFile(new URL(rel, import.meta.url), "utf8"));
1821
+ const j = JSON.parse(await fs8.readFile(new URL(rel, import.meta.url), "utf8"));
1164
1822
  if (j.name === "fluxplugin" && j.version) return j.version;
1165
1823
  } catch {
1166
1824
  }
@@ -1186,23 +1844,30 @@ async function doctor(cwd) {
1186
1844
  return bad ? 1 : 0;
1187
1845
  }
1188
1846
  const e = ensureElectron(cwd, log);
1189
- e.bin ? ok(`Electron binary: ${path8.relative(cwd, e.bin) || e.bin}`) : ko(`Electron: ${e.error}
1847
+ e.bin ? ok(`Electron binary: ${path9.relative(cwd, e.bin) || e.bin}`) : ko(`Electron: ${e.error}
1190
1848
  fix: npm install -D electron`);
1191
1849
  try {
1192
1850
  (await import("esbuild")).version ? ok("esbuild") : ko("esbuild");
1193
1851
  } catch {
1194
1852
  ko("esbuild is not available");
1195
1853
  }
1196
- const hasTw = await fs7.stat(path8.join(cwd, "src/app/renderer/styles/tailwind.css")).then(() => true, () => false);
1854
+ const hasTw = await fs8.stat(path9.join(cwd, "src/app/renderer/styles/tailwind.css")).then(() => true, () => false);
1197
1855
  if (hasTw) {
1198
1856
  const { resolveTailwindCli } = await import("./chunks/tailwind-TJQVTBG5.js");
1199
1857
  resolveTailwindCli([cwd]) ? ok("Tailwind CLI") : ko("Tailwind CLI missing: npm install -D tailwindcss @tailwindcss/cli");
1200
1858
  }
1201
- const pkg2 = JSON.parse(await fs7.readFile(path8.join(cwd, "package.json"), "utf8").catch(() => "{}"));
1859
+ const pkg2 = JSON.parse(await fs8.readFile(path9.join(cwd, "package.json"), "utf8").catch(() => "{}"));
1202
1860
  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
1861
  log(bad ? "\nSome checks failed." : "\nAll good.");
1204
1862
  return bad ? 1 : 0;
1205
1863
  }
1864
+ function parseStoreSpec(spec) {
1865
+ if (!spec) return void 0;
1866
+ const gh = /^github:([\w.-]+\/[\w.-]+)$/.exec(spec);
1867
+ if (gh) return [{ type: "github", repo: gh[1] }];
1868
+ if (/^https:\/\/\S+$/.test(spec)) return [spec.toLowerCase().split("?")[0].endsWith(".json") ? { type: "json", url: spec } : { type: "http", url: spec }];
1869
+ throw new Error('--store must be "github:owner/repo" or an https URL (a .json link, or a folder URL ending with /)');
1870
+ }
1206
1871
  export {
1207
1872
  SHARED_MODULES,
1208
1873
  TEMPLATES,
@@ -1212,6 +1877,7 @@ export {
1212
1877
  isHostProject,
1213
1878
  run,
1214
1879
  sharedModulesPlugin,
1880
+ standaloneExtras,
1215
1881
  startApp,
1216
1882
  varsFromName,
1217
1883
  withTailwind