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