fluxplugin 1.0.0
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/LICENSE +9 -0
- package/README.md +50 -0
- package/dist/browser/chunks/chunk-Q75AOCXD.js +2544 -0
- package/dist/browser/chunks/chunk-ZZ25CRMR.js +759 -0
- package/dist/browser/index.js +138 -0
- package/dist/browser/manager-ui.js +165 -0
- package/dist/browser/react.js +73 -0
- package/dist/cli.js +1635 -0
- package/dist/node/chunks/chunk-BZ7NZF7I.js +990 -0
- package/dist/node/chunks/chunk-XPLGAEBH.js +2623 -0
- package/dist/node/cli-api.js +843 -0
- package/dist/node/index.js +138 -0
- package/dist/node/main.js +61 -0
- package/dist/node/preload.js +56 -0
- package/dist/types/cli/bin.d.ts +1 -0
- package/dist/types/cli/build.d.ts +7 -0
- package/dist/types/cli/cli.d.ts +2 -0
- package/dist/types/cli/config.d.ts +9 -0
- package/dist/types/cli/host-templates.d.ts +6 -0
- package/dist/types/cli/host.d.ts +16 -0
- package/dist/types/cli/index.d.ts +6 -0
- package/dist/types/cli/shared-modules.d.ts +8 -0
- package/dist/types/cli/templates.d.ts +10 -0
- package/dist/types/core/api.d.ts +11 -0
- package/dist/types/core/common.d.ts +69 -0
- package/dist/types/core/deps.d.ts +34 -0
- package/dist/types/core/events.d.ts +58 -0
- package/dist/types/core/hooks.d.ts +55 -0
- package/dist/types/core/index.d.ts +18 -0
- package/dist/types/core/ipc.d.ts +148 -0
- package/dist/types/core/manager.d.ts +155 -0
- package/dist/types/core/manifest.d.ts +88 -0
- package/dist/types/core/permissions.d.ts +45 -0
- package/dist/types/core/plugin.d.ts +214 -0
- package/dist/types/core/protocol.d.ts +86 -0
- package/dist/types/core/registry.d.ts +88 -0
- package/dist/types/core/routes.d.ts +105 -0
- package/dist/types/core/schema.d.ts +99 -0
- package/dist/types/core/semver.d.ts +16 -0
- package/dist/types/core/slots.d.ts +75 -0
- package/dist/types/core/storage.d.ts +55 -0
- package/dist/types/core/styles.d.ts +34 -0
- package/dist/types/main/adapters.d.ts +81 -0
- package/dist/types/main/discover.d.ts +21 -0
- package/dist/types/main/framework.d.ts +78 -0
- package/dist/types/main/fs-storage.d.ts +33 -0
- package/dist/types/main/index.d.ts +8 -0
- package/dist/types/main/registry.d.ts +75 -0
- package/dist/types/main/security.d.ts +24 -0
- package/dist/types/main/serve-web.d.ts +14 -0
- package/dist/types/main/tar.d.ts +10 -0
- package/dist/types/manager-ui/index.d.ts +7 -0
- package/dist/types/preload/index.d.ts +27 -0
- package/dist/types/react/base-css.d.ts +2 -0
- package/dist/types/react/components.d.ts +89 -0
- package/dist/types/react/http-bridge.d.ts +10 -0
- package/dist/types/react/index.d.ts +6 -0
- package/dist/types/react/palette.d.ts +10 -0
- package/dist/types/react/router.d.ts +47 -0
- package/dist/types/react/runtime.d.ts +99 -0
- package/docs/ARCHITECTURE.md +31 -0
- package/docs/PLUGIN-AUTHORS.md +11 -0
- package/docs/RELEASE.md +20 -0
- package/docs/SECURITY.md +18 -0
- package/docs/screenshots/command-palette.png +0 -0
- package/docs/screenshots/dashboard.png +0 -0
- package/docs/screenshots/plugin-details.png +0 -0
- package/docs/screenshots/plugin-manager.png +0 -0
- package/docs/screenshots/stats.png +0 -0
- package/package.json +112 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JsonStateFile,
|
|
3
|
+
LocalRegistry,
|
|
4
|
+
PluginInstaller,
|
|
5
|
+
computeIntegrity,
|
|
6
|
+
copyDir,
|
|
7
|
+
createTarGz,
|
|
8
|
+
extractTarGz,
|
|
9
|
+
generateSigningKeyPair,
|
|
10
|
+
signManifest,
|
|
11
|
+
verifyPlugin
|
|
12
|
+
} from "./chunks/chunk-BZ7NZF7I.js";
|
|
13
|
+
import {
|
|
14
|
+
validateManifest
|
|
15
|
+
} from "./chunks/chunk-XPLGAEBH.js";
|
|
16
|
+
|
|
17
|
+
// src/cli/cli.ts
|
|
18
|
+
import { parseArgs } from "node:util";
|
|
19
|
+
import { promises as fs4 } from "node:fs";
|
|
20
|
+
import path5 from "node:path";
|
|
21
|
+
import crypto from "node:crypto";
|
|
22
|
+
|
|
23
|
+
// src/cli/build.ts
|
|
24
|
+
import { promises as fs } from "node:fs";
|
|
25
|
+
import path2 from "node:path";
|
|
26
|
+
import { build, context } from "esbuild";
|
|
27
|
+
|
|
28
|
+
// src/cli/shared-modules.ts
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
var SHARED_MODULES = ["react", "react/jsx-runtime", "react/jsx-dev-runtime", "react-dom", "react-dom/client", "fluxplugin", "fluxplugin/react", "fluxplugin/manager-ui"];
|
|
31
|
+
function sharedModulesPlugin(shared = SHARED_MODULES) {
|
|
32
|
+
const set = new Set(shared);
|
|
33
|
+
return {
|
|
34
|
+
name: "flux-shared-modules",
|
|
35
|
+
setup(b) {
|
|
36
|
+
b.onResolve({ filter: /.*/ }, (args) => {
|
|
37
|
+
if (set.has(args.path)) return { path: args.path, namespace: "flux-shared" };
|
|
38
|
+
return void 0;
|
|
39
|
+
});
|
|
40
|
+
b.onLoad({ filter: /.*/, namespace: "flux-shared" }, (args) => ({
|
|
41
|
+
contents: `const m = globalThis.__FLUXPLUGIN_SHARED__ && globalThis.__FLUXPLUGIN_SHARED__[${JSON.stringify(args.path)}];
|
|
42
|
+
if (!m) throw new Error("Shared module ${args.path} is not provided by the host application (see installSharedModules).");
|
|
43
|
+
module.exports = m;`,
|
|
44
|
+
loader: "js",
|
|
45
|
+
resolveDir: path.resolve(".")
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/cli/build.ts
|
|
52
|
+
var exists = (f) => fs.access(f).then(() => true, () => false);
|
|
53
|
+
async function firstExisting(root, names) {
|
|
54
|
+
for (const n of names) if (await exists(path2.join(root, n))) return path2.join(root, n);
|
|
55
|
+
}
|
|
56
|
+
async function buildOptions(root) {
|
|
57
|
+
const raw = JSON.parse(await fs.readFile(path2.join(root, "plugin.json"), "utf8"));
|
|
58
|
+
const m = validateManifest(raw).manifest;
|
|
59
|
+
if (!m) throw new Error("invalid plugin.json (run `fluxplugin validate`)");
|
|
60
|
+
const out = [];
|
|
61
|
+
const common = { bundle: true, sourcemap: true, logLevel: "warning", plugins: [sharedModulesPlugin()], jsx: "automatic", loader: { ".module.css": "local-css" } };
|
|
62
|
+
if (m.main) {
|
|
63
|
+
const entry = await firstExisting(root, ["src/main.ts", "src/main.js"]);
|
|
64
|
+
if (!entry) throw new Error("manifest declares `main` but src/main.ts was not found");
|
|
65
|
+
out.push({ ...common, entryPoints: [entry], outfile: path2.join(root, m.main), platform: "node", format: "esm", target: "node20", external: ["node:*", "electron"] });
|
|
66
|
+
}
|
|
67
|
+
if (m.renderer) {
|
|
68
|
+
const entry = await firstExisting(root, ["src/renderer.tsx", "src/renderer.ts", "src/renderer.jsx", "src/renderer.js"]);
|
|
69
|
+
if (!entry) throw new Error("manifest declares `renderer` but src/renderer.tsx was not found");
|
|
70
|
+
out.push({ ...common, entryPoints: [entry], outfile: path2.join(root, m.renderer), platform: "browser", format: "esm", target: "chrome120" });
|
|
71
|
+
}
|
|
72
|
+
for (const css of m.styles ?? []) {
|
|
73
|
+
const entry = path2.join(root, "src/styles", path2.basename(css));
|
|
74
|
+
if (await exists(entry)) out.push({ ...common, plugins: [], entryPoints: [entry], outfile: path2.join(root, css) });
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
async function buildPlugin(root, watch = false, log2 = console.log) {
|
|
79
|
+
const opts = await buildOptions(root);
|
|
80
|
+
if (!watch) {
|
|
81
|
+
for (const o of opts) await build(o);
|
|
82
|
+
log2(`built ${opts.length} bundle(s) in ${root}`);
|
|
83
|
+
return async () => {
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const ctxs = await Promise.all(opts.map((o) => context(o)));
|
|
87
|
+
await Promise.all(ctxs.map((c) => c.watch()));
|
|
88
|
+
log2(`watching ${root} (${ctxs.length} bundles)`);
|
|
89
|
+
return async () => void await Promise.all(ctxs.map((c) => c.dispose()));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/cli/host.ts
|
|
93
|
+
import { spawn } from "node:child_process";
|
|
94
|
+
import { promises as fs3 } from "node:fs";
|
|
95
|
+
import { createRequire } from "node:module";
|
|
96
|
+
import path4 from "node:path";
|
|
97
|
+
import { build as build2, context as context2 } from "esbuild";
|
|
98
|
+
|
|
99
|
+
// src/cli/config.ts
|
|
100
|
+
import { promises as fs2 } from "node:fs";
|
|
101
|
+
import path3 from "node:path";
|
|
102
|
+
async function loadConfig(flags, cwd = process.cwd()) {
|
|
103
|
+
let file = {};
|
|
104
|
+
try {
|
|
105
|
+
file = JSON.parse(await fs2.readFile(path3.join(cwd, "fluxplugin.config.json"), "utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
pluginsDir: path3.resolve(cwd, flags.dir ?? file.pluginsDir ?? "plugins"),
|
|
110
|
+
dataDir: path3.resolve(cwd, flags.data ?? file.dataDir ?? ".flux-data")
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/cli/host.ts
|
|
115
|
+
var exists2 = (f) => fs3.access(f).then(() => true, () => false);
|
|
116
|
+
async function isHostProject(cwd) {
|
|
117
|
+
return await exists2(path4.join(cwd, "fluxplugin.config.json")) || await exists2(path4.join(cwd, "src/renderer.tsx")) && await exists2(path4.join(cwd, "src/framework.ts"));
|
|
118
|
+
}
|
|
119
|
+
var nodeBanner = { js: "import { createRequire as __r } from 'node:module'; const require = __r(import.meta.url);" };
|
|
120
|
+
function hostBuilds(cwd, outDir, dev) {
|
|
121
|
+
const common = { absWorkingDir: cwd, bundle: true, sourcemap: dev, logLevel: "warning", jsx: "automatic", define: { "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production") } };
|
|
122
|
+
const entry = (f) => path4.join(cwd, "src", f);
|
|
123
|
+
return [
|
|
124
|
+
{ ...common, entryPoints: [entry("renderer.tsx")], outfile: path4.join(outDir, "renderer.js"), platform: "browser", format: "esm", target: "chrome120" },
|
|
125
|
+
{ ...common, entryPoints: [entry("main.ts")], outfile: path4.join(outDir, "main.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
|
|
126
|
+
{ ...common, entryPoints: [entry("preload.ts")], outfile: path4.join(outDir, "preload.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
|
|
127
|
+
{ ...common, entryPoints: [entry("web.ts")], outfile: path4.join(outDir, "web.mjs"), platform: "node", format: "esm", target: "node20", external: ["electron"], banner: nodeBanner }
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
async function pluginDirs(cwd, pluginsDir) {
|
|
131
|
+
const names = await fs3.readdir(pluginsDir).catch(() => []);
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const n of names.sort()) if (await exists2(path4.join(pluginsDir, n, "plugin.json"))) out.push(path4.join(pluginsDir, n));
|
|
134
|
+
void cwd;
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
async function buildHost(cwd, opts = {}) {
|
|
138
|
+
const log2 = opts.log ?? console.log;
|
|
139
|
+
const cfgFile = JSON.parse(await fs3.readFile(path4.join(cwd, "fluxplugin.config.json"), "utf8").catch(() => "{}"));
|
|
140
|
+
const cfg = await loadConfig({}, cwd);
|
|
141
|
+
const outDir = path4.resolve(cwd, cfgFile.outDir ?? "dist");
|
|
142
|
+
await fs3.mkdir(outDir, { recursive: true });
|
|
143
|
+
await fs3.copyFile(path4.join(cwd, "src/index.html"), path4.join(outDir, "index.html"));
|
|
144
|
+
const disposers = [];
|
|
145
|
+
const opts2 = hostBuilds(cwd, outDir, !!opts.watch);
|
|
146
|
+
if (opts.watch) {
|
|
147
|
+
const ctxs = await Promise.all(opts2.map((o) => context2(o)));
|
|
148
|
+
await Promise.all(ctxs.map((c) => c.watch()));
|
|
149
|
+
disposers.push(async () => void await Promise.all(ctxs.map((c) => c.dispose())));
|
|
150
|
+
} else for (const o of opts2) await build2(o);
|
|
151
|
+
for (const dir of await pluginDirs(cwd, cfg.pluginsDir)) {
|
|
152
|
+
try {
|
|
153
|
+
disposers.push(await buildPlugin(dir, !!opts.watch, log2));
|
|
154
|
+
} catch (e) {
|
|
155
|
+
log2(`! plugin ${path4.basename(dir)} not built: ${e.message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
log2(`host built \u2192 ${path4.relative(cwd, outDir) || "."}`);
|
|
159
|
+
return async () => void await Promise.all(disposers.map((d) => d()));
|
|
160
|
+
}
|
|
161
|
+
function electronBinary(cwd) {
|
|
162
|
+
try {
|
|
163
|
+
const p = createRequire(path4.join(cwd, "package.json"))("electron");
|
|
164
|
+
return typeof p === "string" ? p : void 0;
|
|
165
|
+
} catch {
|
|
166
|
+
return void 0;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function startApp(cwd, opts = {}) {
|
|
170
|
+
const bin = opts.web ? void 0 : electronBinary(cwd);
|
|
171
|
+
const env = { ...process.env, FLUXPLUGIN_PROJECT: cwd, ...opts.dev ? { FLUXPLUGIN_DEV: "1" } : {}, ...opts.port ? { PORT: String(opts.port) } : {} };
|
|
172
|
+
if (bin) return { child: spawn(bin, [path4.join(cwd, "dist/main.cjs")], { cwd, env, stdio: "inherit" }), mode: "electron" };
|
|
173
|
+
return { child: spawn(process.execPath, [path4.join(cwd, "dist/web.mjs")], { cwd, env, stdio: "inherit" }), mode: "web" };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/cli/templates.ts
|
|
177
|
+
var pkg = (v) => JSON.stringify(
|
|
178
|
+
{
|
|
179
|
+
name: v.id,
|
|
180
|
+
version: "1.0.0",
|
|
181
|
+
private: true,
|
|
182
|
+
type: "module",
|
|
183
|
+
scripts: { dev: "fluxplugin dev", build: "fluxplugin build", package: "fluxplugin package", validate: "fluxplugin validate" },
|
|
184
|
+
devDependencies: { fluxplugin: "^1.0.0", "@types/react": "^19.0.0", react: "^19.0.0", typescript: "^5.6.0" }
|
|
185
|
+
},
|
|
186
|
+
null,
|
|
187
|
+
2
|
|
188
|
+
);
|
|
189
|
+
var tsconfig = `{
|
|
190
|
+
"compilerOptions": {
|
|
191
|
+
"target": "ES2022",
|
|
192
|
+
"module": "ESNext",
|
|
193
|
+
"moduleResolution": "Bundler",
|
|
194
|
+
"jsx": "react-jsx",
|
|
195
|
+
"strict": true,
|
|
196
|
+
"noEmit": true,
|
|
197
|
+
"skipLibCheck": true,
|
|
198
|
+
"lib": ["ES2022", "DOM"]
|
|
199
|
+
},
|
|
200
|
+
"include": ["src"]
|
|
201
|
+
}
|
|
202
|
+
`;
|
|
203
|
+
var styles = (v) => `/* Styles are loaded when the plugin activates and removed when it is disabled. */
|
|
204
|
+
.${v.slug}-card {
|
|
205
|
+
border: 1px solid var(--flux-border, #e2e8f0);
|
|
206
|
+
border-radius: 12px;
|
|
207
|
+
padding: 16px;
|
|
208
|
+
}
|
|
209
|
+
`;
|
|
210
|
+
function basic(v) {
|
|
211
|
+
return {
|
|
212
|
+
"plugin.json": JSON.stringify(
|
|
213
|
+
{
|
|
214
|
+
id: v.id,
|
|
215
|
+
name: v.name,
|
|
216
|
+
version: "1.0.0",
|
|
217
|
+
description: `${v.name} plugin`,
|
|
218
|
+
author: "Your name",
|
|
219
|
+
main: "./dist/main.mjs",
|
|
220
|
+
renderer: "./dist/renderer.js",
|
|
221
|
+
styles: ["./dist/index.css"],
|
|
222
|
+
activationEvents: [`onCommand:${v.slug}.hello`],
|
|
223
|
+
permissions: ["commands", "routes", "menus", "ui", "styles", "events"],
|
|
224
|
+
engines: { "fluxplugin": "^1.0.0" },
|
|
225
|
+
contributes: { commands: [{ id: `${v.slug}.hello`, title: `${v.name}: Say hello` }] }
|
|
226
|
+
},
|
|
227
|
+
null,
|
|
228
|
+
2
|
|
229
|
+
),
|
|
230
|
+
"package.json": pkg(v),
|
|
231
|
+
"tsconfig.json": tsconfig,
|
|
232
|
+
"src/main.ts": `import { definePlugin } from "fluxplugin";
|
|
233
|
+
|
|
234
|
+
export default definePlugin({
|
|
235
|
+
onActivate(ctx) {
|
|
236
|
+
ctx.logger.info("${v.name} (main) activated");
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
`,
|
|
240
|
+
"src/renderer.tsx": `import { definePlugin } from "fluxplugin";
|
|
241
|
+
|
|
242
|
+
function ${v.Pascal}Page() {
|
|
243
|
+
return (
|
|
244
|
+
<div className="${v.slug}-card">
|
|
245
|
+
<h1>${v.name}</h1>
|
|
246
|
+
<p>Your first plugin page. Edit <code>src/renderer.tsx</code> and save.</p>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export default definePlugin({
|
|
252
|
+
routes: [
|
|
253
|
+
{
|
|
254
|
+
path: "/${v.slug}",
|
|
255
|
+
component: ${v.Pascal}Page,
|
|
256
|
+
meta: { title: "${v.name}", menu: { label: "${v.name}" } },
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
commands: [
|
|
260
|
+
{
|
|
261
|
+
id: "${v.slug}.hello",
|
|
262
|
+
title: "${v.name}: Say hello",
|
|
263
|
+
execute: () => console.log("Hello from ${v.name}!"),
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
});
|
|
267
|
+
`,
|
|
268
|
+
"src/styles/index.css": styles(v),
|
|
269
|
+
"README.md": `# ${v.name}
|
|
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
|
+
`
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function dashboard(v) {
|
|
283
|
+
const files = basic(v);
|
|
284
|
+
const m = JSON.parse(files["plugin.json"]);
|
|
285
|
+
m.activationEvents = ["onStartup"];
|
|
286
|
+
m.contributes = {};
|
|
287
|
+
m.permissions = ["commands", "routes", "menus", "ui", "styles"];
|
|
288
|
+
files["plugin.json"] = JSON.stringify(m, null, 2);
|
|
289
|
+
files["src/renderer.tsx"] = `import { definePlugin } from "fluxplugin";
|
|
290
|
+
import { usePluginState } from "fluxplugin/react";
|
|
291
|
+
|
|
292
|
+
function ${v.Pascal}Widget() {
|
|
293
|
+
const [n, setN] = usePluginState("${v.id}", "clicks", 0);
|
|
294
|
+
return (
|
|
295
|
+
<div className="${v.slug}-card" data-testid="${v.slug}-widget">
|
|
296
|
+
<strong>${v.name}</strong>
|
|
297
|
+
<p>A widget injected into the host's <code>dashboard.after</code> slot.</p>
|
|
298
|
+
<button onClick={() => setN((c) => c + 1)}>Clicked {n} times</button>
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function ${v.Pascal}Page() {
|
|
304
|
+
return <div className="${v.slug}-card"><h1>${v.name}</h1><p>Registered at runtime by a plugin.</p></div>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export default definePlugin({
|
|
308
|
+
routes: [{ path: "/${v.slug}", lazy: async () => ({ default: ${v.Pascal}Page }), meta: { title: "${v.name}", breadcrumb: "${v.name}", menu: { label: "${v.name}" } } }],
|
|
309
|
+
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
|
+
commands: [{ id: "${v.slug}.hello", title: "${v.name}: say hello", category: "${v.name}", execute: () => console.log("Hello from ${v.name}!") }],
|
|
313
|
+
});
|
|
314
|
+
`;
|
|
315
|
+
return files;
|
|
316
|
+
}
|
|
317
|
+
function backend(v) {
|
|
318
|
+
const files = basic(v);
|
|
319
|
+
const m = JSON.parse(files["plugin.json"]);
|
|
320
|
+
m.activationEvents = [];
|
|
321
|
+
m.permissions = ["ipc", "services", "events", "commands", "routes", "menus", "ui", "styles", "storage"];
|
|
322
|
+
m.contributes = {};
|
|
323
|
+
files["plugin.json"] = JSON.stringify(m, null, 2);
|
|
324
|
+
files["src/main.ts"] = `import { definePlugin, defineIPC, s } from "fluxplugin";
|
|
325
|
+
|
|
326
|
+
const Input = s.object({ name: s.string({ min: 1 }) });
|
|
327
|
+
const Output = s.object({ message: s.string(), at: s.number() });
|
|
328
|
+
|
|
329
|
+
export const hello = defineIPC({
|
|
330
|
+
name: "${v.slug}.hello",
|
|
331
|
+
input: Input,
|
|
332
|
+
output: Output,
|
|
333
|
+
handler: async ({ name }, ctx) => ({ message: \`Hello \${name} from \${ctx.pluginId}\`, at: Date.now() }),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
export default definePlugin({
|
|
337
|
+
ipc: [hello],
|
|
338
|
+
// Exposed to the renderer as window.plugins.${v.slug}.hello(...)
|
|
339
|
+
preload: { ${v.slug}: { hello: "${v.slug}.hello" } },
|
|
340
|
+
services: { "${v.slug}": { now: () => Date.now() } },
|
|
341
|
+
});
|
|
342
|
+
`;
|
|
343
|
+
files["src/renderer.tsx"] = `import { definePlugin } from "fluxplugin";
|
|
344
|
+
import { usePluginIPC } from "fluxplugin/react";
|
|
345
|
+
|
|
346
|
+
function ${v.Pascal}Page() {
|
|
347
|
+
const { data, call, loading, error } = usePluginIPC<{ name: string }, { message: string }>("${v.slug}.hello", { caller: "${v.id}" });
|
|
348
|
+
return (
|
|
349
|
+
<div className="${v.slug}-card">
|
|
350
|
+
<button disabled={loading} onClick={() => void call({ name: "world" })}>Call main process</button>
|
|
351
|
+
{data && <p>{data.message}</p>}
|
|
352
|
+
{error && <p role="alert">{error.message}</p>}
|
|
353
|
+
</div>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export default definePlugin({
|
|
358
|
+
routes: [{ path: "/${v.slug}", component: ${v.Pascal}Page, meta: { title: "${v.name}", menu: { label: "${v.name}" } } }],
|
|
359
|
+
});
|
|
360
|
+
`;
|
|
361
|
+
return files;
|
|
362
|
+
}
|
|
363
|
+
var TEMPLATES = { basic, dashboard, backend };
|
|
364
|
+
function varsFromName(name) {
|
|
365
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "my-plugin";
|
|
366
|
+
const id = slug.includes(".") ? slug : `com.example.${slug}`;
|
|
367
|
+
const short = slug.split(".").pop();
|
|
368
|
+
const Pascal = short.split(/[^a-z0-9]+/i).filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join("");
|
|
369
|
+
return { id, name: short.split("-").map((p) => p[0].toUpperCase() + p.slice(1)).join(" "), slug: short.replace(/-/g, "_"), Pascal };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/cli/host-templates.ts
|
|
373
|
+
function hostFiles(v, withSample = true) {
|
|
374
|
+
const files = {
|
|
375
|
+
"package.json": JSON.stringify(
|
|
376
|
+
{
|
|
377
|
+
name: v.name,
|
|
378
|
+
version: "0.1.0",
|
|
379
|
+
private: true,
|
|
380
|
+
type: "module",
|
|
381
|
+
main: "dist/main.cjs",
|
|
382
|
+
scripts: {
|
|
383
|
+
dev: "fluxplugin dev",
|
|
384
|
+
"dev:web": "fluxplugin dev --web",
|
|
385
|
+
build: "fluxplugin build",
|
|
386
|
+
start: "fluxplugin start",
|
|
387
|
+
"plugin:new": "fluxplugin plugin new",
|
|
388
|
+
typecheck: "tsc --noEmit"
|
|
389
|
+
},
|
|
390
|
+
dependencies: { fluxplugin: v.fluxSpec, react: "^19.0.0", "react-dom": "^19.0.0" },
|
|
391
|
+
devDependencies: { electron: "^33.0.0", typescript: "~5.9.3", "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0" }
|
|
392
|
+
},
|
|
393
|
+
null,
|
|
394
|
+
2
|
|
395
|
+
),
|
|
396
|
+
"fluxplugin.config.json": JSON.stringify({ pluginsDir: "plugins", outDir: "dist" }, null, 2),
|
|
397
|
+
"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"] },
|
|
399
|
+
null,
|
|
400
|
+
2
|
|
401
|
+
),
|
|
402
|
+
".gitignore": "node_modules\ndist\n.flux-data\n*.tgz\n",
|
|
403
|
+
"src/index.html": `<!doctype html>
|
|
404
|
+
<html><head><meta charset="utf-8"><title>${v.name}</title>
|
|
405
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self' fluxplugin:; script-src 'self' fluxplugin:; style-src 'self' 'unsafe-inline' fluxplugin:; connect-src 'self'; img-src 'self' data:">
|
|
406
|
+
<style>body{margin:0;font:14px system-ui}</style></head>
|
|
407
|
+
<body><div id="root"></div><script type="module" src="./renderer.js"></script></body></html>
|
|
408
|
+
`,
|
|
409
|
+
"src/framework.ts": `import { MainFramework } from "fluxplugin/main";
|
|
410
|
+
import { s } from "fluxplugin";
|
|
411
|
+
|
|
412
|
+
/** Shared by Electron (main.ts) and browser mode (web.ts). Put host-side setup here. */
|
|
413
|
+
export function createFramework(opts: { pluginDirs: string[]; dataDir: string; dev?: boolean; assetUrl?: (id: string, rel: string) => string }) {
|
|
414
|
+
const fw = new MainFramework({
|
|
415
|
+
pluginDirs: opts.pluginDirs,
|
|
416
|
+
dataDir: opts.dataDir,
|
|
417
|
+
hotReload: opts.dev,
|
|
418
|
+
assetUrl: opts.assetUrl,
|
|
419
|
+
requireApproval: false, // set true to make users approve each plugin's permissions
|
|
420
|
+
allowedRendererChannels: ["host.item.create"],
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// A host operation wrapped in the hook pipeline: plugins can validate, normalize or veto it
|
|
424
|
+
// (before./transform./after.item.create) without changing this file.
|
|
425
|
+
fw.manager.ipc.handle(
|
|
426
|
+
"host.item.create",
|
|
427
|
+
(input: { name: string }) =>
|
|
428
|
+
fw.manager.hooks.run("item.create", input, (i) => {
|
|
429
|
+
const item = { id: Math.random().toString(36).slice(2, 8), name: i.name };
|
|
430
|
+
fw.manager.events.emit("item.created", item, "host");
|
|
431
|
+
return item as Record<string, unknown>;
|
|
432
|
+
}),
|
|
433
|
+
"host",
|
|
434
|
+
{ input: s.object({ name: s.string() }) },
|
|
435
|
+
);
|
|
436
|
+
return fw;
|
|
437
|
+
}
|
|
438
|
+
`,
|
|
439
|
+
"src/main.ts": `// Electron main process.
|
|
440
|
+
import { app, BrowserWindow, ipcMain, protocol, webContents } from "electron";
|
|
441
|
+
import path from "node:path";
|
|
442
|
+
import { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";
|
|
443
|
+
import { createFramework } from "./framework.js";
|
|
444
|
+
|
|
445
|
+
protocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before app is ready
|
|
446
|
+
|
|
447
|
+
app.whenReady().then(async () => {
|
|
448
|
+
const dev = !!process.env["FLUXPLUGIN_DEV"];
|
|
449
|
+
const root = process.env["FLUXPLUGIN_PROJECT"] ?? path.resolve(__dirname, "..");
|
|
450
|
+
const fw = createFramework({
|
|
451
|
+
pluginDirs: [path.join(root, "plugins"), path.join(app.getPath("userData"), "plugins")], // bundled + user-installed
|
|
452
|
+
dataDir: path.join(app.getPath("userData"), "plugins-data"),
|
|
453
|
+
dev,
|
|
454
|
+
});
|
|
455
|
+
registerPluginProtocol(fw, protocol);
|
|
456
|
+
attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });
|
|
457
|
+
await fw.start();
|
|
458
|
+
|
|
459
|
+
const win = new BrowserWindow({
|
|
460
|
+
width: 1100,
|
|
461
|
+
height: 760,
|
|
462
|
+
webPreferences: { preload: path.join(__dirname, "preload.cjs"), contextIsolation: true, sandbox: true, nodeIntegration: false },
|
|
463
|
+
});
|
|
464
|
+
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
465
|
+
win.webContents.on("will-navigate", (e) => e.preventDefault());
|
|
466
|
+
await win.loadFile(path.join(__dirname, "index.html"));
|
|
467
|
+
app.on("before-quit", () => void fw.stop());
|
|
468
|
+
});
|
|
469
|
+
app.on("window-all-closed", () => app.quit());
|
|
470
|
+
`,
|
|
471
|
+
"src/preload.ts": `import { contextBridge, ipcRenderer } from "electron";
|
|
472
|
+
import { exposeFluxBridge } from "fluxplugin/preload";
|
|
473
|
+
|
|
474
|
+
exposeFluxBridge(contextBridge, ipcRenderer); // the only thing renderer code receives: window.__fluxplugin
|
|
475
|
+
`,
|
|
476
|
+
"src/web.ts": `// Browser mode (no Electron): \`fluxplugin dev --web\`.
|
|
477
|
+
import path from "node:path";
|
|
478
|
+
import { serveWeb } from "fluxplugin/main";
|
|
479
|
+
import { createFramework } from "./framework.js";
|
|
480
|
+
|
|
481
|
+
const root = process.env["FLUXPLUGIN_PROJECT"] ?? process.cwd();
|
|
482
|
+
const fw = createFramework({
|
|
483
|
+
pluginDirs: [path.join(root, "plugins")],
|
|
484
|
+
dataDir: path.join(root, ".flux-data"),
|
|
485
|
+
dev: true,
|
|
486
|
+
assetUrl: (id, rel) => \`/@plugins/\${id}/\${rel.replace(/^\\.\\//, "")}\`,
|
|
487
|
+
});
|
|
488
|
+
await fw.start();
|
|
489
|
+
const srv = await serveWeb(fw, { distDir: path.join(root, "dist"), port: Number(process.env["PORT"] ?? 5173) });
|
|
490
|
+
console.log(\`${v.name} \u2192 http://127.0.0.1:\${srv.port}\`);
|
|
491
|
+
`,
|
|
492
|
+
"src/renderer.tsx": renderer(v.name),
|
|
493
|
+
"README.md": `# ${v.name}
|
|
494
|
+
|
|
495
|
+
Created with [fluxplugin](https://www.npmjs.com/package/fluxplugin).
|
|
496
|
+
|
|
497
|
+
\`\`\`bash
|
|
498
|
+
npm install
|
|
499
|
+
npm run dev:web # in a browser, no Electron needed
|
|
500
|
+
npm run dev # in Electron (hot reload for plugins)
|
|
501
|
+
npm run build && npm start
|
|
502
|
+
npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
|
|
503
|
+
\`\`\`
|
|
504
|
+
|
|
505
|
+
* \`src/renderer.tsx\` \u2014 your React UI. \`<PluginSlot name="\u2026"/>\` and extension points are where plugins plug in.
|
|
506
|
+
* \`src/framework.ts\` \u2014 host-side setup (permissions policy, host operations exposed to plugins, hooks).
|
|
507
|
+
* \`plugins/\` \u2014 plugins loaded at startup; users can also install \`.tgz\` plugins from the Plugins page.
|
|
508
|
+
`
|
|
509
|
+
};
|
|
510
|
+
if (withSample) {
|
|
511
|
+
const sample = TEMPLATES.dashboard(varsFromName("hello"));
|
|
512
|
+
for (const [rel, content] of Object.entries(sample)) {
|
|
513
|
+
if (rel === "package.json" || rel === "tsconfig.json" || rel === "README.md") continue;
|
|
514
|
+
files[`plugins/hello/${rel}`] = content;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return files;
|
|
518
|
+
}
|
|
519
|
+
var renderer = (app) => `import React from "react";
|
|
520
|
+
import { createRoot } from "react-dom/client";
|
|
521
|
+
import * as jsxRuntime from "react/jsx-runtime";
|
|
522
|
+
import * as ReactDOMClient from "react-dom/client";
|
|
523
|
+
import * as flux from "fluxplugin";
|
|
524
|
+
import * as fluxReact from "fluxplugin/react";
|
|
525
|
+
import { s, type FluxBridge } from "fluxplugin";
|
|
526
|
+
import {
|
|
527
|
+
Breadcrumbs, CommandPalette, Link, PluginProvider, PluginRoutes, PluginSlot, RendererFramework, StyleHost,
|
|
528
|
+
baseCss, createHttpBridge, installSharedModules, useExtensionItems, useKeybindings, useMenu,
|
|
529
|
+
} from "fluxplugin/react";
|
|
530
|
+
import { PluginManagerUI } from "fluxplugin/manager-ui";
|
|
531
|
+
|
|
532
|
+
// One copy of React and of the runtime, shared with plugin bundles (they import these from globalThis).
|
|
533
|
+
installSharedModules({
|
|
534
|
+
react: React, "react/jsx-runtime": jsxRuntime, "react/jsx-dev-runtime": jsxRuntime, "react-dom/client": ReactDOMClient,
|
|
535
|
+
fluxplugin: flux, "fluxplugin/react": fluxReact,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// Electron: the preload bridge. Browser mode: HTTP bridge to \`fluxplugin dev --web\`.
|
|
539
|
+
const bridge: FluxBridge = (window as unknown as { __fluxplugin?: FluxBridge }).__fluxplugin ?? createHttpBridge();
|
|
540
|
+
const framework = new RendererFramework({ bridge });
|
|
541
|
+
|
|
542
|
+
// Declare extension points BEFORE plugins start. Plugins contribute validated data to them.
|
|
543
|
+
framework.manager.extensionPoints.declare({
|
|
544
|
+
name: "dashboard",
|
|
545
|
+
contributions: { toolbar: s.object({ id: s.string(), label: s.string(), command: s.string().optional() }) },
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const css = baseCss + \`
|
|
549
|
+
body{background:var(--flux-bg);color:var(--flux-fg)}
|
|
550
|
+
.app{display:grid;grid-template-columns:220px 1fr;min-height:100vh}
|
|
551
|
+
.side{border-right:1px solid var(--flux-border);padding:16px;background:var(--flux-surface)}
|
|
552
|
+
.side a{display:block;padding:7px 10px;border-radius:8px;color:inherit;text-decoration:none;margin:2px 0}
|
|
553
|
+
.side a[data-active]{background:var(--flux-accent);color:#fff}.side h1{font-size:16px;margin:0 0 14px}
|
|
554
|
+
.main{padding:24px;max-width:860px}.row{display:flex;gap:8px;margin:12px 0}
|
|
555
|
+
.btn{border:1px solid var(--flux-border);background:var(--flux-bg);color:inherit;border-radius:8px;padding:6px 12px;cursor:pointer}
|
|
556
|
+
.hint{color:var(--flux-muted);font-size:12px}\`;
|
|
557
|
+
|
|
558
|
+
function Dashboard() {
|
|
559
|
+
const tools = useExtensionItems<{ id: string; label: string; command?: string }>("dashboard", "toolbar");
|
|
560
|
+
const [out, setOut] = React.useState("");
|
|
561
|
+
const create = async (name: string) => {
|
|
562
|
+
try { setOut(JSON.stringify(await bridge.invoke("host.item.create", { name }))); }
|
|
563
|
+
catch (e) { setOut("blocked: " + (e as Error).message); }
|
|
564
|
+
};
|
|
565
|
+
return (
|
|
566
|
+
<div>
|
|
567
|
+
<h2>Dashboard</h2>
|
|
568
|
+
<PluginSlot name="dashboard.before" />
|
|
569
|
+
<p className="hint">These buttons are contributed by plugins through a host-declared extension point.</p>
|
|
570
|
+
<div className="row" data-testid="toolbar">
|
|
571
|
+
{tools.map((t) => <button key={t.id} className="btn" onClick={() => t.command && void framework.manager.commands.execute(t.command)}>{t.label}</button>)}
|
|
572
|
+
</div>
|
|
573
|
+
<div className="row">
|
|
574
|
+
<button className="btn" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>
|
|
575
|
+
<button className="btn" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>
|
|
576
|
+
</div>
|
|
577
|
+
<pre data-testid="hook-result" className="hint">{out}</pre>
|
|
578
|
+
<PluginSlot name="dashboard.after" />
|
|
579
|
+
</div>
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function Shell() {
|
|
584
|
+
useKeybindings();
|
|
585
|
+
const { items, run } = useMenu("sidebar");
|
|
586
|
+
return (
|
|
587
|
+
<div className="app">
|
|
588
|
+
<aside className="side">
|
|
589
|
+
<h1>${"${APP}"} <PluginSlot name="header.badges" /></h1>
|
|
590
|
+
<Link to="/">Dashboard</Link>
|
|
591
|
+
{items.map((i) => <a key={i.id} href={"#" + i.path} onClick={(e) => { e.preventDefault(); run(i); }}>{i.label}</a>)}
|
|
592
|
+
<Link to="/plugins">Plugins</Link>
|
|
593
|
+
<p className="hint">Ctrl/Cmd+K: commands</p>
|
|
594
|
+
</aside>
|
|
595
|
+
<main className="main"><Breadcrumbs /><PluginRoutes /></main>
|
|
596
|
+
<CommandPalette />
|
|
597
|
+
</div>
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
framework.manager.routes.register({ path: "/", component: Dashboard, meta: { title: "Dashboard" } }, "host");
|
|
602
|
+
framework.manager.routes.register({ path: "/plugins", component: PluginManagerUI, meta: { title: "Plugins", breadcrumb: "Plugins" } }, "host");
|
|
603
|
+
|
|
604
|
+
await framework.start();
|
|
605
|
+
(window as unknown as Record<string, unknown>)["__framework"] = framework;
|
|
606
|
+
createRoot(document.getElementById("root")!).render(
|
|
607
|
+
<PluginProvider framework={framework}><style>{css}</style><StyleHost /><Shell /></PluginProvider>,
|
|
608
|
+
);
|
|
609
|
+
`.replace("${APP}", app);
|
|
610
|
+
|
|
611
|
+
// src/cli/cli.ts
|
|
612
|
+
var HELP = `fluxplugin <command>
|
|
613
|
+
|
|
614
|
+
init [dir] create a new app (Electron + React) that supports plugins
|
|
615
|
+
dev [--web] run your app with plugin hot reload (Electron, or a browser with --web)
|
|
616
|
+
build bundle your app (in an app folder) or a plugin (in a plugin folder)
|
|
617
|
+
start [--web] run the built app
|
|
618
|
+
plugin new <name> [-t basic|dashboard|backend] scaffold a plugin (alias: create)
|
|
619
|
+
validate [dir] check a plugin manifest, entries and integrity
|
|
620
|
+
package [dir] [--out d] [--sign-key file] build + integrity (+ signature) + .tgz
|
|
621
|
+
install <dir|.tgz> [--dir plugins] uninstall <id> enable <id> disable <id> list
|
|
622
|
+
keygen [--out name] create an ed25519 signing key pair
|
|
623
|
+
tailwind [--dir plugins] [--out file] generate @source lines for Tailwind v4`;
|
|
624
|
+
var log = (...a) => console.log(...a);
|
|
625
|
+
async function readManifest(root) {
|
|
626
|
+
return JSON.parse(await fs4.readFile(path5.join(root, "plugin.json"), "utf8"));
|
|
627
|
+
}
|
|
628
|
+
async function run(argv) {
|
|
629
|
+
const { values: f, positionals: pos } = parseArgs({
|
|
630
|
+
args: argv,
|
|
631
|
+
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" } }
|
|
633
|
+
});
|
|
634
|
+
let [cmd, arg] = pos;
|
|
635
|
+
if (cmd === "plugin") {
|
|
636
|
+
const sub = pos[1];
|
|
637
|
+
if (sub !== "new") throw new Error("usage: fluxplugin plugin new <name>");
|
|
638
|
+
cmd = "create";
|
|
639
|
+
arg = pos[2];
|
|
640
|
+
}
|
|
641
|
+
if (!cmd || f.help) return log(HELP), cmd ? 0 : 1;
|
|
642
|
+
const root = path5.resolve(arg ?? ".");
|
|
643
|
+
try {
|
|
644
|
+
switch (cmd) {
|
|
645
|
+
case "init": {
|
|
646
|
+
const target = path5.resolve(arg ?? ".");
|
|
647
|
+
const entries = await fs4.readdir(target).catch(() => []);
|
|
648
|
+
if (entries.length) throw new Error(`${target} is not empty`);
|
|
649
|
+
const name = (f.name ?? path5.basename(target)).toLowerCase().replace(/[^a-z0-9-_.]+/g, "-");
|
|
650
|
+
const files = hostFiles({ name, fluxSpec: process.env["FLUXPLUGIN_SPEC"] ?? `^${await ownVersion()}` }, !f["no-sample"]);
|
|
651
|
+
for (const [rel2, content] of Object.entries(files)) {
|
|
652
|
+
await fs4.mkdir(path5.dirname(path5.join(target, rel2)), { recursive: true });
|
|
653
|
+
await fs4.writeFile(path5.join(target, rel2), content);
|
|
654
|
+
}
|
|
655
|
+
const rel = path5.relative(process.cwd(), target) || ".";
|
|
656
|
+
log(`\u2714 created ${name} in ${target}
|
|
657
|
+
|
|
658
|
+
Next steps:
|
|
659
|
+
${rel === "." ? "" : `cd ${rel}
|
|
660
|
+
`}npm install
|
|
661
|
+
npm run dev:web # browser mode (no Electron needed)
|
|
662
|
+
npm run dev # Electron
|
|
663
|
+
|
|
664
|
+
Add a plugin: npm run plugin:new my-plugin -- -t backend`);
|
|
665
|
+
return 0;
|
|
666
|
+
}
|
|
667
|
+
case "create": {
|
|
668
|
+
if (!arg) throw new Error("usage: fluxplugin plugin new <name> [-t basic|dashboard|backend]");
|
|
669
|
+
const tpl = f.template ?? "basic";
|
|
670
|
+
if (!(tpl in TEMPLATES)) throw new Error(`unknown template "${tpl}"`);
|
|
671
|
+
const base = await isHostProject(process.cwd()) && !path5.isAbsolute(arg) && !arg.includes("/") ? path5.join((await loadConfig({}, process.cwd())).pluginsDir, arg) : path5.resolve(arg);
|
|
672
|
+
if (await fs4.stat(base).catch(() => null)) throw new Error(`${base} already exists`);
|
|
673
|
+
const files = TEMPLATES[tpl](varsFromName(path5.basename(base)));
|
|
674
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
675
|
+
await fs4.mkdir(path5.dirname(path5.join(base, rel)), { recursive: true });
|
|
676
|
+
await fs4.writeFile(path5.join(base, rel), content);
|
|
677
|
+
}
|
|
678
|
+
log(`\u2714 created ${tpl} plugin in ${base}
|
|
679
|
+
next: fluxplugin build ${path5.relative(process.cwd(), base)}`);
|
|
680
|
+
return 0;
|
|
681
|
+
}
|
|
682
|
+
case "build": {
|
|
683
|
+
if (!arg && await isHostProject(process.cwd())) {
|
|
684
|
+
await buildHost(process.cwd());
|
|
685
|
+
return 0;
|
|
686
|
+
}
|
|
687
|
+
await buildPlugin(root);
|
|
688
|
+
return 0;
|
|
689
|
+
}
|
|
690
|
+
case "dev": {
|
|
691
|
+
const cwd = process.cwd();
|
|
692
|
+
if (arg || !await isHostProject(cwd)) {
|
|
693
|
+
await buildPlugin(root, true);
|
|
694
|
+
await new Promise(() => {
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
const dispose = await buildHost(cwd, { watch: true });
|
|
698
|
+
const { child, mode } = startApp(cwd, { web: f.web, dev: true, port: f.port ? Number(f.port) : void 0 });
|
|
699
|
+
log(`running in ${mode} mode \u2014 edit a plugin and it reloads; Ctrl+C to stop`);
|
|
700
|
+
const stop = () => {
|
|
701
|
+
child.kill();
|
|
702
|
+
void dispose().finally(() => process.exit(0));
|
|
703
|
+
};
|
|
704
|
+
process.on("SIGINT", stop);
|
|
705
|
+
process.on("SIGTERM", stop);
|
|
706
|
+
return await new Promise((r) => child.on("exit", (c) => r(c ?? 0)));
|
|
707
|
+
}
|
|
708
|
+
case "start": {
|
|
709
|
+
const cwd = process.cwd();
|
|
710
|
+
if (!await fs4.stat(path5.join(cwd, "dist/index.html")).catch(() => null)) throw new Error("nothing built yet: run `fluxplugin build` first");
|
|
711
|
+
const { child } = startApp(cwd, { web: f.web, port: f.port ? Number(f.port) : void 0 });
|
|
712
|
+
return await new Promise((r) => child.on("exit", (c) => r(c ?? 0)));
|
|
713
|
+
}
|
|
714
|
+
case "validate":
|
|
715
|
+
return validate(root);
|
|
716
|
+
case "package":
|
|
717
|
+
return pack(root, f.out, f["sign-key"]);
|
|
718
|
+
case "keygen": {
|
|
719
|
+
const k = generateSigningKeyPair();
|
|
720
|
+
const base = f.out ?? "flux-signing";
|
|
721
|
+
await fs4.writeFile(`${base}.private.key`, k.privateKey, { mode: 384 });
|
|
722
|
+
await fs4.writeFile(`${base}.public.key`, k.publicKey);
|
|
723
|
+
log(`wrote ${base}.private.key (keep secret) and ${base}.public.key (distribute to hosts as a trusted key)`);
|
|
724
|
+
return 0;
|
|
725
|
+
}
|
|
726
|
+
case "tailwind": {
|
|
727
|
+
const cfg = await loadConfig(f);
|
|
728
|
+
const names = (await fs4.readdir(cfg.pluginsDir).catch(() => [])).sort();
|
|
729
|
+
const lines = names.map((n) => `@source "${path5.join(cfg.pluginsDir, n, "src").replace(/\\/g, "/")}";`);
|
|
730
|
+
const css = `/* generated by fluxplugin tailwind */
|
|
731
|
+
${lines.join("\n")}
|
|
732
|
+
`;
|
|
733
|
+
if (f.out) await fs4.writeFile(f.out, css);
|
|
734
|
+
else log(css);
|
|
735
|
+
return 0;
|
|
736
|
+
}
|
|
737
|
+
case "install":
|
|
738
|
+
case "uninstall":
|
|
739
|
+
case "enable":
|
|
740
|
+
case "disable":
|
|
741
|
+
case "list":
|
|
742
|
+
return manage(cmd, arg, f);
|
|
743
|
+
default:
|
|
744
|
+
log(HELP);
|
|
745
|
+
return 1;
|
|
746
|
+
}
|
|
747
|
+
} catch (e) {
|
|
748
|
+
console.error(`error: ${e.message}`);
|
|
749
|
+
return 1;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
async function validate(root) {
|
|
753
|
+
const raw = await readManifest(root);
|
|
754
|
+
const v = validateManifest(raw);
|
|
755
|
+
let bad = !v.ok;
|
|
756
|
+
v.issues.forEach((i) => log(`\u2716 ${i.path || "<root>"}: ${i.message}`));
|
|
757
|
+
v.warnings.forEach((w) => log(`! ${w}`));
|
|
758
|
+
if (v.manifest) {
|
|
759
|
+
for (const rel of [v.manifest.main, v.manifest.renderer, ...v.manifest.styles ?? []].filter(Boolean))
|
|
760
|
+
if (!await fs4.stat(path5.join(root, rel)).catch(() => null)) log(`\u2716 missing file ${rel} (run build)`), bad = true;
|
|
761
|
+
try {
|
|
762
|
+
await verifyPlugin(root, raw, v.manifest);
|
|
763
|
+
} catch (e) {
|
|
764
|
+
log(`\u2716 ${e.message}`);
|
|
765
|
+
bad = true;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
log(bad ? "invalid" : `\u2714 ${v.manifest?.id}@${v.manifest?.version} is valid`);
|
|
769
|
+
return bad ? 1 : 0;
|
|
770
|
+
}
|
|
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
|
+
async function manage(cmd, arg, f) {
|
|
792
|
+
const cfg = await loadConfig(f);
|
|
793
|
+
const state = new JsonStateFile(path5.join(cfg.dataDir, "plugins-state.json"));
|
|
794
|
+
const inst = new PluginInstaller(cfg.pluginsDir, path5.join(cfg.dataDir, "backups"));
|
|
795
|
+
if (cmd === "list") {
|
|
796
|
+
const { disabled } = await state.readAll();
|
|
797
|
+
for (const m of await inst.installed()) log(`${disabled.includes(m.id) ? "\u25CB" : "\u25CF"} ${m.id}@${m.version} ${m.name}`);
|
|
798
|
+
return 0;
|
|
799
|
+
}
|
|
800
|
+
if (!arg) throw new Error(`usage: ${cmd} <${cmd === "install" ? "dir|.tgz" : "id"}>`);
|
|
801
|
+
if (cmd === "install") {
|
|
802
|
+
const tmp = path5.join(cfg.dataDir, ".install", String(Date.now()));
|
|
803
|
+
if (arg.endsWith(".tgz")) await extractTarGz(await fs4.readFile(arg), tmp);
|
|
804
|
+
else await copyDir(path5.resolve(arg), tmp);
|
|
805
|
+
const v = validateManifest(await readManifest(tmp));
|
|
806
|
+
if (!v.manifest) throw new Error("invalid plugin: " + v.issues.map((i) => i.message).join("; "));
|
|
807
|
+
const reg = new LocalRegistry(path5.dirname(tmp), "cli");
|
|
808
|
+
inst.addRegistry(reg);
|
|
809
|
+
const [entry] = (await reg.search(v.manifest.id)).filter((e) => e.id === v.manifest.id);
|
|
810
|
+
const r = await inst.install({ ...entry, location: tmp });
|
|
811
|
+
await fs4.rm(tmp, { recursive: true, force: true });
|
|
812
|
+
log(`installed ${r.id}@${r.version}${r.previousVersion ? ` (was ${r.previousVersion}; rollback available)` : ""}`);
|
|
813
|
+
} else if (cmd === "uninstall") {
|
|
814
|
+
await inst.uninstall(arg);
|
|
815
|
+
log(`uninstalled ${arg}`);
|
|
816
|
+
} else {
|
|
817
|
+
await state.update((s) => ({ disabled: cmd === "disable" ? [.../* @__PURE__ */ new Set([...s.disabled, arg])] : s.disabled.filter((x) => x !== arg) }));
|
|
818
|
+
log(`${cmd}d ${arg}`);
|
|
819
|
+
}
|
|
820
|
+
return 0;
|
|
821
|
+
}
|
|
822
|
+
async function ownVersion() {
|
|
823
|
+
for (const rel of ["../package.json", "../../package.json"]) {
|
|
824
|
+
try {
|
|
825
|
+
const j = JSON.parse(await fs4.readFile(new URL(rel, import.meta.url), "utf8"));
|
|
826
|
+
if (j.name === "fluxplugin" && j.version) return j.version;
|
|
827
|
+
} catch {
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return "1.0.0";
|
|
831
|
+
}
|
|
832
|
+
export {
|
|
833
|
+
SHARED_MODULES,
|
|
834
|
+
TEMPLATES,
|
|
835
|
+
buildHost,
|
|
836
|
+
buildPlugin,
|
|
837
|
+
hostFiles,
|
|
838
|
+
isHostProject,
|
|
839
|
+
run,
|
|
840
|
+
sharedModulesPlugin,
|
|
841
|
+
startApp,
|
|
842
|
+
varsFromName
|
|
843
|
+
};
|