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/cli.js
CHANGED
|
@@ -1,20 +1,77 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __esm = (fn, res, err) => function __init() {
|
|
6
|
+
if (err) throw err[0];
|
|
7
|
+
try {
|
|
8
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
9
|
+
} catch (e) {
|
|
10
|
+
throw err = [e], e;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
var __export = (target, all) => {
|
|
14
|
+
for (var name in all)
|
|
15
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// src/cli/tailwind.ts
|
|
19
|
+
var tailwind_exports = {};
|
|
20
|
+
__export(tailwind_exports, {
|
|
21
|
+
compileTailwind: () => compileTailwind,
|
|
22
|
+
resolveTailwindCli: () => resolveTailwindCli
|
|
23
|
+
});
|
|
24
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
25
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import { createRequire } from "node:module";
|
|
27
|
+
import path7 from "node:path";
|
|
28
|
+
function resolveTailwindCli(startDirs) {
|
|
29
|
+
for (const dir of startDirs) {
|
|
30
|
+
try {
|
|
31
|
+
const pj = createRequire(path7.join(dir, "package.json")).resolve("@tailwindcss/cli/package.json");
|
|
32
|
+
const pkg2 = JSON.parse(readFileSync(pj, "utf8"));
|
|
33
|
+
const rel = typeof pkg2.bin === "string" ? pkg2.bin : Object.values(pkg2.bin ?? {})[0];
|
|
34
|
+
if (rel) return path7.join(path7.dirname(pj), rel);
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return void 0;
|
|
39
|
+
}
|
|
40
|
+
function compileTailwind(opts, log2 = console.log) {
|
|
41
|
+
const cli = resolveTailwindCli([opts.cwd, ...opts.searchFrom ?? [], process.cwd()]);
|
|
42
|
+
if (!cli || !existsSync(cli)) throw new Error('Tailwind is not installed: run "npm install -D tailwindcss @tailwindcss/cli"');
|
|
43
|
+
const args = [cli, "-i", opts.input, "-o", opts.output];
|
|
44
|
+
if (!opts.watch) {
|
|
45
|
+
const r = spawnSync(process.execPath, args, { cwd: opts.cwd, encoding: "utf8" });
|
|
46
|
+
if (r.status !== 0) throw new Error(`tailwind failed: ${(r.stderr || r.stdout || "").trim().split("\n").slice(-3).join(" ")}`);
|
|
47
|
+
log2(`tailwind \u2192 ${path7.relative(opts.cwd, opts.output)}`);
|
|
48
|
+
return () => {
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const child = spawn(process.execPath, [...args, "--watch=always"], { cwd: opts.cwd, stdio: ["ignore", "ignore", "pipe"] });
|
|
52
|
+
child.stderr?.on("data", (d) => /error/i.test(String(d)) && log2("tailwind: " + String(d).trim()));
|
|
53
|
+
log2(`tailwind watching ${path7.relative(opts.cwd, opts.input)}`);
|
|
54
|
+
return () => void child.kill();
|
|
55
|
+
}
|
|
56
|
+
var init_tailwind = __esm({
|
|
57
|
+
"src/cli/tailwind.ts"() {
|
|
58
|
+
"use strict";
|
|
59
|
+
}
|
|
60
|
+
});
|
|
3
61
|
|
|
4
62
|
// src/cli/cli.ts
|
|
5
63
|
import { parseArgs } from "node:util";
|
|
6
|
-
import { promises as
|
|
7
|
-
import
|
|
8
|
-
import crypto2 from "node:crypto";
|
|
64
|
+
import { promises as fs11 } from "node:fs";
|
|
65
|
+
import path14 from "node:path";
|
|
9
66
|
|
|
10
67
|
// src/core/schema.ts
|
|
11
68
|
var ok = (value) => ({ ok: true, value });
|
|
12
|
-
var fail = (
|
|
69
|
+
var fail = (path15, message) => ({ ok: false, issues: [{ path: path15, message }] });
|
|
13
70
|
var join = (p, k) => p ? typeof k === "number" ? `${p}[${k}]` : `${p}.${k}` : String(k);
|
|
14
71
|
function make(kind, parse, describe) {
|
|
15
72
|
const self = {
|
|
16
73
|
kind,
|
|
17
|
-
parse: (input,
|
|
74
|
+
parse: (input, path15 = "") => parse(input, path15),
|
|
18
75
|
describe,
|
|
19
76
|
optional() {
|
|
20
77
|
return make(
|
|
@@ -390,6 +447,18 @@ var manifestSchema = s.object({
|
|
|
390
447
|
repository: s.union(s.string(), s.object({ type: s.string().optional(), url: s.string() })).optional(),
|
|
391
448
|
license: s.string().optional(),
|
|
392
449
|
icon: s.string().optional(),
|
|
450
|
+
/** Wide image shown at the top of the plugin page (png/jpg/webp/svg inside the plugin). */
|
|
451
|
+
banner: s.string().optional(),
|
|
452
|
+
/** Screenshots shown in a gallery (paths inside the plugin). */
|
|
453
|
+
screenshots: s.array(s.string()).default([]),
|
|
454
|
+
/** Detailed description as Markdown (default: ./README.md). Rendered safely: no raw HTML. */
|
|
455
|
+
readme: s.string().optional(),
|
|
456
|
+
/** Explanation video (https URL; YouTube and Vimeo are embedded, other hosts become a link). */
|
|
457
|
+
video: s.string({ pattern: /^https:\/\/\S+$/ }).optional(),
|
|
458
|
+
categories: s.array(s.string()).default([]),
|
|
459
|
+
keywords: s.array(s.string()).default([]),
|
|
460
|
+
/** Extra links: docs, support, changelog, funding... */
|
|
461
|
+
links: s.record(s.string({ pattern: /^https:\/\/\S+$/ })).default({}),
|
|
393
462
|
/** Entry executed in the Electron main process. */
|
|
394
463
|
main: s.string().optional(),
|
|
395
464
|
/** Entry executed in the preload context (sandbox-compatible, no Node). */
|
|
@@ -580,6 +649,7 @@ var JsonStateFile = class {
|
|
|
580
649
|
// src/main/registry.ts
|
|
581
650
|
import { promises as fs4 } from "node:fs";
|
|
582
651
|
import path4 from "node:path";
|
|
652
|
+
import crypto2 from "node:crypto";
|
|
583
653
|
|
|
584
654
|
// src/main/tar.ts
|
|
585
655
|
import { promises as fs3 } from "node:fs";
|
|
@@ -669,7 +739,7 @@ async function extractTarGz(buf, dest) {
|
|
|
669
739
|
}
|
|
670
740
|
|
|
671
741
|
// src/main/registry.ts
|
|
672
|
-
async function
|
|
742
|
+
async function readPluginManifest(dir) {
|
|
673
743
|
const raw = JSON.parse(await fs4.readFile(path4.join(dir, MANIFEST_FILE), "utf8"));
|
|
674
744
|
const v = validateManifest(raw);
|
|
675
745
|
if (!v.manifest) throw new Error(`invalid manifest: ${v.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
|
|
@@ -686,6 +756,34 @@ var toEntry = (m, location, source, sha256) => ({
|
|
|
686
756
|
source,
|
|
687
757
|
sha256
|
|
688
758
|
});
|
|
759
|
+
var RUNTIME_DIRS = /* @__PURE__ */ new Set(["dist", "assets", "images", "docs"]);
|
|
760
|
+
var RUNTIME_FILES = /^(plugin\.json|readme(\.[a-z]+)?|license(\.[a-z]+)?|changelog(\.[a-z]+)?|icon\.(png|svg|jpg|webp))$/i;
|
|
761
|
+
var SOURCE_FILE = /\.(tsx?|jsx|map|scss|sass|less)$/i;
|
|
762
|
+
async function copyPluginRuntime(src, dest) {
|
|
763
|
+
const copied = [];
|
|
764
|
+
const manifest = JSON.parse(await fs4.readFile(path4.join(src, MANIFEST_FILE), "utf8"));
|
|
765
|
+
const referenced = new Set(
|
|
766
|
+
[manifest["main"], manifest["preload"], manifest["renderer"], manifest["icon"], manifest["banner"], manifest["readme"], ...manifest["styles"] ?? [], ...manifest["screenshots"] ?? []].filter((x) => typeof x === "string").map((x) => path4.normalize(x).replace(/^(\.\/|\.\\)/, ""))
|
|
767
|
+
);
|
|
768
|
+
const walk2 = async (rel) => {
|
|
769
|
+
for (const e of await fs4.readdir(path4.join(src, rel), { withFileTypes: true })) {
|
|
770
|
+
const r = rel ? path4.join(rel, e.name) : e.name;
|
|
771
|
+
if (e.isDirectory()) {
|
|
772
|
+
if (rel === "" ? RUNTIME_DIRS.has(e.name) : true) await walk2(r);
|
|
773
|
+
} else if (e.isFile()) {
|
|
774
|
+
const top = r.split(path4.sep)[0];
|
|
775
|
+
const ok2 = referenced.has(r) || (rel === "" ? RUNTIME_FILES.test(e.name) : RUNTIME_DIRS.has(top) && !SOURCE_FILE.test(e.name));
|
|
776
|
+
if (!ok2 || SOURCE_FILE.test(e.name) && !referenced.has(r)) continue;
|
|
777
|
+
await fs4.mkdir(path4.dirname(path4.join(dest, r)), { recursive: true });
|
|
778
|
+
await fs4.copyFile(path4.join(src, r), path4.join(dest, r));
|
|
779
|
+
copied.push(r.split(path4.sep).join("/"));
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
await fs4.mkdir(dest, { recursive: true });
|
|
784
|
+
await walk2("");
|
|
785
|
+
return copied;
|
|
786
|
+
}
|
|
689
787
|
async function copyDir(src, dest) {
|
|
690
788
|
await fs4.mkdir(dest, { recursive: true });
|
|
691
789
|
for (const e of await fs4.readdir(src, { withFileTypes: true })) {
|
|
@@ -696,6 +794,37 @@ async function copyDir(src, dest) {
|
|
|
696
794
|
else if (e.isFile()) await fs4.copyFile(s2, d);
|
|
697
795
|
}
|
|
698
796
|
}
|
|
797
|
+
function entriesFromIndex(index, baseUrl, source, query = "") {
|
|
798
|
+
const q = query.toLowerCase();
|
|
799
|
+
return (index.plugins ?? []).filter((p) => p && typeof p.id === "string" && typeof p.version === "string" && typeof p.url === "string").filter((p) => !q || `${p.id} ${p.name ?? ""} ${p.description ?? ""}`.toLowerCase().includes(q)).map((p) => ({
|
|
800
|
+
id: p.id,
|
|
801
|
+
name: p.name ?? p.id,
|
|
802
|
+
version: p.version,
|
|
803
|
+
description: p.description,
|
|
804
|
+
author: p.author,
|
|
805
|
+
permissions: p.permissions ?? [],
|
|
806
|
+
location: new URL(p.url, baseUrl).href,
|
|
807
|
+
source,
|
|
808
|
+
sha256: p.sha256,
|
|
809
|
+
homepage: p.homepage,
|
|
810
|
+
icon: p.icon
|
|
811
|
+
}));
|
|
812
|
+
}
|
|
813
|
+
async function pruneNonRuntime(dir) {
|
|
814
|
+
const removed = [];
|
|
815
|
+
const walk2 = async (rel) => {
|
|
816
|
+
for (const e of await fs4.readdir(path4.join(dir, rel), { withFileTypes: true })) {
|
|
817
|
+
const r = rel ? path4.join(rel, e.name) : e.name;
|
|
818
|
+
const abs = path4.join(dir, r);
|
|
819
|
+
if (e.isDirectory()) {
|
|
820
|
+
if (e.name === "node_modules" || rel === "" && (e.name === "src" || e.name === ".git")) await fs4.rm(abs, { recursive: true, force: true }), removed.push(r + "/");
|
|
821
|
+
else await walk2(r);
|
|
822
|
+
} else if (/\.(map|tsx?|scss|sass|less)$/i.test(e.name) && !/\.d\.ts$/i.test(e.name)) await fs4.rm(abs, { force: true }), removed.push(r);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
await walk2("");
|
|
826
|
+
return removed;
|
|
827
|
+
}
|
|
699
828
|
var LocalRegistry = class {
|
|
700
829
|
constructor(dir, name = `local:${dir}`) {
|
|
701
830
|
this.dir = dir;
|
|
@@ -715,7 +844,7 @@ var LocalRegistry = class {
|
|
|
715
844
|
for (const n of names.sort()) {
|
|
716
845
|
const root = path4.join(this.dir, n);
|
|
717
846
|
try {
|
|
718
|
-
const { manifest } = await
|
|
847
|
+
const { manifest } = await readPluginManifest(root);
|
|
719
848
|
if (!q || `${manifest.id} ${manifest.name} ${manifest.description ?? ""}`.toLowerCase().includes(q)) out.push(toEntry(manifest, root, this.name));
|
|
720
849
|
} catch {
|
|
721
850
|
}
|
|
@@ -723,8 +852,34 @@ var LocalRegistry = class {
|
|
|
723
852
|
return out;
|
|
724
853
|
}
|
|
725
854
|
async fetch(entry, dest) {
|
|
726
|
-
await
|
|
727
|
-
return (await
|
|
855
|
+
await copyPluginRuntime(entry.location, dest);
|
|
856
|
+
return (await readPluginManifest(dest)).manifest;
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
var HttpRegistry = class {
|
|
860
|
+
constructor(baseUrl, fetchImpl = fetch) {
|
|
861
|
+
this.baseUrl = baseUrl;
|
|
862
|
+
this.fetchImpl = fetchImpl;
|
|
863
|
+
this.name = `http:${baseUrl}`;
|
|
864
|
+
}
|
|
865
|
+
baseUrl;
|
|
866
|
+
fetchImpl;
|
|
867
|
+
name;
|
|
868
|
+
async search(query = "") {
|
|
869
|
+
const base = this.baseUrl.endsWith("/") ? this.baseUrl : this.baseUrl + "/";
|
|
870
|
+
const res = await this.fetchImpl(new URL("index.json", base));
|
|
871
|
+
if (!res.ok) throw new Error(`store responded ${res.status}`);
|
|
872
|
+
return entriesFromIndex(await res.json(), base, this.name, query);
|
|
873
|
+
}
|
|
874
|
+
async fetch(entry, dest) {
|
|
875
|
+
const res = await this.fetchImpl(entry.location);
|
|
876
|
+
if (!res.ok) throw new Error(`download failed: ${res.status}`);
|
|
877
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
878
|
+
if (entry.sha256 && crypto2.createHash("sha256").update(buf).digest("hex") !== entry.sha256.replace(/^sha256-/, "")) {
|
|
879
|
+
throw new Error("downloaded archive does not match the registry checksum");
|
|
880
|
+
}
|
|
881
|
+
await extractTarGz(buf, dest);
|
|
882
|
+
return (await readPluginManifest(dest)).manifest;
|
|
728
883
|
}
|
|
729
884
|
};
|
|
730
885
|
var PluginInstaller = class {
|
|
@@ -747,7 +902,7 @@ var PluginInstaller = class {
|
|
|
747
902
|
const out = [];
|
|
748
903
|
for (const n of await fs4.readdir(this.pluginsDir).catch(() => [])) {
|
|
749
904
|
try {
|
|
750
|
-
out.push((await
|
|
905
|
+
out.push((await readPluginManifest(path4.join(this.pluginsDir, n))).manifest);
|
|
751
906
|
} catch {
|
|
752
907
|
}
|
|
753
908
|
}
|
|
@@ -764,11 +919,12 @@ var PluginInstaller = class {
|
|
|
764
919
|
await fs4.mkdir(path4.dirname(stage), { recursive: true });
|
|
765
920
|
try {
|
|
766
921
|
const manifest = await registry.fetch(entry, stage);
|
|
922
|
+
await pruneNonRuntime(stage);
|
|
767
923
|
if (manifest.id !== entry.id) throw new Error(`registry entry "${entry.id}" contains plugin "${manifest.id}"`);
|
|
768
924
|
const target = this.dirOf(entry.id);
|
|
769
925
|
let previousVersion;
|
|
770
926
|
try {
|
|
771
|
-
previousVersion = (await
|
|
927
|
+
previousVersion = (await readPluginManifest(target)).manifest.version;
|
|
772
928
|
const bk = path4.join(this.backupDir, entry.id, previousVersion);
|
|
773
929
|
await fs4.rm(bk, { recursive: true, force: true });
|
|
774
930
|
await fs4.mkdir(path4.dirname(bk), { recursive: true });
|
|
@@ -786,7 +942,7 @@ var PluginInstaller = class {
|
|
|
786
942
|
/** Removes the plugin directory (backups are kept so the user can still roll back/reinstall). */
|
|
787
943
|
async uninstall(id) {
|
|
788
944
|
const dir = this.dirOf(id);
|
|
789
|
-
const { manifest } = await
|
|
945
|
+
const { manifest } = await readPluginManifest(dir);
|
|
790
946
|
const bk = path4.join(this.backupDir, id, manifest.version);
|
|
791
947
|
await fs4.rm(bk, { recursive: true, force: true });
|
|
792
948
|
await fs4.mkdir(path4.dirname(bk), { recursive: true });
|
|
@@ -804,7 +960,7 @@ var PluginInstaller = class {
|
|
|
804
960
|
const dir = this.dirOf(id);
|
|
805
961
|
let current;
|
|
806
962
|
try {
|
|
807
|
-
current = (await
|
|
963
|
+
current = (await readPluginManifest(dir)).manifest.version;
|
|
808
964
|
const bk = path4.join(this.backupDir, id, current);
|
|
809
965
|
await fs4.rm(bk, { recursive: true, force: true });
|
|
810
966
|
await fs4.rename(dir, bk);
|
|
@@ -821,13 +977,126 @@ var PluginInstaller = class {
|
|
|
821
977
|
}
|
|
822
978
|
};
|
|
823
979
|
|
|
980
|
+
// src/main/stores.ts
|
|
981
|
+
import path5 from "node:path";
|
|
982
|
+
|
|
983
|
+
// src/main/github-registry.ts
|
|
984
|
+
import crypto3 from "node:crypto";
|
|
985
|
+
var DEFAULT_TOPIC = "fluxplugin-plugin";
|
|
986
|
+
var MAX_PACKAGE_BYTES = 100 * 1024 * 1024;
|
|
987
|
+
var isLoopback = (h) => h === "127.0.0.1" || h === "localhost" || h === "[::1]";
|
|
988
|
+
function assertSafeUrl(u) {
|
|
989
|
+
const url = new URL(u);
|
|
990
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) throw new Error(`refusing non-https URL: ${u}`);
|
|
991
|
+
return url;
|
|
992
|
+
}
|
|
993
|
+
var GitHubRegistry = class {
|
|
994
|
+
constructor(o = {}) {
|
|
995
|
+
this.o = o;
|
|
996
|
+
this.name = o.name ?? (o.repo ? `github:${o.repo}` : `github:topic/${o.topic ?? DEFAULT_TOPIC}`);
|
|
997
|
+
}
|
|
998
|
+
o;
|
|
999
|
+
name;
|
|
1000
|
+
cache = /* @__PURE__ */ new Map();
|
|
1001
|
+
get api() {
|
|
1002
|
+
return (this.o.apiBase ?? "https://api.github.com").replace(/\/$/, "");
|
|
1003
|
+
}
|
|
1004
|
+
get raw() {
|
|
1005
|
+
return (this.o.rawBase ?? "https://raw.githubusercontent.com").replace(/\/$/, "");
|
|
1006
|
+
}
|
|
1007
|
+
trusted(url) {
|
|
1008
|
+
const hosts = /* @__PURE__ */ new Set(["api.github.com", "github.com", "raw.githubusercontent.com", new URL(this.api).hostname, new URL(this.raw).hostname]);
|
|
1009
|
+
return hosts.has(url.hostname);
|
|
1010
|
+
}
|
|
1011
|
+
async get(u, accept = "application/vnd.github+json") {
|
|
1012
|
+
const url = assertSafeUrl(u);
|
|
1013
|
+
const headers = { accept, "user-agent": "fluxplugin" };
|
|
1014
|
+
if (this.o.token && this.trusted(url)) headers["authorization"] = `Bearer ${this.o.token}`;
|
|
1015
|
+
const res = await (this.o.fetchImpl ?? fetch)(url, { headers, redirect: "follow", signal: AbortSignal.timeout(2e4) });
|
|
1016
|
+
if (res.status === 403 || res.status === 429) {
|
|
1017
|
+
if (res.headers.get("x-ratelimit-remaining") === "0" || res.status === 429) throw new Error("GitHub rate limit reached. Set GITHUB_TOKEN (any token, no scopes needed) to raise it, or retry later.");
|
|
1018
|
+
}
|
|
1019
|
+
if (!res.ok) throw new Error(`GitHub responded ${res.status} for ${url.pathname}`);
|
|
1020
|
+
return res;
|
|
1021
|
+
}
|
|
1022
|
+
async json(u) {
|
|
1023
|
+
return await (await this.get(u)).json();
|
|
1024
|
+
}
|
|
1025
|
+
async search(query = "") {
|
|
1026
|
+
const hit = this.cache.get(query);
|
|
1027
|
+
if (hit && Date.now() - hit.at < 6e4) return hit.entries;
|
|
1028
|
+
const entries = this.o.repo ? await this.catalog(query) : await this.discover(query);
|
|
1029
|
+
this.cache.set(query, { at: Date.now(), entries });
|
|
1030
|
+
return entries;
|
|
1031
|
+
}
|
|
1032
|
+
async catalog(query) {
|
|
1033
|
+
const ref = this.o.ref ?? "HEAD";
|
|
1034
|
+
const base = `${this.raw}/${this.o.repo}/${ref}/`;
|
|
1035
|
+
const index = await this.json(new URL(this.o.indexPath ?? "index.json", base).href);
|
|
1036
|
+
return entriesFromIndex(index, base, this.name, query);
|
|
1037
|
+
}
|
|
1038
|
+
async discover(query) {
|
|
1039
|
+
const q = `topic:${this.o.topic ?? DEFAULT_TOPIC}${query ? ` ${query} in:name,description,readme` : ""}`;
|
|
1040
|
+
const found = await this.json(`${this.api}/search/repositories?q=${encodeURIComponent(q)}&per_page=30&sort=updated`);
|
|
1041
|
+
const out = [];
|
|
1042
|
+
const queue = [...found.items ?? []];
|
|
1043
|
+
const worker = async () => {
|
|
1044
|
+
for (let repo = queue.shift(); repo; repo = queue.shift()) {
|
|
1045
|
+
try {
|
|
1046
|
+
const rel = await this.json(`${this.api}/repos/${repo.full_name}/releases/latest`);
|
|
1047
|
+
const descAsset = rel.assets?.find((a) => a.name.endsWith(".fluxplugin.json"));
|
|
1048
|
+
if (!descAsset) continue;
|
|
1049
|
+
const d = await (await this.get(descAsset.browser_download_url, "application/json")).json();
|
|
1050
|
+
const pkg2 = rel.assets?.find((a) => a.name === d.file);
|
|
1051
|
+
if (!d || typeof d.id !== "string" || typeof d.version !== "string" || !pkg2 || !/^[0-9a-f]{64}$/.test(d.sha256 ?? "")) continue;
|
|
1052
|
+
out.push({ id: d.id, name: d.name ?? d.id, version: d.version, description: d.description, author: d.author, permissions: d.permissions ?? [], location: pkg2.browser_download_url, source: this.name, sha256: d.sha256, homepage: repo.html_url, stars: repo.stargazers_count });
|
|
1053
|
+
} catch (e) {
|
|
1054
|
+
if (/rate limit/i.test(e.message)) throw e;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
await Promise.all(Array.from({ length: 5 }, worker));
|
|
1059
|
+
return out.sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0) || a.id.localeCompare(b.id));
|
|
1060
|
+
}
|
|
1061
|
+
async fetch(entry, dest) {
|
|
1062
|
+
const res = await this.get(entry.location, "application/octet-stream");
|
|
1063
|
+
const len = Number(res.headers.get("content-length") ?? 0);
|
|
1064
|
+
if (len > MAX_PACKAGE_BYTES) throw new Error("package is too large");
|
|
1065
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1066
|
+
if (buf.length > MAX_PACKAGE_BYTES) throw new Error("package is too large");
|
|
1067
|
+
if (!entry.sha256) throw new Error("this store entry has no checksum; refusing to install an unverifiable package");
|
|
1068
|
+
if (crypto3.createHash("sha256").update(buf).digest("hex") !== entry.sha256.replace(/^sha256-/, "")) throw new Error("downloaded package does not match the published checksum");
|
|
1069
|
+
await extractTarGz(buf, dest);
|
|
1070
|
+
return (await readPluginManifest(dest)).manifest;
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
// src/main/stores.ts
|
|
1075
|
+
var DEFAULT_STORE = { type: "github", topic: DEFAULT_TOPIC };
|
|
1076
|
+
function createRegistries(specs = [DEFAULT_STORE], opts = {}) {
|
|
1077
|
+
const env = opts.env ?? process.env;
|
|
1078
|
+
const token = env["GITHUB_TOKEN"] ?? env["GH_TOKEN"];
|
|
1079
|
+
return specs.map((s2) => {
|
|
1080
|
+
switch (s2.type) {
|
|
1081
|
+
case "github":
|
|
1082
|
+
return new GitHubRegistry({ ...s2, token, fetchImpl: opts.fetchImpl });
|
|
1083
|
+
case "http":
|
|
1084
|
+
return new HttpRegistry(s2.url, opts.fetchImpl);
|
|
1085
|
+
case "local":
|
|
1086
|
+
return new LocalRegistry(path5.resolve(opts.baseDir ?? process.cwd(), s2.path));
|
|
1087
|
+
default:
|
|
1088
|
+
throw new Error(`unknown store type: ${JSON.stringify(s2)}`);
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
824
1093
|
// src/cli/build.ts
|
|
825
1094
|
import { promises as fs5 } from "node:fs";
|
|
826
|
-
import
|
|
1095
|
+
import path8 from "node:path";
|
|
827
1096
|
import { build, context } from "esbuild";
|
|
828
1097
|
|
|
829
1098
|
// src/cli/shared-modules.ts
|
|
830
|
-
import
|
|
1099
|
+
import path6 from "node:path";
|
|
831
1100
|
var SHARED_MODULES = ["react", "react/jsx-runtime", "react/jsx-dev-runtime", "react-dom", "react-dom/client", "fluxplugin", "fluxplugin/react", "fluxplugin/manager-ui"];
|
|
832
1101
|
function sharedModulesPlugin(shared = SHARED_MODULES) {
|
|
833
1102
|
const set = new Set(shared);
|
|
@@ -843,109 +1112,137 @@ function sharedModulesPlugin(shared = SHARED_MODULES) {
|
|
|
843
1112
|
if (!m) throw new Error("Shared module ${args.path} is not provided by the host application (see installSharedModules).");
|
|
844
1113
|
module.exports = m;`,
|
|
845
1114
|
loader: "js",
|
|
846
|
-
resolveDir:
|
|
1115
|
+
resolveDir: path6.resolve(".")
|
|
847
1116
|
}));
|
|
848
1117
|
}
|
|
849
1118
|
};
|
|
850
1119
|
}
|
|
851
1120
|
|
|
852
1121
|
// src/cli/build.ts
|
|
1122
|
+
init_tailwind();
|
|
853
1123
|
var exists = (f) => fs5.access(f).then(() => true, () => false);
|
|
854
1124
|
async function firstExisting(root, names) {
|
|
855
|
-
for (const n of names) if (await exists(
|
|
1125
|
+
for (const n of names) if (await exists(path8.join(root, n))) return path8.join(root, n);
|
|
856
1126
|
}
|
|
857
|
-
async function buildOptions(root) {
|
|
858
|
-
const raw = JSON.parse(await fs5.readFile(
|
|
1127
|
+
async function buildOptions(root, dev = false) {
|
|
1128
|
+
const raw = JSON.parse(await fs5.readFile(path8.join(root, "plugin.json"), "utf8"));
|
|
859
1129
|
const m = validateManifest(raw).manifest;
|
|
860
1130
|
if (!m) throw new Error("invalid plugin.json (run `fluxplugin validate`)");
|
|
861
1131
|
const out = [];
|
|
862
|
-
const common = {
|
|
1132
|
+
const common = {
|
|
1133
|
+
bundle: true,
|
|
1134
|
+
sourcemap: dev,
|
|
1135
|
+
// sourcemaps only while developing; a built/published plugin never has them
|
|
1136
|
+
logLevel: "warning",
|
|
1137
|
+
plugins: [sharedModulesPlugin()],
|
|
1138
|
+
jsx: "automatic",
|
|
1139
|
+
loader: { ".module.css": "local-css" }
|
|
1140
|
+
};
|
|
863
1141
|
if (m.main) {
|
|
864
1142
|
const entry = await firstExisting(root, ["src/main.ts", "src/main.js"]);
|
|
865
1143
|
if (!entry) throw new Error("manifest declares `main` but src/main.ts was not found");
|
|
866
|
-
out.push({ ...common, entryPoints: [entry], outfile:
|
|
1144
|
+
out.push({ ...common, entryPoints: [entry], outfile: path8.join(root, m.main), platform: "node", format: "esm", target: "node20", external: ["node:*", "electron"] });
|
|
867
1145
|
}
|
|
868
1146
|
if (m.renderer) {
|
|
869
1147
|
const entry = await firstExisting(root, ["src/renderer.tsx", "src/renderer.ts", "src/renderer.jsx", "src/renderer.js"]);
|
|
870
1148
|
if (!entry) throw new Error("manifest declares `renderer` but src/renderer.tsx was not found");
|
|
871
|
-
out.push({ ...common, entryPoints: [entry], outfile:
|
|
1149
|
+
out.push({ ...common, entryPoints: [entry], outfile: path8.join(root, m.renderer), platform: "browser", format: "esm", target: "chrome120" });
|
|
872
1150
|
}
|
|
873
1151
|
for (const css of m.styles ?? []) {
|
|
874
|
-
|
|
875
|
-
|
|
1152
|
+
if (path8.basename(css) === "tailwind.css") continue;
|
|
1153
|
+
const entry = path8.join(root, "src/styles", path8.basename(css));
|
|
1154
|
+
if (await exists(entry)) out.push({ ...common, plugins: [], entryPoints: [entry], outfile: path8.join(root, css) });
|
|
876
1155
|
}
|
|
877
1156
|
return out;
|
|
878
1157
|
}
|
|
879
1158
|
async function buildPlugin(root, watch = false, log2 = console.log) {
|
|
880
|
-
const opts = await buildOptions(root);
|
|
1159
|
+
const opts = await buildOptions(root, watch);
|
|
1160
|
+
const twIn = path8.join(root, "src/styles/tailwind.css");
|
|
1161
|
+
const stopTw = await exists(twIn) ? compileTailwind({ cwd: root, input: twIn, output: path8.join(root, "dist/tailwind.css"), watch, searchFrom: [process.cwd(), path8.dirname(path8.dirname(root))] }, log2) : () => {
|
|
1162
|
+
};
|
|
881
1163
|
if (!watch) {
|
|
882
1164
|
for (const o of opts) await build(o);
|
|
883
1165
|
log2(`built ${opts.length} bundle(s) in ${root}`);
|
|
884
|
-
return async () =>
|
|
885
|
-
};
|
|
1166
|
+
return async () => stopTw();
|
|
886
1167
|
}
|
|
887
1168
|
const ctxs = await Promise.all(opts.map((o) => context(o)));
|
|
888
1169
|
await Promise.all(ctxs.map((c) => c.watch()));
|
|
889
1170
|
log2(`watching ${root} (${ctxs.length} bundles)`);
|
|
890
|
-
return async () => void await Promise.all(ctxs.map((c) => c.dispose()));
|
|
1171
|
+
return async () => (stopTw(), void await Promise.all(ctxs.map((c) => c.dispose())));
|
|
891
1172
|
}
|
|
892
1173
|
|
|
893
1174
|
// src/cli/host.ts
|
|
894
|
-
import { spawn } from "node:child_process";
|
|
895
|
-
import { promises as fs7 } from "node:fs";
|
|
896
|
-
import { createRequire } from "node:module";
|
|
897
|
-
import
|
|
1175
|
+
import { spawn as spawn2, spawnSync as spawnSync2 } from "node:child_process";
|
|
1176
|
+
import { existsSync as existsSync2, promises as fs7, statSync } from "node:fs";
|
|
1177
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
1178
|
+
import path10 from "node:path";
|
|
898
1179
|
import { build as build2, context as context2 } from "esbuild";
|
|
1180
|
+
init_tailwind();
|
|
899
1181
|
|
|
900
1182
|
// src/cli/config.ts
|
|
901
1183
|
import { promises as fs6 } from "node:fs";
|
|
902
|
-
import
|
|
1184
|
+
import path9 from "node:path";
|
|
903
1185
|
async function loadConfig(flags, cwd = process.cwd()) {
|
|
904
1186
|
let file = {};
|
|
905
1187
|
try {
|
|
906
|
-
file = JSON.parse(await fs6.readFile(
|
|
1188
|
+
file = JSON.parse(await fs6.readFile(path9.join(cwd, "fluxplugin.config.json"), "utf8"));
|
|
907
1189
|
} catch {
|
|
908
1190
|
}
|
|
909
1191
|
return {
|
|
910
|
-
pluginsDir:
|
|
911
|
-
dataDir:
|
|
1192
|
+
pluginsDir: path9.resolve(cwd, flags.dir ?? file.pluginsDir ?? "plugins"),
|
|
1193
|
+
dataDir: path9.resolve(cwd, flags.data ?? file.dataDir ?? ".flux-data"),
|
|
1194
|
+
stores: Array.isArray(file.stores) && file.stores.length ? file.stores : [DEFAULT_STORE]
|
|
912
1195
|
};
|
|
913
1196
|
}
|
|
914
1197
|
|
|
915
1198
|
// src/cli/host.ts
|
|
916
1199
|
var exists2 = (f) => fs7.access(f).then(() => true, () => false);
|
|
917
1200
|
async function isHostProject(cwd) {
|
|
918
|
-
|
|
1201
|
+
if (await exists2(path10.join(cwd, "fluxplugin.config.json"))) return true;
|
|
1202
|
+
return await exists2(path10.join(cwd, "src/app/renderer/index.tsx")) || await exists2(path10.join(cwd, "src/renderer.tsx"));
|
|
919
1203
|
}
|
|
920
1204
|
var nodeBanner = { js: "import { createRequire as __r } from 'node:module'; const require = __r(import.meta.url);" };
|
|
1205
|
+
var pick = (cwd, ...rels) => {
|
|
1206
|
+
for (const r of rels) if (existsSync2(path10.join(cwd, r))) return path10.join(cwd, r);
|
|
1207
|
+
return path10.join(cwd, rels[0]);
|
|
1208
|
+
};
|
|
921
1209
|
function hostBuilds(cwd, outDir, dev) {
|
|
922
1210
|
const common = { absWorkingDir: cwd, bundle: true, sourcemap: dev, logLevel: "warning", jsx: "automatic", define: { "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production") } };
|
|
923
|
-
const
|
|
1211
|
+
const renderer = pick(cwd, "src/app/renderer/index.tsx", "src/renderer.tsx");
|
|
1212
|
+
const main = pick(cwd, "src/app/main/index.ts", "src/main.ts");
|
|
1213
|
+
const preload = pick(cwd, "src/app/preload/index.ts", "src/preload.ts");
|
|
1214
|
+
const web = pick(cwd, "src/app/main/web.ts", "src/web.ts");
|
|
924
1215
|
return [
|
|
925
|
-
{ ...common, entryPoints: [
|
|
926
|
-
{ ...common, entryPoints: [
|
|
927
|
-
{ ...common, entryPoints: [
|
|
928
|
-
{ ...common, entryPoints: [
|
|
1216
|
+
{ ...common, entryPoints: [renderer], outfile: path10.join(outDir, "renderer.js"), platform: "browser", format: "esm", target: "chrome120" },
|
|
1217
|
+
{ ...common, entryPoints: [main], outfile: path10.join(outDir, "main.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
|
|
1218
|
+
{ ...common, entryPoints: [preload], outfile: path10.join(outDir, "preload.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
|
|
1219
|
+
{ ...common, entryPoints: [web], outfile: path10.join(outDir, "web.mjs"), platform: "node", format: "esm", target: "node20", external: ["electron"], banner: nodeBanner }
|
|
929
1220
|
];
|
|
930
1221
|
}
|
|
931
1222
|
async function pluginDirs(cwd, pluginsDir) {
|
|
932
1223
|
const names = await fs7.readdir(pluginsDir).catch(() => []);
|
|
933
1224
|
const out = [];
|
|
934
|
-
for (const n of names.sort()) if (await exists2(
|
|
1225
|
+
for (const n of names.sort()) if (await exists2(path10.join(pluginsDir, n, "plugin.json"))) out.push(path10.join(pluginsDir, n));
|
|
935
1226
|
void cwd;
|
|
936
1227
|
return out;
|
|
937
1228
|
}
|
|
938
1229
|
async function buildHost(cwd, opts = {}) {
|
|
939
1230
|
const log2 = opts.log ?? console.log;
|
|
940
|
-
const cfgFile = JSON.parse(await fs7.readFile(
|
|
1231
|
+
const cfgFile = JSON.parse(await fs7.readFile(path10.join(cwd, "fluxplugin.config.json"), "utf8").catch(() => "{}"));
|
|
941
1232
|
const cfg = await loadConfig({}, cwd);
|
|
942
|
-
const outDir =
|
|
1233
|
+
const outDir = path10.resolve(cwd, cfgFile.outDir ?? "dist");
|
|
943
1234
|
await fs7.mkdir(outDir, { recursive: true });
|
|
944
|
-
await fs7.copyFile(
|
|
1235
|
+
await fs7.copyFile(pick(cwd, "src/app/renderer/index.html", "src/index.html"), path10.join(outDir, "index.html"));
|
|
945
1236
|
const disposers = [];
|
|
1237
|
+
const twIn = path10.join(cwd, "src/app/renderer/styles/tailwind.css");
|
|
1238
|
+
if (await exists2(twIn)) {
|
|
1239
|
+
const stop = compileTailwind({ cwd, input: twIn, output: path10.join(outDir, "tailwind.css"), watch: opts.watch }, log2);
|
|
1240
|
+
disposers.push(async () => stop());
|
|
1241
|
+
}
|
|
946
1242
|
const opts2 = hostBuilds(cwd, outDir, !!opts.watch);
|
|
947
1243
|
if (opts.watch) {
|
|
948
1244
|
const ctxs = await Promise.all(opts2.map((o) => context2(o)));
|
|
1245
|
+
await Promise.all(ctxs.map((c) => c.rebuild()));
|
|
949
1246
|
await Promise.all(ctxs.map((c) => c.watch()));
|
|
950
1247
|
disposers.push(async () => void await Promise.all(ctxs.map((c) => c.dispose())));
|
|
951
1248
|
} else for (const o of opts2) await build2(o);
|
|
@@ -953,26 +1250,202 @@ async function buildHost(cwd, opts = {}) {
|
|
|
953
1250
|
try {
|
|
954
1251
|
disposers.push(await buildPlugin(dir, !!opts.watch, log2));
|
|
955
1252
|
} catch (e) {
|
|
956
|
-
log2(`! plugin ${
|
|
1253
|
+
log2(`! plugin ${path10.basename(dir)} not built: ${e.message}`);
|
|
957
1254
|
}
|
|
958
1255
|
}
|
|
959
|
-
log2(`host built \u2192 ${
|
|
1256
|
+
log2(`host built \u2192 ${path10.relative(cwd, outDir) || "."}`);
|
|
960
1257
|
return async () => void await Promise.all(disposers.map((d) => d()));
|
|
961
1258
|
}
|
|
962
1259
|
function electronBinary(cwd) {
|
|
963
1260
|
try {
|
|
964
|
-
const p =
|
|
965
|
-
|
|
1261
|
+
const p = createRequire2(path10.join(cwd, "package.json"))("electron");
|
|
1262
|
+
if (typeof p === "string" && existsSync2(p)) return { bin: p };
|
|
1263
|
+
return { error: "the Electron binary was not found" };
|
|
1264
|
+
} catch (e) {
|
|
1265
|
+
return { error: e.message.split("\n")[0] };
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
function ensureElectron(cwd, log2 = console.log) {
|
|
1269
|
+
const first = electronBinary(cwd);
|
|
1270
|
+
if (first.bin) return first;
|
|
1271
|
+
let pkgDir;
|
|
1272
|
+
try {
|
|
1273
|
+
pkgDir = path10.dirname(createRequire2(path10.join(cwd, "package.json")).resolve("electron/package.json"));
|
|
966
1274
|
} catch {
|
|
967
|
-
return
|
|
1275
|
+
return { error: "Electron is not installed in this project" };
|
|
1276
|
+
}
|
|
1277
|
+
const installJs = path10.join(pkgDir, "install.js");
|
|
1278
|
+
if (!existsSync2(installJs)) return first;
|
|
1279
|
+
log2("Electron's binary is missing (your package manager blocked its install script) \u2192 downloading it now\u2026");
|
|
1280
|
+
const r = spawnSync2(process.execPath, [installJs], { cwd: pkgDir, stdio: "inherit" });
|
|
1281
|
+
if (r.status !== 0) return { error: "the Electron download failed (network or proxy?). Retry, or set ELECTRON_MIRROR" };
|
|
1282
|
+
return electronBinary(cwd);
|
|
1283
|
+
}
|
|
1284
|
+
function needsNoSandbox(bin) {
|
|
1285
|
+
if (process.platform !== "linux") return false;
|
|
1286
|
+
if (process.getuid?.() === 0) return true;
|
|
1287
|
+
try {
|
|
1288
|
+
const st = statSync(path10.join(path10.dirname(bin), "chrome-sandbox"));
|
|
1289
|
+
return !(st.uid === 0 && (st.mode & 2048) !== 0);
|
|
1290
|
+
} catch {
|
|
1291
|
+
return false;
|
|
968
1292
|
}
|
|
969
1293
|
}
|
|
970
1294
|
function startApp(cwd, opts = {}) {
|
|
971
|
-
const
|
|
1295
|
+
const log2 = opts.log ?? console.log;
|
|
972
1296
|
const env = { ...process.env, FLUXPLUGIN_PROJECT: cwd, ...opts.dev ? { FLUXPLUGIN_DEV: "1" } : {}, ...opts.port ? { PORT: String(opts.port) } : {} };
|
|
973
|
-
if (
|
|
974
|
-
|
|
1297
|
+
if (opts.web) return { child: spawn2(process.execPath, [path10.join(cwd, "dist/web.mjs")], { cwd, env, stdio: "inherit" }), mode: "web" };
|
|
1298
|
+
const { bin, error } = ensureElectron(cwd, log2);
|
|
1299
|
+
if (!bin) throw new Error(`cannot start Electron: ${error}.
|
|
1300
|
+
Try: npm install -D electron then fluxplugin doctor
|
|
1301
|
+
Or run in a browser: fluxplugin ${opts.dev ? "dev" : "start"} --web`);
|
|
1302
|
+
const args = [...process.env["FLUXPLUGIN_ELECTRON_ARGS"]?.split(" ").filter(Boolean) ?? [], path10.join(cwd, "dist/main.cjs")];
|
|
1303
|
+
if (needsNoSandbox(bin)) {
|
|
1304
|
+
args.unshift("--no-sandbox");
|
|
1305
|
+
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");
|
|
1306
|
+
}
|
|
1307
|
+
const started = Date.now();
|
|
1308
|
+
const child = spawn2(bin, args, { cwd, env, stdio: "inherit" });
|
|
1309
|
+
child.on("exit", (code) => {
|
|
1310
|
+
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`);
|
|
1311
|
+
});
|
|
1312
|
+
return { child, mode: "electron" };
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// src/cli/host-files.generated.ts
|
|
1316
|
+
var HOST_APP_FILES = {
|
|
1317
|
+
"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',
|
|
1318
|
+
"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',
|
|
1319
|
+
"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',
|
|
1320
|
+
"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',
|
|
1321
|
+
"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',
|
|
1322
|
+
"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',
|
|
1323
|
+
"src/app/main/window.ts": `import { BrowserWindow, shell } from "electron";
|
|
1324
|
+
import path from "node:path";
|
|
1325
|
+
import { TITLEBAR_HEIGHT } from "../shared/constants";
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Frameless window with a custom title bar drawn by the renderer (<TitleBar />).
|
|
1329
|
+
* - macOS: "hiddenInset" keeps the native traffic-light buttons (the bar reserves space for them).
|
|
1330
|
+
* - Windows/Linux: "hidden" + titleBarOverlay keeps the native minimize/maximize/close buttons on the right.
|
|
1331
|
+
*/
|
|
1332
|
+
export function createMainWindow(distDir: string): BrowserWindow {
|
|
1333
|
+
const mac = process.platform === "darwin";
|
|
1334
|
+
const win = new BrowserWindow({
|
|
1335
|
+
width: 1180,
|
|
1336
|
+
height: 780,
|
|
1337
|
+
minWidth: 760,
|
|
1338
|
+
minHeight: 480,
|
|
1339
|
+
show: false,
|
|
1340
|
+
titleBarStyle: mac ? "hiddenInset" : "hidden",
|
|
1341
|
+
...(mac ? {} : { titleBarOverlay: { color: "#f8fafc", symbolColor: "#0f172a", height: TITLEBAR_HEIGHT } }),
|
|
1342
|
+
webPreferences: {
|
|
1343
|
+
preload: path.join(distDir, "preload.cjs"),
|
|
1344
|
+
contextIsolation: true,
|
|
1345
|
+
sandbox: true,
|
|
1346
|
+
nodeIntegration: false,
|
|
1347
|
+
},
|
|
1348
|
+
});
|
|
1349
|
+
win.once("ready-to-show", () => win.show());
|
|
1350
|
+
// The app never navigates away; https links open in the user's browser, everything else is blocked.
|
|
1351
|
+
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
1352
|
+
if (/^https:\\/\\//.test(url)) void shell.openExternal(url);
|
|
1353
|
+
return { action: "deny" };
|
|
1354
|
+
});
|
|
1355
|
+
win.webContents.on("will-navigate", (e) => e.preventDefault());
|
|
1356
|
+
void win.loadFile(path.join(distDir, "index.html"));
|
|
1357
|
+
return win;
|
|
1358
|
+
}
|
|
1359
|
+
`,
|
|
1360
|
+
"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',
|
|
1361
|
+
"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',
|
|
1362
|
+
"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',
|
|
1363
|
+
"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',
|
|
1364
|
+
"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',
|
|
1365
|
+
"src/app/renderer/index.html": `<!doctype html>
|
|
1366
|
+
<html>
|
|
1367
|
+
<head>
|
|
1368
|
+
<meta charset="utf-8" />
|
|
1369
|
+
<title>__APP_NAME__</title>
|
|
1370
|
+
<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" />
|
|
1371
|
+
<link rel="stylesheet" href="./renderer.css" />
|
|
1372
|
+
</head>
|
|
1373
|
+
<body>
|
|
1374
|
+
<div id="root"></div>
|
|
1375
|
+
<script type="module" src="./renderer.js"></script>
|
|
1376
|
+
</body>
|
|
1377
|
+
</html>
|
|
1378
|
+
`,
|
|
1379
|
+
"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',
|
|
1380
|
+
"src/app/renderer/menus.ts": `import type { RendererFramework } from "fluxplugin/react";
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* The host's own application menu (the bar in the title bar). Plugins add entries to the same registry, either as
|
|
1384
|
+
* new top-level menus or as extra children \u2014 see docs/CUSTOMIZING.md. Shortcuts shown come from the commands' keybinding.
|
|
1385
|
+
*/
|
|
1386
|
+
export function registerHostMenus(framework: RendererFramework): void {
|
|
1387
|
+
const { menus, commands } = framework.manager;
|
|
1388
|
+
commands.register({ id: "host.palette", title: "Command palette", category: "View", keybinding: "Mod+K", execute: () => window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true })) }, "host");
|
|
1389
|
+
commands.register({ id: "host.reload", title: "Reload window", category: "View", keybinding: "Mod+R", execute: () => location.reload() }, "host");
|
|
1390
|
+
commands.register({ id: "host.goHome", title: "Go to dashboard", category: "Go", execute: () => (location.hash = "#/") }, "host");
|
|
1391
|
+
commands.register({ id: "host.goPlugins", title: "Manage plugins", category: "Go", execute: () => (location.hash = "#/plugins") }, "host");
|
|
1392
|
+
commands.register({ id: "host.docs", title: "Documentation", category: "Help", execute: () => void window.open("https://www.npmjs.com/package/fluxplugin", "_blank") }, "host");
|
|
1393
|
+
|
|
1394
|
+
menus.register({ id: "app.go", location: "application", label: "Go", order: 10, children: [
|
|
1395
|
+
{ id: "app.go.home", label: "Dashboard", command: "host.goHome" },
|
|
1396
|
+
{ id: "app.go.plugins", label: "Plugins", command: "host.goPlugins" },
|
|
1397
|
+
] }, "host");
|
|
1398
|
+
menus.register({ id: "app.view", location: "application", label: "View", order: 20, children: [
|
|
1399
|
+
{ id: "app.view.palette", label: "Command palette", command: "host.palette" },
|
|
1400
|
+
{ id: "app.view.sep", label: "", separator: true },
|
|
1401
|
+
{ id: "app.view.reload", label: "Reload", command: "host.reload" },
|
|
1402
|
+
] }, "host");
|
|
1403
|
+
menus.register({ id: "app.help", location: "application", label: "Help", order: 90, children: [{ id: "app.help.docs", label: "Documentation", command: "host.docs" }] }, "host");
|
|
975
1404
|
}
|
|
1405
|
+
`,
|
|
1406
|
+
"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',
|
|
1407
|
+
"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',
|
|
1408
|
+
"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',
|
|
1409
|
+
"src/app/renderer/routes.ts": `import type { RendererFramework } from "fluxplugin/react";
|
|
1410
|
+
import { DashboardPage } from "./pages/DashboardPage";
|
|
1411
|
+
import { PluginsPage } from "./pages/PluginsPage";
|
|
1412
|
+
|
|
1413
|
+
/** The host's own pages (owner "host"). Plugins add theirs at runtime through the same registry. */
|
|
1414
|
+
export function registerHostRoutes(framework: RendererFramework): void {
|
|
1415
|
+
framework.manager.routes.register({ path: "/", component: DashboardPage, meta: { title: "Dashboard" } }, "host");
|
|
1416
|
+
framework.manager.routes.register({ path: "/plugins", component: PluginsPage, meta: { title: "Plugins", breadcrumb: "Plugins" } }, "host");
|
|
1417
|
+
}
|
|
1418
|
+
`,
|
|
1419
|
+
"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',
|
|
1420
|
+
"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',
|
|
1421
|
+
"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',
|
|
1422
|
+
"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',
|
|
1423
|
+
"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",
|
|
1424
|
+
"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',
|
|
1425
|
+
"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'
|
|
1426
|
+
};
|
|
1427
|
+
var HOST_TAILWIND_FILES = {
|
|
1428
|
+
"src/app/renderer/index.html": `<!doctype html>
|
|
1429
|
+
<html>
|
|
1430
|
+
<head>
|
|
1431
|
+
<meta charset="utf-8" />
|
|
1432
|
+
<title>__APP_NAME__</title>
|
|
1433
|
+
<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" />
|
|
1434
|
+
<link rel="stylesheet" href="./renderer.css" />
|
|
1435
|
+
<link rel="stylesheet" href="./tailwind.css" />
|
|
1436
|
+
</head>
|
|
1437
|
+
<body>
|
|
1438
|
+
<div id="root"></div>
|
|
1439
|
+
<script type="module" src="./renderer.js"></script>
|
|
1440
|
+
</body>
|
|
1441
|
+
</html>
|
|
1442
|
+
`,
|
|
1443
|
+
"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',
|
|
1444
|
+
"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',
|
|
1445
|
+
"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',
|
|
1446
|
+
"src/app/renderer/styles/app.css": "body { margin: 0; font: 14px system-ui; background: var(--flux-bg); color: var(--flux-fg); }\n",
|
|
1447
|
+
"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'
|
|
1448
|
+
};
|
|
976
1449
|
|
|
977
1450
|
// src/cli/templates.ts
|
|
978
1451
|
var pkg = (v) => JSON.stringify(
|
|
@@ -981,7 +1454,7 @@ var pkg = (v) => JSON.stringify(
|
|
|
981
1454
|
version: "1.0.0",
|
|
982
1455
|
private: true,
|
|
983
1456
|
type: "module",
|
|
984
|
-
scripts: { dev: "fluxplugin dev", build: "fluxplugin build", package: "fluxplugin package", validate: "fluxplugin validate" },
|
|
1457
|
+
scripts: { dev: "fluxplugin dev", build: "fluxplugin build", package: "fluxplugin package", validate: "fluxplugin validate", release: "fluxplugin publish" },
|
|
985
1458
|
devDependencies: { fluxplugin: "^1.0.0", "@types/react": "^19.0.0", react: "^19.0.0", typescript: "^5.6.0" }
|
|
986
1459
|
},
|
|
987
1460
|
null,
|
|
@@ -1017,6 +1490,14 @@ function basic(v) {
|
|
|
1017
1490
|
version: "1.0.0",
|
|
1018
1491
|
description: `${v.name} plugin`,
|
|
1019
1492
|
author: "Your name",
|
|
1493
|
+
license: "MIT",
|
|
1494
|
+
repository: "https://github.com/YOUR_NAME/${v.slug}",
|
|
1495
|
+
// fluxplugin publish releases here (replace YOUR_NAME)
|
|
1496
|
+
icon: "./assets/icon.svg",
|
|
1497
|
+
readme: "./README.md",
|
|
1498
|
+
categories: ["Productivity"],
|
|
1499
|
+
keywords: ["${v.slug}"],
|
|
1500
|
+
links: {},
|
|
1020
1501
|
main: "./dist/main.mjs",
|
|
1021
1502
|
renderer: "./dist/renderer.js",
|
|
1022
1503
|
styles: ["./dist/index.css"],
|
|
@@ -1067,17 +1548,8 @@ export default definePlugin({
|
|
|
1067
1548
|
});
|
|
1068
1549
|
`,
|
|
1069
1550
|
"src/styles/index.css": styles(v),
|
|
1070
|
-
"
|
|
1071
|
-
|
|
1072
|
-
Created with \`fluxplugin create\`.
|
|
1073
|
-
|
|
1074
|
-
\`\`\`bash
|
|
1075
|
-
pnpm install
|
|
1076
|
-
pnpm dev # rebuild on change (the host hot-reloads it)
|
|
1077
|
-
pnpm build
|
|
1078
|
-
pnpm package # produces ${v.id}-1.0.0.tgz
|
|
1079
|
-
\`\`\`
|
|
1080
|
-
`
|
|
1551
|
+
"assets/icon.svg": iconSvg(v),
|
|
1552
|
+
"README.md": readme(v)
|
|
1081
1553
|
};
|
|
1082
1554
|
}
|
|
1083
1555
|
function dashboard(v) {
|
|
@@ -1085,7 +1557,7 @@ function dashboard(v) {
|
|
|
1085
1557
|
const m = JSON.parse(files["plugin.json"]);
|
|
1086
1558
|
m.activationEvents = ["onStartup"];
|
|
1087
1559
|
m.contributes = {};
|
|
1088
|
-
m.permissions = ["commands", "routes", "menus", "ui", "styles"];
|
|
1560
|
+
m.permissions = ["commands", "routes", "menus", "ui", "styles", "notifications"];
|
|
1089
1561
|
files["plugin.json"] = JSON.stringify(m, null, 2);
|
|
1090
1562
|
files["src/renderer.tsx"] = `import { definePlugin } from "fluxplugin";
|
|
1091
1563
|
import { usePluginState } from "fluxplugin/react";
|
|
@@ -1108,9 +1580,21 @@ function ${v.Pascal}Page() {
|
|
|
1108
1580
|
export default definePlugin({
|
|
1109
1581
|
routes: [{ path: "/${v.slug}", lazy: async () => ({ default: ${v.Pascal}Page }), meta: { title: "${v.name}", breadcrumb: "${v.name}", menu: { label: "${v.name}" } } }],
|
|
1110
1582
|
components: [{ slot: "dashboard.after", component: ${v.Pascal}Widget, priority: 10 }],
|
|
1111
|
-
// Data contribution to the host's "dashboard" extension point (validated by the host's schema).
|
|
1112
|
-
extensions: [{ point: "dashboard", contribution: { toolbar: [{ id: "${v.slug}-hello", label: "${v.name}: hello", command: "${v.slug}.hello" }] } }],
|
|
1113
1583
|
commands: [{ id: "${v.slug}.hello", title: "${v.name}: say hello", category: "${v.name}", execute: () => console.log("Hello from ${v.name}!") }],
|
|
1584
|
+
// Application menu: adds a top-level "${v.name}" menu to the title bar (File/Edit-style). Shortcuts come from the command's keybinding.
|
|
1585
|
+
menus: [{ id: "${v.slug}.menu", location: "application", label: "${v.name}", order: 50,
|
|
1586
|
+
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" }] }],
|
|
1587
|
+
// Title-bar search: your results appear next to commands and pages.
|
|
1588
|
+
extensions: [
|
|
1589
|
+
{ point: "dashboard", contribution: { toolbar: [{ id: "${v.slug}-hello", label: "${v.name}: hello", command: "${v.slug}.hello" }] } },
|
|
1590
|
+
{ point: "titlebar", contribution: { searchProviders: [{ id: "${v.slug}.search", label: "${v.name}",
|
|
1591
|
+
search: (q: string) => ("${v.slug}".includes(q.toLowerCase()) ? [{ id: "${v.slug}.r1", title: "Open ${v.name}", subtitle: "page", run: () => { location.hash = "#/${v.slug}"; } }] : []) }] } },
|
|
1592
|
+
],
|
|
1593
|
+
// A theme: tokens are CSS variables; the user picks it in the title bar.
|
|
1594
|
+
// { 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" } }] } },
|
|
1595
|
+
onActivate(ctx) {
|
|
1596
|
+
ctx.notifications.push({ title: "${v.name} is ready", body: "Open the title-bar bell to see this notification.", level: "success" });
|
|
1597
|
+
},
|
|
1114
1598
|
});
|
|
1115
1599
|
`;
|
|
1116
1600
|
return files;
|
|
@@ -1169,6 +1653,75 @@ function varsFromName(name) {
|
|
|
1169
1653
|
const Pascal = short.split(/[^a-z0-9]+/i).filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join("");
|
|
1170
1654
|
return { id, name: short.split("-").map((p) => p[0].toUpperCase() + p.slice(1)).join(" "), slug: short.replace(/-/g, "_"), Pascal };
|
|
1171
1655
|
}
|
|
1656
|
+
function iconSvg(v) {
|
|
1657
|
+
let h = 0;
|
|
1658
|
+
for (const c of v.id) h = (h * 31 + c.charCodeAt(0)) % 360;
|
|
1659
|
+
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>
|
|
1660
|
+
`;
|
|
1661
|
+
}
|
|
1662
|
+
function readme(v) {
|
|
1663
|
+
return `# ${v.name}
|
|
1664
|
+
|
|
1665
|
+
One or two sentences that say what this plugin does and who it is for.
|
|
1666
|
+
|
|
1667
|
+
## What you get
|
|
1668
|
+
|
|
1669
|
+
- A **page** at \`/${v.slug}\` in the sidebar
|
|
1670
|
+
- A **widget** on the dashboard
|
|
1671
|
+
- A **command** you can run from the command palette (Ctrl/Cmd+K)
|
|
1672
|
+
|
|
1673
|
+
## How to use it
|
|
1674
|
+
|
|
1675
|
+
1. Enable the plugin in **Plugins**.
|
|
1676
|
+
2. Open **${v.name}** in the sidebar.
|
|
1677
|
+
3. Configure it in the plugin's *Settings* section.
|
|
1678
|
+
|
|
1679
|
+
## Permissions
|
|
1680
|
+
|
|
1681
|
+
| Permission | Why |
|
|
1682
|
+
| --- | --- |
|
|
1683
|
+
| \`routes\`, \`menus\` | adds its page and sidebar entry |
|
|
1684
|
+
| \`ui\` | adds a dashboard widget |
|
|
1685
|
+
| \`commands\` | registers its commands |
|
|
1686
|
+
|
|
1687
|
+
## Video & screenshots
|
|
1688
|
+
|
|
1689
|
+
Add them in \`plugin.json\` and they appear on this page:
|
|
1690
|
+
|
|
1691
|
+
\`\`\`json
|
|
1692
|
+
"video": "https://www.youtube.com/watch?v=YOUR_VIDEO_ID",
|
|
1693
|
+
"banner": "./assets/banner.png",
|
|
1694
|
+
"screenshots": ["./assets/shot-1.png", "./assets/shot-2.png"],
|
|
1695
|
+
"links": { "docs": "https://example.com/docs", "support": "https://example.com/support" }
|
|
1696
|
+
\`\`\`
|
|
1697
|
+
|
|
1698
|
+
## Changelog
|
|
1699
|
+
|
|
1700
|
+
- **1.0.0** \u2014 first release
|
|
1701
|
+
`;
|
|
1702
|
+
}
|
|
1703
|
+
function withTailwind(files, v) {
|
|
1704
|
+
const m = JSON.parse(files["plugin.json"]);
|
|
1705
|
+
m.styles = ["./dist/index.css", "./dist/tailwind.css"];
|
|
1706
|
+
files["plugin.json"] = JSON.stringify(m, null, 2);
|
|
1707
|
+
const pkg2 = JSON.parse(files["package.json"]);
|
|
1708
|
+
pkg2.devDependencies = { ...pkg2.devDependencies, tailwindcss: "^4.0.0", "@tailwindcss/cli": "^4.0.0" };
|
|
1709
|
+
files["package.json"] = JSON.stringify(pkg2, null, 2);
|
|
1710
|
+
files["src/styles/tailwind.css"] = `/* Utilities only: the host owns the preflight/reset. Colors follow the host theme. */
|
|
1711
|
+
@import "tailwindcss/theme.css" layer(theme);
|
|
1712
|
+
@import "tailwindcss/utilities.css" layer(utilities);
|
|
1713
|
+
|
|
1714
|
+
@theme inline {
|
|
1715
|
+
--color-surface: var(--flux-surface);
|
|
1716
|
+
--color-border: var(--flux-border);
|
|
1717
|
+
--color-accent: var(--flux-accent);
|
|
1718
|
+
--color-muted: var(--flux-muted);
|
|
1719
|
+
--color-fg: var(--flux-fg);
|
|
1720
|
+
}
|
|
1721
|
+
`;
|
|
1722
|
+
files["src/renderer.tsx"] = files["src/renderer.tsx"].split(`className="${v.slug}-card"`).join('className="rounded-xl border border-border bg-surface p-4"');
|
|
1723
|
+
return files;
|
|
1724
|
+
}
|
|
1172
1725
|
|
|
1173
1726
|
// src/cli/host-templates.ts
|
|
1174
1727
|
function hostFiles(v, withSample = true) {
|
|
@@ -1189,250 +1742,277 @@ function hostFiles(v, withSample = true) {
|
|
|
1189
1742
|
typecheck: "tsc --noEmit"
|
|
1190
1743
|
},
|
|
1191
1744
|
dependencies: { fluxplugin: v.fluxSpec, react: "^19.0.0", "react-dom": "^19.0.0" },
|
|
1192
|
-
devDependencies: { electron: "^
|
|
1745
|
+
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" } : {} },
|
|
1746
|
+
// npm 11.16+/12 and pnpm 10 block dependency install scripts unless allowed; Electron needs its postinstall to download the binary.
|
|
1747
|
+
allowScripts: { electron: true, esbuild: true },
|
|
1748
|
+
pnpm: { onlyBuiltDependencies: ["electron", "esbuild"] }
|
|
1193
1749
|
},
|
|
1194
1750
|
null,
|
|
1195
1751
|
2
|
|
1196
1752
|
),
|
|
1197
|
-
"
|
|
1753
|
+
// "stores" is used by the CLI (fluxplugin search/install) AND by your app (Plugins → Available). Add your own store here.
|
|
1754
|
+
"fluxplugin.config.json": JSON.stringify({ pluginsDir: "plugins", outDir: "dist", stores: [{ type: "github", topic: "fluxplugin-plugin" }] }, null, 2),
|
|
1198
1755
|
"tsconfig.json": JSON.stringify(
|
|
1199
|
-
{ 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"] },
|
|
1756
|
+
{ 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"] },
|
|
1200
1757
|
null,
|
|
1201
1758
|
2
|
|
1202
1759
|
),
|
|
1203
1760
|
".gitignore": "node_modules\ndist\n.flux-data\n*.tgz\n",
|
|
1204
|
-
"
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
pluginDirs: opts.pluginDirs,
|
|
1217
|
-
dataDir: opts.dataDir,
|
|
1218
|
-
hotReload: opts.dev,
|
|
1219
|
-
assetUrl: opts.assetUrl,
|
|
1220
|
-
requireApproval: false, // set true to make users approve each plugin's permissions
|
|
1221
|
-
allowedRendererChannels: ["host.item.create"],
|
|
1222
|
-
});
|
|
1223
|
-
|
|
1224
|
-
// A host operation wrapped in the hook pipeline: plugins can validate, normalize or veto it
|
|
1225
|
-
// (before./transform./after.item.create) without changing this file.
|
|
1226
|
-
fw.manager.ipc.handle(
|
|
1227
|
-
"host.item.create",
|
|
1228
|
-
(input: { name: string }) =>
|
|
1229
|
-
fw.manager.hooks.run("item.create", input, (i) => {
|
|
1230
|
-
const item = { id: Math.random().toString(36).slice(2, 8), name: i.name };
|
|
1231
|
-
fw.manager.events.emit("item.created", item, "host");
|
|
1232
|
-
return item as Record<string, unknown>;
|
|
1233
|
-
}),
|
|
1234
|
-
"host",
|
|
1235
|
-
{ input: s.object({ name: s.string() }) },
|
|
1236
|
-
);
|
|
1237
|
-
return fw;
|
|
1761
|
+
"README.md": readme2(v.name)
|
|
1762
|
+
};
|
|
1763
|
+
for (const [rel, content] of Object.entries(HOST_APP_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name);
|
|
1764
|
+
if (v.tailwind) for (const [rel, content] of Object.entries(HOST_TAILWIND_FILES)) files[rel] = content.split("__APP_NAME__").join(v.name);
|
|
1765
|
+
if (withSample) {
|
|
1766
|
+
const sample = TEMPLATES.dashboard(varsFromName("hello"));
|
|
1767
|
+
for (const [rel, content] of Object.entries(sample)) {
|
|
1768
|
+
if (rel === "package.json" || rel === "tsconfig.json") continue;
|
|
1769
|
+
files[`plugins/hello/${rel}`] = content;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return files;
|
|
1238
1773
|
}
|
|
1239
|
-
|
|
1240
|
-
"src/main.ts": `// Electron main process.
|
|
1241
|
-
import { app, BrowserWindow, ipcMain, protocol, webContents } from "electron";
|
|
1242
|
-
import path from "node:path";
|
|
1243
|
-
import { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";
|
|
1244
|
-
import { createFramework } from "./framework.js";
|
|
1245
|
-
|
|
1246
|
-
protocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before app is ready
|
|
1247
|
-
|
|
1248
|
-
app.whenReady().then(async () => {
|
|
1249
|
-
const dev = !!process.env["FLUXPLUGIN_DEV"];
|
|
1250
|
-
const root = process.env["FLUXPLUGIN_PROJECT"] ?? path.resolve(__dirname, "..");
|
|
1251
|
-
const fw = createFramework({
|
|
1252
|
-
pluginDirs: [path.join(root, "plugins"), path.join(app.getPath("userData"), "plugins")], // bundled + user-installed
|
|
1253
|
-
dataDir: path.join(app.getPath("userData"), "plugins-data"),
|
|
1254
|
-
dev,
|
|
1255
|
-
});
|
|
1256
|
-
registerPluginProtocol(fw, protocol);
|
|
1257
|
-
attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });
|
|
1258
|
-
await fw.start();
|
|
1259
|
-
|
|
1260
|
-
const win = new BrowserWindow({
|
|
1261
|
-
width: 1100,
|
|
1262
|
-
height: 760,
|
|
1263
|
-
webPreferences: { preload: path.join(__dirname, "preload.cjs"), contextIsolation: true, sandbox: true, nodeIntegration: false },
|
|
1264
|
-
});
|
|
1265
|
-
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
1266
|
-
win.webContents.on("will-navigate", (e) => e.preventDefault());
|
|
1267
|
-
await win.loadFile(path.join(__dirname, "index.html"));
|
|
1268
|
-
app.on("before-quit", () => void fw.stop());
|
|
1269
|
-
});
|
|
1270
|
-
app.on("window-all-closed", () => app.quit());
|
|
1271
|
-
`,
|
|
1272
|
-
"src/preload.ts": `import { contextBridge, ipcRenderer } from "electron";
|
|
1273
|
-
import { exposeFluxBridge } from "fluxplugin/preload";
|
|
1274
|
-
|
|
1275
|
-
exposeFluxBridge(contextBridge, ipcRenderer); // the only thing renderer code receives: window.__fluxplugin
|
|
1276
|
-
`,
|
|
1277
|
-
"src/web.ts": `// Browser mode (no Electron): \`fluxplugin dev --web\`.
|
|
1278
|
-
import path from "node:path";
|
|
1279
|
-
import { serveWeb } from "fluxplugin/main";
|
|
1280
|
-
import { createFramework } from "./framework.js";
|
|
1281
|
-
|
|
1282
|
-
const root = process.env["FLUXPLUGIN_PROJECT"] ?? process.cwd();
|
|
1283
|
-
const fw = createFramework({
|
|
1284
|
-
pluginDirs: [path.join(root, "plugins")],
|
|
1285
|
-
dataDir: path.join(root, ".flux-data"),
|
|
1286
|
-
dev: true,
|
|
1287
|
-
assetUrl: (id, rel) => \`/@plugins/\${id}/\${rel.replace(/^\\.\\//, "")}\`,
|
|
1288
|
-
});
|
|
1289
|
-
await fw.start();
|
|
1290
|
-
const srv = await serveWeb(fw, { distDir: path.join(root, "dist"), port: Number(process.env["PORT"] ?? 5173) });
|
|
1291
|
-
console.log(\`${v.name} \u2192 http://127.0.0.1:\${srv.port}\`);
|
|
1292
|
-
`,
|
|
1293
|
-
"src/renderer.tsx": renderer(v.name),
|
|
1294
|
-
"README.md": `# ${v.name}
|
|
1774
|
+
var readme2 = (name) => `# ${name}
|
|
1295
1775
|
|
|
1296
1776
|
Created with [fluxplugin](https://www.npmjs.com/package/fluxplugin).
|
|
1297
1777
|
|
|
1298
1778
|
\`\`\`bash
|
|
1299
1779
|
npm install
|
|
1300
1780
|
npm run dev:web # in a browser, no Electron needed
|
|
1301
|
-
npm run dev # in Electron (hot
|
|
1781
|
+
npm run dev # in Electron (plugins hot-reload)
|
|
1302
1782
|
npm run build && npm start
|
|
1303
1783
|
npm run plugin:new my-plugin -- -t backend # scaffold a plugin in ./plugins
|
|
1304
1784
|
\`\`\`
|
|
1305
1785
|
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1786
|
+
## Structure (everything lives in src/app)
|
|
1787
|
+
|
|
1788
|
+
\`\`\`
|
|
1789
|
+
src/app/
|
|
1790
|
+
shared/ constants.ts, types.ts names used by both processes (depends on nothing)
|
|
1791
|
+
main/ index.ts (Electron entry) \xB7 web.ts (browser entry) \xB7 framework.ts \xB7 operations.ts \xB7 window.ts \xB7 paths.ts
|
|
1792
|
+
preload/ index.ts exposes window.__fluxplugin
|
|
1793
|
+
renderer/
|
|
1794
|
+
index.tsx \xB7 index.html entry
|
|
1795
|
+
bootstrap.ts bridge, shared modules, framework start (infrastructure)
|
|
1796
|
+
extension-points.ts \xB7 routes.ts what plugins may extend / host pages
|
|
1797
|
+
providers/ shell/ pages/ UI layers
|
|
1798
|
+
hooks/ services/ app logic (services know the IPC channels, hooks serve the UI)
|
|
1799
|
+
styles/app.css
|
|
1800
|
+
plugins/ plugins loaded at startup (users can also install .tgz from the Plugins page)
|
|
1801
|
+
\`\`\`
|
|
1802
|
+
`;
|
|
1803
|
+
|
|
1804
|
+
// src/cli/package.ts
|
|
1805
|
+
import crypto4 from "node:crypto";
|
|
1806
|
+
import { promises as fs8 } from "node:fs";
|
|
1807
|
+
import os from "node:os";
|
|
1808
|
+
import path11 from "node:path";
|
|
1809
|
+
async function packagePlugin(root, opts = {}) {
|
|
1810
|
+
const log2 = opts.log ?? console.log;
|
|
1811
|
+
await buildPlugin(root, false, log2);
|
|
1812
|
+
let raw = JSON.parse(await fs8.readFile(path11.join(root, "plugin.json"), "utf8"));
|
|
1813
|
+
const v = validateManifest(raw);
|
|
1814
|
+
if (!v.manifest) throw new Error("invalid plugin.json: " + v.issues.map((i) => `${i.path}: ${i.message}`).join("; "));
|
|
1815
|
+
const m = v.manifest;
|
|
1816
|
+
raw["integrity"] = await computeIntegrity(root, m);
|
|
1817
|
+
if (opts.signKey) raw = signManifest(raw, (await fs8.readFile(opts.signKey, "utf8")).trim());
|
|
1818
|
+
const stage = await fs8.mkdtemp(path11.join(os.tmpdir(), "flux-pack-"));
|
|
1819
|
+
try {
|
|
1820
|
+
const shipped = await copyPluginRuntime(root, stage);
|
|
1821
|
+
await fs8.writeFile(path11.join(stage, "plugin.json"), JSON.stringify(raw, null, 2));
|
|
1822
|
+
const bad = shipped.filter((f) => /\.(map|tsx?)$/i.test(f) || f.startsWith("src/"));
|
|
1823
|
+
if (bad.length) throw new Error(`refusing to package sources/sourcemaps: ${bad.join(", ")}`);
|
|
1824
|
+
const buf = await createTarGz(stage);
|
|
1825
|
+
const out = path11.resolve(opts.out ?? ".");
|
|
1826
|
+
await fs8.mkdir(out, { recursive: true });
|
|
1827
|
+
const file = path11.join(out, `${m.id}-${m.version}.tgz`);
|
|
1828
|
+
await fs8.writeFile(file, buf);
|
|
1829
|
+
const sha256 = crypto4.createHash("sha256").update(buf).digest("hex");
|
|
1830
|
+
const descriptor = {
|
|
1831
|
+
id: m.id,
|
|
1832
|
+
name: m.name,
|
|
1833
|
+
version: m.version,
|
|
1834
|
+
description: m.description,
|
|
1835
|
+
author: typeof m.author === "string" ? m.author : m.author?.name,
|
|
1836
|
+
permissions: m.permissions ?? [],
|
|
1837
|
+
file: path11.basename(file),
|
|
1838
|
+
sha256
|
|
1839
|
+
};
|
|
1840
|
+
const descriptorFile = path11.join(out, `${m.id}-${m.version}.fluxplugin.json`);
|
|
1841
|
+
await fs8.writeFile(descriptorFile, JSON.stringify(descriptor, null, 2));
|
|
1842
|
+
log2(`packaged ${path11.relative(process.cwd(), file) || file} (${shipped.length} files: ${shipped.filter((x) => x !== "plugin.json").slice(0, 6).join(", ")}${shipped.length > 7 ? ", \u2026" : ""})
|
|
1843
|
+
sha256 ${sha256}${opts.signKey ? "\n signed" : ""}`);
|
|
1844
|
+
return { file, descriptorFile, sha256, manifest: m, shipped, signed: !!opts.signKey };
|
|
1845
|
+
} finally {
|
|
1846
|
+
await fs8.rm(stage, { recursive: true, force: true });
|
|
1317
1847
|
}
|
|
1318
|
-
return files;
|
|
1319
1848
|
}
|
|
1320
|
-
var renderer = (app) => `import React from "react";
|
|
1321
|
-
import { createRoot } from "react-dom/client";
|
|
1322
|
-
import * as jsxRuntime from "react/jsx-runtime";
|
|
1323
|
-
import * as ReactDOMClient from "react-dom/client";
|
|
1324
|
-
import * as flux from "fluxplugin";
|
|
1325
|
-
import * as fluxReact from "fluxplugin/react";
|
|
1326
|
-
import { s, type FluxBridge } from "fluxplugin";
|
|
1327
|
-
import {
|
|
1328
|
-
Breadcrumbs, CommandPalette, Link, PluginProvider, PluginRoutes, PluginSlot, RendererFramework, StyleHost,
|
|
1329
|
-
baseCss, createHttpBridge, installSharedModules, useExtensionItems, useKeybindings, useMenu,
|
|
1330
|
-
} from "fluxplugin/react";
|
|
1331
|
-
import { PluginManagerUI } from "fluxplugin/manager-ui";
|
|
1332
|
-
|
|
1333
|
-
// One copy of React and of the runtime, shared with plugin bundles (they import these from globalThis).
|
|
1334
|
-
installSharedModules({
|
|
1335
|
-
react: React, "react/jsx-runtime": jsxRuntime, "react/jsx-dev-runtime": jsxRuntime, "react-dom/client": ReactDOMClient,
|
|
1336
|
-
fluxplugin: flux, "fluxplugin/react": fluxReact,
|
|
1337
|
-
});
|
|
1338
1849
|
|
|
1339
|
-
//
|
|
1340
|
-
|
|
1341
|
-
|
|
1850
|
+
// src/cli/publish.ts
|
|
1851
|
+
import { promises as fs9 } from "node:fs";
|
|
1852
|
+
import os2 from "node:os";
|
|
1853
|
+
import path12 from "node:path";
|
|
1854
|
+
function parseGitHubRepo(input) {
|
|
1855
|
+
if (!input) return void 0;
|
|
1856
|
+
const m = /^(?:https?:\/\/github\.com\/|git@github\.com:|github:)?([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(input.trim());
|
|
1857
|
+
return m ? `${m[1]}/${m[2]}` : void 0;
|
|
1858
|
+
}
|
|
1859
|
+
async function publishPlugin(root, o = {}) {
|
|
1860
|
+
const log2 = o.log ?? console.log;
|
|
1861
|
+
const manifest = JSON.parse(await fs9.readFile(path12.join(root, "plugin.json"), "utf8"));
|
|
1862
|
+
const repo = parseGitHubRepo(o.repo) ?? parseGitHubRepo(typeof manifest["repository"] === "string" ? manifest["repository"] : manifest["repository"]?.url);
|
|
1863
|
+
if (!repo || /YOUR_NAME/i.test(JSON.stringify(manifest["repository"] ?? ""))) {
|
|
1864
|
+
throw new Error('no GitHub repository: set "repository": "https://github.com/<you>/<repo>" in plugin.json (or pass --repo owner/name)');
|
|
1865
|
+
}
|
|
1866
|
+
const problems = [];
|
|
1867
|
+
if (!manifest["description"]) problems.push("description is missing");
|
|
1868
|
+
if (!manifest["icon"]) problems.push('"icon" is missing');
|
|
1869
|
+
if (!manifest["license"]) problems.push('"license" is missing');
|
|
1870
|
+
if (problems.length) throw new Error("not ready for the store: " + problems.join("; "));
|
|
1871
|
+
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`);
|
|
1872
|
+
const out = await fs9.mkdtemp(path12.join(os2.tmpdir(), "flux-publish-"));
|
|
1873
|
+
try {
|
|
1874
|
+
const pkg2 = await packagePlugin(root, { out, signKey: o.signKey, log: log2 });
|
|
1875
|
+
const tag = `v${pkg2.manifest.version}`;
|
|
1876
|
+
if (o.dryRun) {
|
|
1877
|
+
log2(`
|
|
1878
|
+
[dry run] would create release ${tag} in github.com/${repo} with:
|
|
1879
|
+
- ${path12.basename(pkg2.file)}
|
|
1880
|
+
- ${path12.basename(pkg2.descriptorFile)}
|
|
1881
|
+
and add the topic "${o.topic ?? DEFAULT_TOPIC}"`);
|
|
1882
|
+
return { repo, version: pkg2.manifest.version, id: pkg2.manifest.id };
|
|
1883
|
+
}
|
|
1884
|
+
const token = o.token ?? process.env["GITHUB_TOKEN"] ?? process.env["GH_TOKEN"];
|
|
1885
|
+
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");
|
|
1886
|
+
const api = (o.apiBase ?? "https://api.github.com").replace(/\/$/, "");
|
|
1887
|
+
const f = o.fetchImpl ?? fetch;
|
|
1888
|
+
const gh = async (method, url, body, extra = {}, raw) => {
|
|
1889
|
+
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) });
|
|
1890
|
+
let json = null;
|
|
1891
|
+
try {
|
|
1892
|
+
json = await res.json();
|
|
1893
|
+
} catch {
|
|
1894
|
+
}
|
|
1895
|
+
return { status: res.status, json };
|
|
1896
|
+
};
|
|
1897
|
+
const info = await gh("GET", `${api}/repos/${repo}`);
|
|
1898
|
+
if (info.status === 404) throw new Error(`repository github.com/${repo} was not found (or your token cannot see it)`);
|
|
1899
|
+
if (info.status === 401) throw new Error("GitHub rejected the token (401)");
|
|
1900
|
+
if (info.json?.permissions && !info.json.permissions.push) throw new Error(`your token cannot write to ${repo}`);
|
|
1901
|
+
const notes = `${pkg2.manifest.name} ${pkg2.manifest.version}
|
|
1342
1902
|
|
|
1343
|
-
|
|
1344
|
-
framework.manager.extensionPoints.declare({
|
|
1345
|
-
name: "dashboard",
|
|
1346
|
-
contributions: { toolbar: s.object({ id: s.string(), label: s.string(), command: s.string().optional() }) },
|
|
1347
|
-
});
|
|
1903
|
+
${pkg2.manifest.description ?? ""}
|
|
1348
1904
|
|
|
1349
|
-
|
|
1350
|
-
body{background:var(--flux-bg);color:var(--flux-fg)}
|
|
1351
|
-
.app{display:grid;grid-template-columns:220px 1fr;min-height:100vh}
|
|
1352
|
-
.side{border-right:1px solid var(--flux-border);padding:16px;background:var(--flux-surface)}
|
|
1353
|
-
.side a{display:block;padding:7px 10px;border-radius:8px;color:inherit;text-decoration:none;margin:2px 0}
|
|
1354
|
-
.side a[data-active]{background:var(--flux-accent);color:#fff}.side h1{font-size:16px;margin:0 0 14px}
|
|
1355
|
-
.main{padding:24px;max-width:860px}.row{display:flex;gap:8px;margin:12px 0}
|
|
1356
|
-
.btn{border:1px solid var(--flux-border);background:var(--flux-bg);color:inherit;border-radius:8px;padding:6px 12px;cursor:pointer}
|
|
1357
|
-
.hint{color:var(--flux-muted);font-size:12px}\`;
|
|
1358
|
-
|
|
1359
|
-
function Dashboard() {
|
|
1360
|
-
const tools = useExtensionItems<{ id: string; label: string; command?: string }>("dashboard", "toolbar");
|
|
1361
|
-
const [out, setOut] = React.useState("");
|
|
1362
|
-
const create = async (name: string) => {
|
|
1363
|
-
try { setOut(JSON.stringify(await bridge.invoke("host.item.create", { name }))); }
|
|
1364
|
-
catch (e) { setOut("blocked: " + (e as Error).message); }
|
|
1365
|
-
};
|
|
1366
|
-
return (
|
|
1367
|
-
<div>
|
|
1368
|
-
<h2>Dashboard</h2>
|
|
1369
|
-
<PluginSlot name="dashboard.before" />
|
|
1370
|
-
<p className="hint">These buttons are contributed by plugins through a host-declared extension point.</p>
|
|
1371
|
-
<div className="row" data-testid="toolbar">
|
|
1372
|
-
{tools.map((t) => <button key={t.id} className="btn" onClick={() => t.command && void framework.manager.commands.execute(t.command)}>{t.label}</button>)}
|
|
1373
|
-
</div>
|
|
1374
|
-
<div className="row">
|
|
1375
|
-
<button className="btn" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>
|
|
1376
|
-
<button className="btn" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>
|
|
1377
|
-
</div>
|
|
1378
|
-
<pre data-testid="hook-result" className="hint">{out}</pre>
|
|
1379
|
-
<PluginSlot name="dashboard.after" />
|
|
1380
|
-
</div>
|
|
1381
|
-
);
|
|
1382
|
-
}
|
|
1905
|
+
Permissions: ${(pkg2.manifest.permissions ?? []).join(", ") || "none"}
|
|
1383
1906
|
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1907
|
+
Install: \`fluxplugin install ${pkg2.manifest.id}\` or from the Plugins page of any app using the fluxplugin store.`;
|
|
1908
|
+
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) });
|
|
1909
|
+
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.`);
|
|
1910
|
+
if (rel.status >= 300) throw new Error(`could not create the release (${rel.status}): ${rel.json?.message ?? "unknown error"}`);
|
|
1911
|
+
const upload = String(rel.json.upload_url).replace(/\{\?[^}]*\}$/, "");
|
|
1912
|
+
for (const [file, type] of [[pkg2.file, "application/gzip"], [pkg2.descriptorFile, "application/json"]]) {
|
|
1913
|
+
const up = await gh("POST", `${upload}?name=${encodeURIComponent(path12.basename(file))}`, void 0, { "content-type": type }, new Uint8Array(await fs9.readFile(file)));
|
|
1914
|
+
if (up.status >= 300) throw new Error(`upload of ${path12.basename(file)} failed (${up.status}): ${up.json?.message ?? ""}`);
|
|
1915
|
+
}
|
|
1916
|
+
const topics = await gh("GET", `${api}/repos/${repo}/topics`);
|
|
1917
|
+
const names = topics.json?.names ?? [];
|
|
1918
|
+
const topic = o.topic ?? DEFAULT_TOPIC;
|
|
1919
|
+
if (!names.includes(topic)) {
|
|
1920
|
+
const put = await gh("PUT", `${api}/repos/${repo}/topics`, { names: [...names, topic] });
|
|
1921
|
+
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.`);
|
|
1922
|
+
}
|
|
1923
|
+
log2(`
|
|
1924
|
+
\u2714 published ${pkg2.manifest.id}@${pkg2.manifest.version}
|
|
1925
|
+
release: ${rel.json.html_url ?? `https://github.com/${repo}/releases/tag/${tag}`}
|
|
1926
|
+
users install it with: fluxplugin install ${pkg2.manifest.id}
|
|
1927
|
+
(new GitHub topics can take a few minutes to show up in search)`);
|
|
1928
|
+
return { release: rel.json.html_url, repo, version: pkg2.manifest.version, id: pkg2.manifest.id };
|
|
1929
|
+
} finally {
|
|
1930
|
+
await fs9.rm(out, { recursive: true, force: true });
|
|
1931
|
+
}
|
|
1400
1932
|
}
|
|
1401
1933
|
|
|
1402
|
-
|
|
1403
|
-
|
|
1934
|
+
// src/cli/store.ts
|
|
1935
|
+
import crypto5 from "node:crypto";
|
|
1936
|
+
import { promises as fs10 } from "node:fs";
|
|
1937
|
+
import path13 from "node:path";
|
|
1938
|
+
var INDEX = "index.json";
|
|
1939
|
+
async function storeInit(dir) {
|
|
1940
|
+
await fs10.mkdir(dir, { recursive: true });
|
|
1941
|
+
const idx = path13.join(dir, INDEX);
|
|
1942
|
+
if (await fs10.stat(idx).catch(() => null)) throw new Error(`${idx} already exists`);
|
|
1943
|
+
await fs10.writeFile(idx, JSON.stringify({ plugins: [] }, null, 2));
|
|
1944
|
+
await fs10.writeFile(path13.join(dir, "README.md"), `# My plugin store
|
|
1404
1945
|
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1946
|
+
A store is a folder with \`index.json\` and the plugin packages (\`.tgz\`).
|
|
1947
|
+
|
|
1948
|
+
\`\`\`bash
|
|
1949
|
+
fluxplugin package ./my-plugin --out . # build a package
|
|
1950
|
+
fluxplugin store add com.acme.my-plugin-1.0.0.tgz --dir . --base-url https://plugins.acme.com/
|
|
1951
|
+
\`\`\`
|
|
1952
|
+
|
|
1953
|
+
Host this folder anywhere that serves static files:
|
|
1954
|
+
- **GitHub Pages** (or a repo's raw files): add \`{ "type": "github", "repo": "acme/plugin-store" }\` to your app's stores
|
|
1955
|
+
- **Any web server / S3 / CDN**: add \`{ "type": "http", "url": "https://plugins.acme.com/" }\`
|
|
1956
|
+
- **A shared folder**: \`{ "type": "local", "path": "./my-store" }\`
|
|
1957
|
+
`);
|
|
1958
|
+
}
|
|
1959
|
+
async function storeAdd(pkgFile, dir, baseUrl) {
|
|
1960
|
+
const buf = await fs10.readFile(pkgFile);
|
|
1961
|
+
const entries = await readTarGz(buf);
|
|
1962
|
+
const pj = entries.find((e) => e.type === "file" && /^[^/]+\/plugin\.json$/.test(e.name));
|
|
1963
|
+
if (!pj) throw new Error("not a fluxplugin package (no plugin.json)");
|
|
1964
|
+
const v = validateManifest(JSON.parse(pj.data.toString("utf8")));
|
|
1965
|
+
if (!v.manifest) throw new Error("invalid plugin.json in the package: " + v.issues.map((i) => i.message).join("; "));
|
|
1966
|
+
const bad = entries.filter((e) => e.type === "file" && /\.(map|tsx?)$/i.test(e.name));
|
|
1967
|
+
if (bad.length) throw new Error(`the package contains sources/sourcemaps (${bad[0].name}); rebuild it with "fluxplugin package"`);
|
|
1968
|
+
const m = v.manifest;
|
|
1969
|
+
const sha256 = crypto5.createHash("sha256").update(buf).digest("hex");
|
|
1970
|
+
await fs10.mkdir(dir, { recursive: true });
|
|
1971
|
+
const fileName = path13.basename(pkgFile);
|
|
1972
|
+
if (path13.resolve(pkgFile) !== path13.resolve(dir, fileName)) await fs10.copyFile(pkgFile, path13.join(dir, fileName));
|
|
1973
|
+
const idxFile = path13.join(dir, INDEX);
|
|
1974
|
+
const idx = JSON.parse(await fs10.readFile(idxFile, "utf8").catch(() => '{"plugins":[]}'));
|
|
1975
|
+
const url = baseUrl ? new URL(fileName, baseUrl.endsWith("/") ? baseUrl : baseUrl + "/").href : fileName;
|
|
1976
|
+
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 };
|
|
1977
|
+
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 }));
|
|
1978
|
+
await fs10.writeFile(idxFile, JSON.stringify(idx, null, 2));
|
|
1979
|
+
return { id: m.id, version: m.version, sha256 };
|
|
1980
|
+
}
|
|
1411
1981
|
|
|
1412
1982
|
// src/cli/cli.ts
|
|
1413
1983
|
var HELP = `fluxplugin <command>
|
|
1414
1984
|
|
|
1415
|
-
init [dir]
|
|
1985
|
+
init [dir] [--tailwindcss] create a new app (Electron + React) that supports plugins
|
|
1986
|
+
doctor check Node, Electron, esbuild, Tailwind and fix what it can
|
|
1416
1987
|
dev [--web] run your app with plugin hot reload (Electron, or a browser with --web)
|
|
1417
1988
|
build bundle your app (in an app folder) or a plugin (in a plugin folder)
|
|
1418
1989
|
start [--web] run the built app
|
|
1419
|
-
plugin new <name> [-t basic|dashboard|backend] scaffold a plugin (alias: create)
|
|
1990
|
+
plugin new <name> [-t basic|dashboard|backend] [--tailwindcss] scaffold a plugin (alias: create)
|
|
1420
1991
|
validate [dir] check a plugin manifest, entries and integrity
|
|
1992
|
+
publish [dir] [--repo o/r] [--dry-run] publish a plugin to the store (GitHub release + topic)
|
|
1993
|
+
search [query] search the configured stores
|
|
1994
|
+
install <id[@version]|dir|.tgz> install a plugin from a store, a folder or a package
|
|
1995
|
+
store init <dir> create your own store (index.json) \u2014 host it anywhere
|
|
1996
|
+
store add <pkg.tgz> [--dir d] [--base-url url] add a package to your store
|
|
1421
1997
|
package [dir] [--out d] [--sign-key file] build + integrity (+ signature) + .tgz
|
|
1422
1998
|
install <dir|.tgz> [--dir plugins] uninstall <id> enable <id> disable <id> list
|
|
1423
1999
|
keygen [--out name] create an ed25519 signing key pair
|
|
1424
2000
|
tailwind [--dir plugins] [--out file] generate @source lines for Tailwind v4`;
|
|
1425
2001
|
var log = (...a) => console.log(...a);
|
|
1426
|
-
async function
|
|
1427
|
-
return JSON.parse(await
|
|
2002
|
+
async function readManifest(root) {
|
|
2003
|
+
return JSON.parse(await fs11.readFile(path14.join(root, "plugin.json"), "utf8"));
|
|
1428
2004
|
}
|
|
1429
2005
|
async function run(argv) {
|
|
1430
2006
|
const { values: f, positionals: pos } = parseArgs({
|
|
1431
2007
|
args: argv,
|
|
1432
2008
|
allowPositionals: true,
|
|
1433
|
-
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" } }
|
|
2009
|
+
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" } }
|
|
1434
2010
|
});
|
|
1435
2011
|
let [cmd, arg] = pos;
|
|
2012
|
+
if (cmd === "store") {
|
|
2013
|
+
cmd = "store:" + (pos[1] ?? "");
|
|
2014
|
+
arg = pos[2];
|
|
2015
|
+
}
|
|
1436
2016
|
if (cmd === "plugin") {
|
|
1437
2017
|
const sub = pos[1];
|
|
1438
2018
|
if (sub !== "new") throw new Error("usage: fluxplugin plugin new <name>");
|
|
@@ -1440,20 +2020,20 @@ async function run(argv) {
|
|
|
1440
2020
|
arg = pos[2];
|
|
1441
2021
|
}
|
|
1442
2022
|
if (!cmd || f.help) return log(HELP), cmd ? 0 : 1;
|
|
1443
|
-
const root =
|
|
2023
|
+
const root = path14.resolve(arg ?? ".");
|
|
1444
2024
|
try {
|
|
1445
2025
|
switch (cmd) {
|
|
1446
2026
|
case "init": {
|
|
1447
|
-
const target =
|
|
1448
|
-
const entries = await
|
|
2027
|
+
const target = path14.resolve(arg ?? ".");
|
|
2028
|
+
const entries = await fs11.readdir(target).catch(() => []);
|
|
1449
2029
|
if (entries.length) throw new Error(`${target} is not empty`);
|
|
1450
|
-
const name = (f.name ??
|
|
1451
|
-
const files = hostFiles({ name, fluxSpec: process.env["FLUXPLUGIN_SPEC"] ?? `^${await ownVersion()}
|
|
2030
|
+
const name = (f.name ?? path14.basename(target)).toLowerCase().replace(/[^a-z0-9-_.]+/g, "-");
|
|
2031
|
+
const files = hostFiles({ name, fluxSpec: process.env["FLUXPLUGIN_SPEC"] ?? `^${await ownVersion()}`, electron: await latestElectron(), tailwind: !!f.tailwindcss }, !f["no-sample"]);
|
|
1452
2032
|
for (const [rel2, content] of Object.entries(files)) {
|
|
1453
|
-
await
|
|
1454
|
-
await
|
|
2033
|
+
await fs11.mkdir(path14.dirname(path14.join(target, rel2)), { recursive: true });
|
|
2034
|
+
await fs11.writeFile(path14.join(target, rel2), content);
|
|
1455
2035
|
}
|
|
1456
|
-
const rel =
|
|
2036
|
+
const rel = path14.relative(process.cwd(), target) || ".";
|
|
1457
2037
|
log(`\u2714 created ${name} in ${target}
|
|
1458
2038
|
|
|
1459
2039
|
Next steps:
|
|
@@ -1469,15 +2049,17 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
|
|
|
1469
2049
|
if (!arg) throw new Error("usage: fluxplugin plugin new <name> [-t basic|dashboard|backend]");
|
|
1470
2050
|
const tpl = f.template ?? "basic";
|
|
1471
2051
|
if (!(tpl in TEMPLATES)) throw new Error(`unknown template "${tpl}"`);
|
|
1472
|
-
const base = await isHostProject(process.cwd()) && !
|
|
1473
|
-
if (await
|
|
1474
|
-
const
|
|
2052
|
+
const base = await isHostProject(process.cwd()) && !path14.isAbsolute(arg) && !arg.includes("/") ? path14.join((await loadConfig({}, process.cwd())).pluginsDir, arg) : path14.resolve(arg);
|
|
2053
|
+
if (await fs11.stat(base).catch(() => null)) throw new Error(`${base} already exists`);
|
|
2054
|
+
const vars = varsFromName(path14.basename(base));
|
|
2055
|
+
let files = TEMPLATES[tpl](vars);
|
|
2056
|
+
if (f.tailwindcss) files = withTailwind(files, vars);
|
|
1475
2057
|
for (const [rel, content] of Object.entries(files)) {
|
|
1476
|
-
await
|
|
1477
|
-
await
|
|
2058
|
+
await fs11.mkdir(path14.dirname(path14.join(base, rel)), { recursive: true });
|
|
2059
|
+
await fs11.writeFile(path14.join(base, rel), content);
|
|
1478
2060
|
}
|
|
1479
2061
|
log(`\u2714 created ${tpl} plugin in ${base}
|
|
1480
|
-
next: fluxplugin build ${
|
|
2062
|
+
next: fluxplugin build ${path14.relative(process.cwd(), base)}`);
|
|
1481
2063
|
return 0;
|
|
1482
2064
|
}
|
|
1483
2065
|
case "build": {
|
|
@@ -1497,6 +2079,7 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
|
|
|
1497
2079
|
}
|
|
1498
2080
|
const dispose = await buildHost(cwd, { watch: true });
|
|
1499
2081
|
const { child, mode } = startApp(cwd, { web: f.web, dev: true, port: f.port ? Number(f.port) : void 0 });
|
|
2082
|
+
child.on("error", (e) => console.error(`error: ${e.message}`));
|
|
1500
2083
|
log(`running in ${mode} mode \u2014 edit a plugin and it reloads; Ctrl+C to stop`);
|
|
1501
2084
|
const stop = () => {
|
|
1502
2085
|
child.kill();
|
|
@@ -1508,30 +2091,57 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
|
|
|
1508
2091
|
}
|
|
1509
2092
|
case "start": {
|
|
1510
2093
|
const cwd = process.cwd();
|
|
1511
|
-
if (!await
|
|
2094
|
+
if (!await fs11.stat(path14.join(cwd, "dist/index.html")).catch(() => null)) throw new Error("nothing built yet: run `fluxplugin build` first");
|
|
1512
2095
|
const { child } = startApp(cwd, { web: f.web, port: f.port ? Number(f.port) : void 0 });
|
|
1513
2096
|
return await new Promise((r) => child.on("exit", (c) => r(c ?? 0)));
|
|
1514
2097
|
}
|
|
2098
|
+
case "doctor":
|
|
2099
|
+
return await doctor(process.cwd());
|
|
1515
2100
|
case "validate":
|
|
1516
|
-
return validate(root);
|
|
2101
|
+
return await validate(root);
|
|
1517
2102
|
case "package":
|
|
1518
|
-
return
|
|
2103
|
+
return await packagePlugin(root, { out: f.out, signKey: f["sign-key"] }), 0;
|
|
2104
|
+
case "publish":
|
|
2105
|
+
await publishPlugin(root, { repo: f.repo, token: f.token, signKey: f["sign-key"], dryRun: f["dry-run"] });
|
|
2106
|
+
return 0;
|
|
2107
|
+
case "search": {
|
|
2108
|
+
const cfg = await loadConfig(f);
|
|
2109
|
+
const regs = createRegistries(cfg.stores);
|
|
2110
|
+
const res = await Promise.allSettled(regs.map((r) => r.search(arg)));
|
|
2111
|
+
let n = 0;
|
|
2112
|
+
res.forEach((r, i) => {
|
|
2113
|
+
if (r.status === "rejected") return void log(`! ${regs[i].name}: ${r.reason.message}`);
|
|
2114
|
+
for (const e of r.value) n++, log(`${e.id}@${e.version} ${e.name}${e.description ? " \u2014 " + e.description : ""} [${e.source}]`);
|
|
2115
|
+
});
|
|
2116
|
+
if (!n) log("no plugins found");
|
|
2117
|
+
return 0;
|
|
2118
|
+
}
|
|
2119
|
+
case "store:init":
|
|
2120
|
+
await storeInit(path14.resolve(arg ?? "store"));
|
|
2121
|
+
log(`\u2714 store created in ${path14.resolve(arg ?? "store")} (see its README.md)`);
|
|
2122
|
+
return 0;
|
|
2123
|
+
case "store:add": {
|
|
2124
|
+
if (!arg) throw new Error("usage: store add <package.tgz> [--dir store] [--base-url https://\u2026]");
|
|
2125
|
+
const r = await storeAdd(path14.resolve(arg), path14.resolve(f.dir ?? "store"), f["base-url"]);
|
|
2126
|
+
log(`\u2714 added ${r.id}@${r.version} to ${path14.resolve(f.dir ?? "store")}/index.json`);
|
|
2127
|
+
return 0;
|
|
2128
|
+
}
|
|
1519
2129
|
case "keygen": {
|
|
1520
2130
|
const k = generateSigningKeyPair();
|
|
1521
2131
|
const base = f.out ?? "flux-signing";
|
|
1522
|
-
await
|
|
1523
|
-
await
|
|
2132
|
+
await fs11.writeFile(`${base}.private.key`, k.privateKey, { mode: 384 });
|
|
2133
|
+
await fs11.writeFile(`${base}.public.key`, k.publicKey);
|
|
1524
2134
|
log(`wrote ${base}.private.key (keep secret) and ${base}.public.key (distribute to hosts as a trusted key)`);
|
|
1525
2135
|
return 0;
|
|
1526
2136
|
}
|
|
1527
2137
|
case "tailwind": {
|
|
1528
2138
|
const cfg = await loadConfig(f);
|
|
1529
|
-
const names = (await
|
|
1530
|
-
const lines = names.map((n) => `@source "${
|
|
2139
|
+
const names = (await fs11.readdir(cfg.pluginsDir).catch(() => [])).sort();
|
|
2140
|
+
const lines = names.map((n) => `@source "${path14.join(cfg.pluginsDir, n, "src").replace(/\\/g, "/")}";`);
|
|
1531
2141
|
const css = `/* generated by fluxplugin tailwind */
|
|
1532
2142
|
${lines.join("\n")}
|
|
1533
2143
|
`;
|
|
1534
|
-
if (f.out) await
|
|
2144
|
+
if (f.out) await fs11.writeFile(f.out, css);
|
|
1535
2145
|
else log(css);
|
|
1536
2146
|
return 0;
|
|
1537
2147
|
}
|
|
@@ -1540,7 +2150,7 @@ ${lines.join("\n")}
|
|
|
1540
2150
|
case "enable":
|
|
1541
2151
|
case "disable":
|
|
1542
2152
|
case "list":
|
|
1543
|
-
return manage(cmd, arg, f);
|
|
2153
|
+
return await manage(cmd, arg, f);
|
|
1544
2154
|
default:
|
|
1545
2155
|
log(HELP);
|
|
1546
2156
|
return 1;
|
|
@@ -1551,14 +2161,14 @@ ${lines.join("\n")}
|
|
|
1551
2161
|
}
|
|
1552
2162
|
}
|
|
1553
2163
|
async function validate(root) {
|
|
1554
|
-
const raw = await
|
|
2164
|
+
const raw = await readManifest(root);
|
|
1555
2165
|
const v = validateManifest(raw);
|
|
1556
2166
|
let bad = !v.ok;
|
|
1557
2167
|
v.issues.forEach((i) => log(`\u2716 ${i.path || "<root>"}: ${i.message}`));
|
|
1558
2168
|
v.warnings.forEach((w) => log(`! ${w}`));
|
|
1559
2169
|
if (v.manifest) {
|
|
1560
2170
|
for (const rel of [v.manifest.main, v.manifest.renderer, ...v.manifest.styles ?? []].filter(Boolean))
|
|
1561
|
-
if (!await
|
|
2171
|
+
if (!await fs11.stat(path14.join(root, rel)).catch(() => null)) log(`\u2716 missing file ${rel} (run build)`), bad = true;
|
|
1562
2172
|
try {
|
|
1563
2173
|
await verifyPlugin(root, raw, v.manifest);
|
|
1564
2174
|
} catch (e) {
|
|
@@ -1569,47 +2179,38 @@ async function validate(root) {
|
|
|
1569
2179
|
log(bad ? "invalid" : `\u2714 ${v.manifest?.id}@${v.manifest?.version} is valid`);
|
|
1570
2180
|
return bad ? 1 : 0;
|
|
1571
2181
|
}
|
|
1572
|
-
async function pack(root, out = ".", keyFile) {
|
|
1573
|
-
await buildPlugin(root);
|
|
1574
|
-
let raw = await readManifest2(root);
|
|
1575
|
-
const m = validateManifest(raw).manifest;
|
|
1576
|
-
if (!m) throw new Error("invalid manifest");
|
|
1577
|
-
raw["integrity"] = await computeIntegrity(root, m);
|
|
1578
|
-
if (keyFile) raw = signManifest(raw, (await fs8.readFile(keyFile, "utf8")).trim());
|
|
1579
|
-
const stage = await fs8.mkdtemp(path9.join((await import("node:os")).tmpdir(), "flux-pack-"));
|
|
1580
|
-
await copyDir(root, stage);
|
|
1581
|
-
await fs8.rm(path9.join(stage, "src"), { recursive: true, force: true });
|
|
1582
|
-
await fs8.writeFile(path9.join(stage, "plugin.json"), JSON.stringify(raw, null, 2));
|
|
1583
|
-
const buf = await createTarGz(stage);
|
|
1584
|
-
await fs8.rm(stage, { recursive: true, force: true });
|
|
1585
|
-
await fs8.mkdir(out, { recursive: true });
|
|
1586
|
-
const file = path9.join(out, `${m.id}-${m.version}.tgz`);
|
|
1587
|
-
await fs8.writeFile(file, buf);
|
|
1588
|
-
log(`packaged ${file}
|
|
1589
|
-
sha256 ${crypto2.createHash("sha256").update(buf).digest("hex")}${keyFile ? "\n signed" : ""}`);
|
|
1590
|
-
return 0;
|
|
1591
|
-
}
|
|
1592
2182
|
async function manage(cmd, arg, f) {
|
|
1593
2183
|
const cfg = await loadConfig(f);
|
|
1594
|
-
const state = new JsonStateFile(
|
|
1595
|
-
const inst = new PluginInstaller(cfg.pluginsDir,
|
|
2184
|
+
const state = new JsonStateFile(path14.join(cfg.dataDir, "plugins-state.json"));
|
|
2185
|
+
const inst = new PluginInstaller(cfg.pluginsDir, path14.join(cfg.dataDir, "backups"));
|
|
1596
2186
|
if (cmd === "list") {
|
|
1597
2187
|
const { disabled } = await state.readAll();
|
|
1598
2188
|
for (const m of await inst.installed()) log(`${disabled.includes(m.id) ? "\u25CB" : "\u25CF"} ${m.id}@${m.version} ${m.name}`);
|
|
1599
2189
|
return 0;
|
|
1600
2190
|
}
|
|
1601
2191
|
if (!arg) throw new Error(`usage: ${cmd} <${cmd === "install" ? "dir|.tgz" : "id"}>`);
|
|
2192
|
+
if (cmd === "install" && arg && !arg.endsWith(".tgz") && !await fs11.stat(arg).catch(() => null)) {
|
|
2193
|
+
const [id, version] = arg.split("@");
|
|
2194
|
+
const store = new PluginInstaller(cfg.pluginsDir, path14.join(cfg.dataDir, "backups"), createRegistries(cfg.stores));
|
|
2195
|
+
const found = (await store.search(id)).filter((e) => e.id === id && (!version || e.version === version));
|
|
2196
|
+
if (!found.length) throw new Error(`"${arg}" was not found in your stores (${cfg.stores.map((s2) => s2.type).join(", ")}). Try: fluxplugin search ${id}`);
|
|
2197
|
+
const best = found.sort((a, b) => b.version.localeCompare(a.version, void 0, { numeric: true }))[0];
|
|
2198
|
+
const r = await store.install(best);
|
|
2199
|
+
log(`\u2714 installed ${r.id}@${r.version} from ${best.source}${r.previousVersion ? ` (was ${r.previousVersion}; rollback available)` : ""}
|
|
2200
|
+
permissions it asks for: ${best.permissions.join(", ") || "none"}`);
|
|
2201
|
+
return 0;
|
|
2202
|
+
}
|
|
1602
2203
|
if (cmd === "install") {
|
|
1603
|
-
const tmp =
|
|
1604
|
-
if (arg.endsWith(".tgz")) await extractTarGz(await
|
|
1605
|
-
else await copyDir(
|
|
1606
|
-
const v = validateManifest(await
|
|
2204
|
+
const tmp = path14.join(cfg.dataDir, ".install", String(Date.now()));
|
|
2205
|
+
if (arg.endsWith(".tgz")) await extractTarGz(await fs11.readFile(arg), tmp);
|
|
2206
|
+
else await copyDir(path14.resolve(arg), tmp);
|
|
2207
|
+
const v = validateManifest(await readManifest(tmp));
|
|
1607
2208
|
if (!v.manifest) throw new Error("invalid plugin: " + v.issues.map((i) => i.message).join("; "));
|
|
1608
|
-
const reg = new LocalRegistry(
|
|
2209
|
+
const reg = new LocalRegistry(path14.dirname(tmp), "cli");
|
|
1609
2210
|
inst.addRegistry(reg);
|
|
1610
2211
|
const [entry] = (await reg.search(v.manifest.id)).filter((e) => e.id === v.manifest.id);
|
|
1611
2212
|
const r = await inst.install({ ...entry, location: tmp });
|
|
1612
|
-
await
|
|
2213
|
+
await fs11.rm(tmp, { recursive: true, force: true });
|
|
1613
2214
|
log(`installed ${r.id}@${r.version}${r.previousVersion ? ` (was ${r.previousVersion}; rollback available)` : ""}`);
|
|
1614
2215
|
} else if (cmd === "uninstall") {
|
|
1615
2216
|
await inst.uninstall(arg);
|
|
@@ -1623,13 +2224,49 @@ async function manage(cmd, arg, f) {
|
|
|
1623
2224
|
async function ownVersion() {
|
|
1624
2225
|
for (const rel of ["../package.json", "../../package.json"]) {
|
|
1625
2226
|
try {
|
|
1626
|
-
const j = JSON.parse(await
|
|
2227
|
+
const j = JSON.parse(await fs11.readFile(new URL(rel, import.meta.url), "utf8"));
|
|
1627
2228
|
if (j.name === "fluxplugin" && j.version) return j.version;
|
|
1628
2229
|
} catch {
|
|
1629
2230
|
}
|
|
1630
2231
|
}
|
|
1631
2232
|
return "1.0.0";
|
|
1632
2233
|
}
|
|
2234
|
+
async function latestElectron() {
|
|
2235
|
+
try {
|
|
2236
|
+
const r = await fetch("https://registry.npmjs.org/electron/latest", { signal: AbortSignal.timeout(4e3) });
|
|
2237
|
+
const j = await r.json();
|
|
2238
|
+
if (j.version && /^\d+\.\d+\.\d+$/.test(j.version)) return `^${j.version}`;
|
|
2239
|
+
} catch {
|
|
2240
|
+
}
|
|
2241
|
+
return "^44.5.1";
|
|
2242
|
+
}
|
|
2243
|
+
async function doctor(cwd) {
|
|
2244
|
+
let bad = false;
|
|
2245
|
+
const ok2 = (m) => log(`\u2714 ${m}`);
|
|
2246
|
+
const ko = (m) => (log(`\u2716 ${m}`), bad = true);
|
|
2247
|
+
Number(process.versions.node.split(".")[0]) >= 20 ? ok2(`Node ${process.versions.node}`) : ko(`Node ${process.versions.node} (need >= 20)`);
|
|
2248
|
+
if (!await isHostProject(cwd)) {
|
|
2249
|
+
log("! not inside a fluxplugin app folder (run this where package.json and src/app are)");
|
|
2250
|
+
return bad ? 1 : 0;
|
|
2251
|
+
}
|
|
2252
|
+
const e = ensureElectron(cwd, log);
|
|
2253
|
+
e.bin ? ok2(`Electron binary: ${path14.relative(cwd, e.bin) || e.bin}`) : ko(`Electron: ${e.error}
|
|
2254
|
+
fix: npm install -D electron`);
|
|
2255
|
+
try {
|
|
2256
|
+
(await import("esbuild")).version ? ok2("esbuild") : ko("esbuild");
|
|
2257
|
+
} catch {
|
|
2258
|
+
ko("esbuild is not available");
|
|
2259
|
+
}
|
|
2260
|
+
const hasTw = await fs11.stat(path14.join(cwd, "src/app/renderer/styles/tailwind.css")).then(() => true, () => false);
|
|
2261
|
+
if (hasTw) {
|
|
2262
|
+
const { resolveTailwindCli: resolveTailwindCli2 } = await Promise.resolve().then(() => (init_tailwind(), tailwind_exports));
|
|
2263
|
+
resolveTailwindCli2([cwd]) ? ok2("Tailwind CLI") : ko("Tailwind CLI missing: npm install -D tailwindcss @tailwindcss/cli");
|
|
2264
|
+
}
|
|
2265
|
+
const pkg2 = JSON.parse(await fs11.readFile(path14.join(cwd, "package.json"), "utf8").catch(() => "{}"));
|
|
2266
|
+
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).`);
|
|
2267
|
+
log(bad ? "\nSome checks failed." : "\nAll good.");
|
|
2268
|
+
return bad ? 1 : 0;
|
|
2269
|
+
}
|
|
1633
2270
|
|
|
1634
2271
|
// src/cli/bin.ts
|
|
1635
2272
|
process.exit(await run(process.argv.slice(2)));
|