fluxplugin 1.0.1 → 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.
Files changed (62) hide show
  1. package/README.md +22 -9
  2. package/dist/{node/chunks/chunk-XPLGAEBH.js → browser/chunks/chunk-TL63576R.js} +106 -81
  3. package/dist/browser/chunks/{chunk-ZZ25CRMR.js → chunk-VXQJIDXW.js} +479 -6
  4. package/dist/browser/index.js +5 -1
  5. package/dist/browser/manager-ui.js +93 -18
  6. package/dist/browser/react.js +34 -4
  7. package/dist/cli.js +869 -174
  8. package/dist/node/chunks/chunk-3FH6NAJG.js +38 -0
  9. package/dist/node/chunks/{chunk-BZ7NZF7I.js → chunk-C7VWZZOX.js} +259 -44
  10. package/dist/{browser/chunks/chunk-Q75AOCXD.js → node/chunks/chunk-EVX2O65A.js} +185 -2
  11. package/dist/node/chunks/chunk-WSFCRVEQ.js +9 -0
  12. package/dist/node/chunks/tailwind-TJQVTBG5.js +9 -0
  13. package/dist/node/cli-api.js +562 -129
  14. package/dist/node/index.js +6 -1
  15. package/dist/node/main.js +19 -2
  16. package/dist/node/preload.js +2 -1
  17. package/dist/types/cli/build.d.ts +1 -1
  18. package/dist/types/cli/config.d.ts +3 -0
  19. package/dist/types/cli/host-files.generated.d.ts +1 -0
  20. package/dist/types/cli/host-templates.d.ts +3 -0
  21. package/dist/types/cli/host.d.ts +8 -0
  22. package/dist/types/cli/package.d.ts +18 -0
  23. package/dist/types/cli/publish.d.ts +23 -0
  24. package/dist/types/cli/store.d.ts +7 -0
  25. package/dist/types/cli/tailwind.d.ts +10 -0
  26. package/dist/types/cli/templates.d.ts +2 -0
  27. package/dist/types/core/index.d.ts +1 -0
  28. package/dist/types/core/manager.d.ts +2 -0
  29. package/dist/types/core/manifest.d.ts +9 -0
  30. package/dist/types/core/notifications.d.ts +48 -0
  31. package/dist/types/core/plugin.d.ts +7 -0
  32. package/dist/types/core/protocol.d.ts +27 -0
  33. package/dist/types/main/framework.d.ts +7 -1
  34. package/dist/types/main/github-registry.d.ts +49 -0
  35. package/dist/types/main/index.d.ts +2 -0
  36. package/dist/types/main/registry.d.ts +30 -0
  37. package/dist/types/main/stores.d.ts +29 -0
  38. package/dist/types/react/base-css.d.ts +1 -1
  39. package/dist/types/react/icons.d.ts +12 -0
  40. package/dist/types/react/index.d.ts +5 -0
  41. package/dist/types/react/markdown.d.ts +8 -0
  42. package/dist/types/react/os.d.ts +5 -0
  43. package/dist/types/react/runtime.d.ts +2 -1
  44. package/dist/types/react/theme.d.ts +53 -0
  45. package/dist/types/react/titlebar.d.ts +28 -0
  46. package/docs/CUSTOMIZING.md +257 -0
  47. package/docs/PLUGIN-AUTHORS.md +36 -9
  48. package/docs/RELEASE.md +1 -1
  49. package/docs/SECURITY.md +5 -0
  50. package/docs/STORE.md +123 -0
  51. package/docs/TROUBLESHOOTING.md +35 -0
  52. package/docs/screenshots/command-palette.png +0 -0
  53. package/docs/screenshots/dashboard.png +0 -0
  54. package/docs/screenshots/electron-plugin-page.png +0 -0
  55. package/docs/screenshots/electron-titlebar.png +0 -0
  56. package/docs/screenshots/plugin-details.png +0 -0
  57. package/docs/screenshots/plugin-manager.png +0 -0
  58. package/docs/screenshots/plugin-video.png +0 -0
  59. package/docs/screenshots/stats.png +0 -0
  60. package/docs/screenshots/store-available.png +0 -0
  61. package/docs/screenshots/tailwind-dark.png +0 -0
  62. package/package.json +1 -1
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 fs8 } from "node:fs";
7
- import path9 from "node:path";
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 = (path10, message) => ({ ok: false, issues: [{ path: path10, message }] });
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, path10 = "") => parse(input, path10),
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 readManifest(dir) {
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 readManifest(root);
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 copyDir(entry.location, dest);
727
- return (await readManifest(dest)).manifest;
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 readManifest(path4.join(this.pluginsDir, n))).manifest);
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 readManifest(target)).manifest.version;
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 readManifest(dir);
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 readManifest(dir)).manifest.version;
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 path6 from "node:path";
1095
+ import path8 from "node:path";
827
1096
  import { build, context } from "esbuild";
828
1097
 
829
1098
  // src/cli/shared-modules.ts
830
- import path5 from "node:path";
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,85 +1112,99 @@ 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: path5.resolve(".")
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(path6.join(root, n))) return path6.join(root, n);
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(path6.join(root, "plugin.json"), "utf8"));
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 = { bundle: true, sourcemap: true, logLevel: "warning", plugins: [sharedModulesPlugin()], jsx: "automatic", loader: { ".module.css": "local-css" } };
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: path6.join(root, m.main), platform: "node", format: "esm", target: "node20", external: ["node:*", "electron"] });
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: path6.join(root, m.renderer), platform: "browser", format: "esm", target: "chrome120" });
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
- const entry = path6.join(root, "src/styles", path6.basename(css));
875
- if (await exists(entry)) out.push({ ...common, plugins: [], entryPoints: [entry], outfile: path6.join(root, css) });
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 { existsSync, promises as fs7, statSync } from "node:fs";
896
- import { createRequire } from "node:module";
897
- import path8 from "node:path";
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 path7 from "node:path";
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(path7.join(cwd, "fluxplugin.config.json"), "utf8"));
1188
+ file = JSON.parse(await fs6.readFile(path9.join(cwd, "fluxplugin.config.json"), "utf8"));
907
1189
  } catch {
908
1190
  }
909
1191
  return {
910
- pluginsDir: path7.resolve(cwd, flags.dir ?? file.pluginsDir ?? "plugins"),
911
- dataDir: path7.resolve(cwd, flags.data ?? file.dataDir ?? ".flux-data")
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
- if (await exists2(path8.join(cwd, "fluxplugin.config.json"))) return true;
919
- return await exists2(path8.join(cwd, "src/app/renderer/index.tsx")) || await exists2(path8.join(cwd, "src/renderer.tsx"));
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"));
920
1203
  }
921
1204
  var nodeBanner = { js: "import { createRequire as __r } from 'node:module'; const require = __r(import.meta.url);" };
922
1205
  var pick = (cwd, ...rels) => {
923
- for (const r of rels) if (existsSync(path8.join(cwd, r))) return path8.join(cwd, r);
924
- return path8.join(cwd, rels[0]);
1206
+ for (const r of rels) if (existsSync2(path10.join(cwd, r))) return path10.join(cwd, r);
1207
+ return path10.join(cwd, rels[0]);
925
1208
  };
926
1209
  function hostBuilds(cwd, outDir, dev) {
927
1210
  const common = { absWorkingDir: cwd, bundle: true, sourcemap: dev, logLevel: "warning", jsx: "automatic", define: { "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production") } };
@@ -930,27 +1213,32 @@ function hostBuilds(cwd, outDir, dev) {
930
1213
  const preload = pick(cwd, "src/app/preload/index.ts", "src/preload.ts");
931
1214
  const web = pick(cwd, "src/app/main/web.ts", "src/web.ts");
932
1215
  return [
933
- { ...common, entryPoints: [renderer], outfile: path8.join(outDir, "renderer.js"), platform: "browser", format: "esm", target: "chrome120" },
934
- { ...common, entryPoints: [main], outfile: path8.join(outDir, "main.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
935
- { ...common, entryPoints: [preload], outfile: path8.join(outDir, "preload.cjs"), platform: "node", format: "cjs", target: "node20", external: ["electron"] },
936
- { ...common, entryPoints: [web], outfile: path8.join(outDir, "web.mjs"), platform: "node", format: "esm", target: "node20", external: ["electron"], banner: nodeBanner }
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 }
937
1220
  ];
938
1221
  }
939
1222
  async function pluginDirs(cwd, pluginsDir) {
940
1223
  const names = await fs7.readdir(pluginsDir).catch(() => []);
941
1224
  const out = [];
942
- for (const n of names.sort()) if (await exists2(path8.join(pluginsDir, n, "plugin.json"))) out.push(path8.join(pluginsDir, n));
1225
+ for (const n of names.sort()) if (await exists2(path10.join(pluginsDir, n, "plugin.json"))) out.push(path10.join(pluginsDir, n));
943
1226
  void cwd;
944
1227
  return out;
945
1228
  }
946
1229
  async function buildHost(cwd, opts = {}) {
947
1230
  const log2 = opts.log ?? console.log;
948
- const cfgFile = JSON.parse(await fs7.readFile(path8.join(cwd, "fluxplugin.config.json"), "utf8").catch(() => "{}"));
1231
+ const cfgFile = JSON.parse(await fs7.readFile(path10.join(cwd, "fluxplugin.config.json"), "utf8").catch(() => "{}"));
949
1232
  const cfg = await loadConfig({}, cwd);
950
- const outDir = path8.resolve(cwd, cfgFile.outDir ?? "dist");
1233
+ const outDir = path10.resolve(cwd, cfgFile.outDir ?? "dist");
951
1234
  await fs7.mkdir(outDir, { recursive: true });
952
- await fs7.copyFile(pick(cwd, "src/app/renderer/index.html", "src/index.html"), path8.join(outDir, "index.html"));
1235
+ await fs7.copyFile(pick(cwd, "src/app/renderer/index.html", "src/index.html"), path10.join(outDir, "index.html"));
953
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
+ }
954
1242
  const opts2 = hostBuilds(cwd, outDir, !!opts.watch);
955
1243
  if (opts.watch) {
956
1244
  const ctxs = await Promise.all(opts2.map((o) => context2(o)));
@@ -962,26 +1250,42 @@ async function buildHost(cwd, opts = {}) {
962
1250
  try {
963
1251
  disposers.push(await buildPlugin(dir, !!opts.watch, log2));
964
1252
  } catch (e) {
965
- log2(`! plugin ${path8.basename(dir)} not built: ${e.message}`);
1253
+ log2(`! plugin ${path10.basename(dir)} not built: ${e.message}`);
966
1254
  }
967
1255
  }
968
- log2(`host built \u2192 ${path8.relative(cwd, outDir) || "."}`);
1256
+ log2(`host built \u2192 ${path10.relative(cwd, outDir) || "."}`);
969
1257
  return async () => void await Promise.all(disposers.map((d) => d()));
970
1258
  }
971
1259
  function electronBinary(cwd) {
972
1260
  try {
973
- const p = createRequire(path8.join(cwd, "package.json"))("electron");
974
- if (typeof p === "string" && existsSync(p)) return { bin: p };
1261
+ const p = createRequire2(path10.join(cwd, "package.json"))("electron");
1262
+ if (typeof p === "string" && existsSync2(p)) return { bin: p };
975
1263
  return { error: "the Electron binary was not found" };
976
1264
  } catch (e) {
977
1265
  return { error: e.message.split("\n")[0] };
978
1266
  }
979
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"));
1274
+ } catch {
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
+ }
980
1284
  function needsNoSandbox(bin) {
981
1285
  if (process.platform !== "linux") return false;
982
1286
  if (process.getuid?.() === 0) return true;
983
1287
  try {
984
- const st = statSync(path8.join(path8.dirname(bin), "chrome-sandbox"));
1288
+ const st = statSync(path10.join(path10.dirname(bin), "chrome-sandbox"));
985
1289
  return !(st.uid === 0 && (st.mode & 2048) !== 0);
986
1290
  } catch {
987
1291
  return false;
@@ -990,18 +1294,18 @@ function needsNoSandbox(bin) {
990
1294
  function startApp(cwd, opts = {}) {
991
1295
  const log2 = opts.log ?? console.log;
992
1296
  const env = { ...process.env, FLUXPLUGIN_PROJECT: cwd, ...opts.dev ? { FLUXPLUGIN_DEV: "1" } : {}, ...opts.port ? { PORT: String(opts.port) } : {} };
993
- if (opts.web) return { child: spawn(process.execPath, [path8.join(cwd, "dist/web.mjs")], { cwd, env, stdio: "inherit" }), mode: "web" };
994
- const { bin, error } = electronBinary(cwd);
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);
995
1299
  if (!bin) throw new Error(`cannot start Electron: ${error}.
996
- Install it with: npm install -D electron (then re-run)
997
- or run in a browser: fluxplugin ${opts.dev ? "dev" : "start"} --web`);
998
- const args = [...process.env["FLUXPLUGIN_ELECTRON_ARGS"]?.split(" ").filter(Boolean) ?? [], path8.join(cwd, "dist/main.cjs")];
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")];
999
1303
  if (needsNoSandbox(bin)) {
1000
1304
  args.unshift("--no-sandbox");
1001
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");
1002
1306
  }
1003
1307
  const started = Date.now();
1004
- const child = spawn(bin, args, { cwd, env, stdio: "inherit" });
1308
+ const child = spawn2(bin, args, { cwd, env, stdio: "inherit" });
1005
1309
  child.on("exit", (code) => {
1006
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`);
1007
1311
  });
@@ -1010,39 +1314,52 @@ function startApp(cwd, opts = {}) {
1010
1314
 
1011
1315
  // src/cli/host-files.generated.ts
1012
1316
  var HOST_APP_FILES = {
1013
- "src/app/main/framework.ts": `import { MainFramework } from "fluxplugin/main";
1014
- import { CHANNELS } from "../shared/constants";
1015
- import { registerOperations } from "./operations";
1016
-
1017
- export interface FrameworkOptions {
1018
- pluginDirs: string[];
1019
- dataDir: string;
1020
- dev?: boolean;
1021
- assetUrl?: (id: string, rel: string) => string;
1022
- }
1023
-
1024
- /** Creates the plugin framework. Shared by the Electron entry (index.ts) and the browser entry (web.ts). */
1025
- export function createFramework(opts: FrameworkOptions): MainFramework {
1026
- const fw = new MainFramework({
1027
- pluginDirs: opts.pluginDirs,
1028
- dataDir: opts.dataDir,
1029
- hotReload: opts.dev,
1030
- assetUrl: opts.assetUrl,
1031
- requireApproval: false, // true \u2192 users must approve each plugin's permissions
1032
- allowedRendererChannels: [CHANNELS.createItem],
1033
- });
1034
- registerOperations(fw);
1035
- return fw;
1036
- }
1037
- `,
1038
- "src/app/main/index.ts": '// Electron entry (main process). Keep it thin: wiring only, logic lives in the other files of this folder.\nimport { app, ipcMain, protocol, webContents } from "electron";\nimport path from "node:path";\nimport { PLUGIN_SCHEME, attachElectron, registerPluginProtocol } from "fluxplugin/main";\nimport { createFramework } from "./framework";\nimport { pluginDirs, projectRoot } from "./paths";\nimport { createMainWindow } from "./window";\n\nprotocol.registerSchemesAsPrivileged([PLUGIN_SCHEME]); // must run before the app is ready\n\napp.whenReady().then(async () => {\n const fw = createFramework({\n pluginDirs: pluginDirs(projectRoot(__dirname), app.getPath("userData")),\n dataDir: path.join(app.getPath("userData"), "plugins-data"),\n dev: !!process.env["FLUXPLUGIN_DEV"],\n });\n registerPluginProtocol(fw, protocol);\n attachElectron(fw, { ipcMain: ipcMain as never, webContents: webContents as never });\n await fw.start();\n createMainWindow(__dirname);\n app.on("before-quit", () => void fw.stop());\n});\n\napp.on("window-all-closed", () => app.quit());\n',
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',
1039
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',
1040
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',
1041
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',
1042
- "src/app/main/window.ts": 'import { BrowserWindow } from "electron";\nimport path from "node:path";\n\nexport function createMainWindow(distDir: string): BrowserWindow {\n const win = new BrowserWindow({\n width: 1100,\n height: 760,\n show: false,\n webPreferences: {\n preload: path.join(distDir, "preload.cjs"),\n contextIsolation: true,\n sandbox: true,\n nodeIntegration: false,\n },\n });\n win.once("ready-to-show", () => win.show());\n // The app never navigates away and never opens new windows.\n win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));\n win.webContents.on("will-navigate", (e) => e.preventDefault());\n void win.loadFile(path.join(distDir, "index.html"));\n return win;\n}\n',
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
+ `,
1043
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',
1044
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',
1045
- "src/app/renderer/bootstrap.ts": 'import React from "react";\nimport * as jsxRuntime from "react/jsx-runtime";\nimport * as ReactDOMClient from "react-dom/client";\nimport * as flux from "fluxplugin";\nimport * as fluxReact from "fluxplugin/react";\nimport type { FluxBridge } from "fluxplugin";\nimport { RendererFramework, createHttpBridge, installSharedModules } from "fluxplugin/react";\nimport { declareExtensionPoints } from "./extension-points";\nimport { registerHostRoutes } from "./routes";\n\n/**\n * Infrastructure layer: connects to the main process, shares singletons with plugin bundles,\n * declares what plugins may extend, registers the host\'s own routes, then starts the plugins.\n */\nexport async function bootstrap(): Promise<{ framework: RendererFramework; bridge: FluxBridge }> {\n // One copy of React and of the runtime: plugin bundles import these from globalThis.\n installSharedModules({\n react: React,\n "react/jsx-runtime": jsxRuntime,\n "react/jsx-dev-runtime": jsxRuntime,\n "react-dom/client": ReactDOMClient,\n fluxplugin: flux,\n "fluxplugin/react": fluxReact,\n });\n\n // Electron: the preload bridge. Browser mode: HTTP bridge to `fluxplugin dev --web`.\n const bridge: FluxBridge = (window as unknown as { __fluxplugin?: FluxBridge }).__fluxplugin ?? createHttpBridge();\n const framework = new RendererFramework({ bridge });\n\n declareExtensionPoints(framework); // BEFORE plugins start\n registerHostRoutes(framework);\n await framework.start();\n return { framework, bridge };\n}\n',
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',
1046
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',
1047
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',
1048
1365
  "src/app/renderer/index.html": `<!doctype html>
@@ -1050,7 +1367,7 @@ export function createFramework(opts: FrameworkOptions): MainFramework {
1050
1367
  <head>
1051
1368
  <meta charset="utf-8" />
1052
1369
  <title>__APP_NAME__</title>
1053
- <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:" />
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" />
1054
1371
  <link rel="stylesheet" href="./renderer.css" />
1055
1372
  </head>
1056
1373
  <body>
@@ -1060,9 +1377,35 @@ export function createFramework(opts: FrameworkOptions): MainFramework {
1060
1377
  </html>
1061
1378
  `,
1062
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',
1063
- "src/app/renderer/pages/DashboardPage.tsx": 'import { PluginSlot, useExtensionItems, usePluginFramework } from "fluxplugin/react";\nimport { EXTENSION_POINTS, SLOTS } from "../../shared/constants";\nimport type { ToolbarAction } from "../../shared/types";\nimport { useCreateItem } from "../hooks/useCreateItem";\n\nexport function DashboardPage() {\n const framework = usePluginFramework();\n const actions = useExtensionItems<ToolbarAction>(EXTENSION_POINTS.dashboard, "toolbar");\n const { create, result } = useCreateItem();\n return (\n <div>\n <h2>Dashboard</h2>\n <PluginSlot name={SLOTS.dashboardBefore} />\n <p className="hint">These buttons are contributed by plugins through a host-declared extension point.</p>\n <div className="row" data-testid="toolbar">\n {actions.map((a) => (\n <button key={a.id} className="btn" onClick={() => a.command && void framework.manager.commands.execute(a.command)}>\n {a.label}\n </button>\n ))}\n </div>\n <div className="row">\n <button className="btn" onClick={() => void create(" Ada ")}>Create item \u201C Ada \u201D (plugin hooks may rewrite it)</button>\n <button className="btn" onClick={() => void create(" ")}>Create empty item (a plugin may veto)</button>\n </div>\n <pre data-testid="hook-result" className="hint">{result}</pre>\n <PluginSlot name={SLOTS.dashboardAfter} />\n </div>\n );\n}\n',
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");
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',
1064
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',
1065
- "src/app/renderer/providers/AppProviders.tsx": 'import { createContext, useContext, type ReactNode } from "react";\nimport type { FluxBridge } from "fluxplugin";\nimport { PluginProvider, StyleHost, baseCss, type RendererFramework } from "fluxplugin/react";\n\nconst BridgeContext = createContext<FluxBridge | null>(null);\n\n/** Gives pages access to the main-process bridge without importing globals. */\nexport function useBridge(): FluxBridge {\n const b = useContext(BridgeContext);\n if (!b) throw new Error("useBridge must be used inside <AppProviders>");\n return b;\n}\n\nexport function AppProviders({ framework, bridge, children }: { framework: RendererFramework; bridge: FluxBridge; children: ReactNode }) {\n return (\n <PluginProvider framework={framework}>\n <BridgeContext.Provider value={bridge}>\n <style>{baseCss}</style>\n <StyleHost />\n {children}\n </BridgeContext.Provider>\n </PluginProvider>\n );\n}\n',
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',
1066
1409
  "src/app/renderer/routes.ts": `import type { RendererFramework } from "fluxplugin/react";
1067
1410
  import { DashboardPage } from "./pages/DashboardPage";
1068
1411
  import { PluginsPage } from "./pages/PluginsPage";
@@ -1074,12 +1417,35 @@ export function registerHostRoutes(framework: RendererFramework): void {
1074
1417
  }
1075
1418
  `,
1076
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',
1077
- "src/app/renderer/shell/AppShell.tsx": 'import { Breadcrumbs, CommandPalette, PluginRoutes, useKeybindings } from "fluxplugin/react";\nimport { Sidebar } from "./Sidebar";\n\n/** Layout of the whole window: sidebar + routed content + command palette. */\nexport function AppShell() {\n useKeybindings(); // runs commands bound to keyboard shortcuts (Mod+Shift+R, ...)\n return (\n <div className="app">\n <Sidebar />\n <main className="main">\n <Breadcrumbs />\n <PluginRoutes />\n </main>\n <CommandPalette />\n </div>\n );\n}\n',
1078
- "src/app/renderer/shell/Sidebar.tsx": 'import { Link, PluginSlot, useMenu } from "fluxplugin/react";\nimport { APP_NAME, SLOTS } from "../../shared/constants";\n\nexport function Sidebar() {\n const { items, run } = useMenu("sidebar"); // entries added by plugins (routes with meta.menu, or menus.register)\n return (\n <aside className="side">\n <h1>\n {APP_NAME} <PluginSlot name={SLOTS.sidebarHeader} />\n </h1>\n <Link to="/">Dashboard</Link>\n {items.map((i) => (\n <a key={i.id} href={"#" + i.path} onClick={(e) => (e.preventDefault(), run(i))}>\n {i.label}\n </a>\n ))}\n <Link to="/plugins">Plugins</Link>\n <p className="hint">Ctrl/Cmd+K: commands</p>\n </aside>\n );\n}\n',
1079
- "src/app/renderer/styles/app.css": "body { margin: 0; font: 14px system-ui; background: var(--flux-bg); color: var(--flux-fg); }\n.app { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }\n.side { border-right: 1px solid var(--flux-border); padding: 16px; background: var(--flux-surface); }\n.side h1 { font-size: 16px; margin: 0 0 14px; }\n.side a { display: block; padding: 7px 10px; border-radius: 8px; color: inherit; text-decoration: none; margin: 2px 0; }\n.side a[data-active] { background: var(--flux-accent); color: #fff; }\n.main { padding: 24px; max-width: 860px; }\n.row { display: flex; gap: 8px; margin: 12px 0; flex-wrap: wrap; }\n.btn { border: 1px solid var(--flux-border); background: var(--flux-bg); color: inherit; border-radius: 8px; padding: 6px 12px; cursor: pointer; }\n.hint { color: var(--flux-muted); font-size: 12px; }\n",
1080
- "src/app/shared/constants.ts": '/** Names shared by the main process and the renderer. No imports here: this layer depends on nothing. */\nexport const APP_NAME = "__APP_NAME__";\n\n/** IPC channels the host exposes to the renderer (also allow-listed in main/framework.ts). */\nexport const CHANNELS = {\n createItem: "host.item.create",\n} as const;\n\n/** Declarative extension points the host offers to plugins (see renderer/extension-points.ts). */\nexport const EXTENSION_POINTS = {\n dashboard: "dashboard",\n} as const;\n\n/** UI slots where plugins can inject components (see <PluginSlot name=... />). */\nexport const SLOTS = {\n sidebarHeader: "header.badges",\n dashboardBefore: "dashboard.before",\n dashboardAfter: "dashboard.after",\n} as const;\n\n/** Hook pipeline operations plugins can intercept: before./after./transform./validate.<operation>. */\nexport const OPERATIONS = {\n createItem: "item.create",\n} as const;\n',
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',
1081
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'
1082
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
+ };
1083
1449
 
1084
1450
  // src/cli/templates.ts
1085
1451
  var pkg = (v) => JSON.stringify(
@@ -1088,7 +1454,7 @@ var pkg = (v) => JSON.stringify(
1088
1454
  version: "1.0.0",
1089
1455
  private: true,
1090
1456
  type: "module",
1091
- 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" },
1092
1458
  devDependencies: { fluxplugin: "^1.0.0", "@types/react": "^19.0.0", react: "^19.0.0", typescript: "^5.6.0" }
1093
1459
  },
1094
1460
  null,
@@ -1124,6 +1490,14 @@ function basic(v) {
1124
1490
  version: "1.0.0",
1125
1491
  description: `${v.name} plugin`,
1126
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: {},
1127
1501
  main: "./dist/main.mjs",
1128
1502
  renderer: "./dist/renderer.js",
1129
1503
  styles: ["./dist/index.css"],
@@ -1174,17 +1548,8 @@ export default definePlugin({
1174
1548
  });
1175
1549
  `,
1176
1550
  "src/styles/index.css": styles(v),
1177
- "README.md": `# ${v.name}
1178
-
1179
- Created with \`fluxplugin create\`.
1180
-
1181
- \`\`\`bash
1182
- pnpm install
1183
- pnpm dev # rebuild on change (the host hot-reloads it)
1184
- pnpm build
1185
- pnpm package # produces ${v.id}-1.0.0.tgz
1186
- \`\`\`
1187
- `
1551
+ "assets/icon.svg": iconSvg(v),
1552
+ "README.md": readme(v)
1188
1553
  };
1189
1554
  }
1190
1555
  function dashboard(v) {
@@ -1192,7 +1557,7 @@ function dashboard(v) {
1192
1557
  const m = JSON.parse(files["plugin.json"]);
1193
1558
  m.activationEvents = ["onStartup"];
1194
1559
  m.contributes = {};
1195
- m.permissions = ["commands", "routes", "menus", "ui", "styles"];
1560
+ m.permissions = ["commands", "routes", "menus", "ui", "styles", "notifications"];
1196
1561
  files["plugin.json"] = JSON.stringify(m, null, 2);
1197
1562
  files["src/renderer.tsx"] = `import { definePlugin } from "fluxplugin";
1198
1563
  import { usePluginState } from "fluxplugin/react";
@@ -1215,9 +1580,21 @@ function ${v.Pascal}Page() {
1215
1580
  export default definePlugin({
1216
1581
  routes: [{ path: "/${v.slug}", lazy: async () => ({ default: ${v.Pascal}Page }), meta: { title: "${v.name}", breadcrumb: "${v.name}", menu: { label: "${v.name}" } } }],
1217
1582
  components: [{ slot: "dashboard.after", component: ${v.Pascal}Widget, priority: 10 }],
1218
- // Data contribution to the host's "dashboard" extension point (validated by the host's schema).
1219
- extensions: [{ point: "dashboard", contribution: { toolbar: [{ id: "${v.slug}-hello", label: "${v.name}: hello", command: "${v.slug}.hello" }] } }],
1220
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
+ },
1221
1598
  });
1222
1599
  `;
1223
1600
  return files;
@@ -1276,6 +1653,75 @@ function varsFromName(name) {
1276
1653
  const Pascal = short.split(/[^a-z0-9]+/i).filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join("");
1277
1654
  return { id, name: short.split("-").map((p) => p[0].toUpperCase() + p.slice(1)).join(" "), slug: short.replace(/-/g, "_"), Pascal };
1278
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
+ }
1279
1725
 
1280
1726
  // src/cli/host-templates.ts
1281
1727
  function hostFiles(v, withSample = true) {
@@ -1296,31 +1742,36 @@ function hostFiles(v, withSample = true) {
1296
1742
  typecheck: "tsc --noEmit"
1297
1743
  },
1298
1744
  dependencies: { fluxplugin: v.fluxSpec, react: "^19.0.0", "react-dom": "^19.0.0" },
1299
- devDependencies: { electron: "^33.0.0", typescript: "~5.9.3", "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0" }
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"] }
1300
1749
  },
1301
1750
  null,
1302
1751
  2
1303
1752
  ),
1304
- "fluxplugin.config.json": JSON.stringify({ pluginsDir: "plugins", outDir: "dist" }, null, 2),
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),
1305
1755
  "tsconfig.json": JSON.stringify(
1306
- { 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"] },
1307
1757
  null,
1308
1758
  2
1309
1759
  ),
1310
1760
  ".gitignore": "node_modules\ndist\n.flux-data\n*.tgz\n",
1311
- "README.md": readme(v.name)
1761
+ "README.md": readme2(v.name)
1312
1762
  };
1313
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);
1314
1765
  if (withSample) {
1315
1766
  const sample = TEMPLATES.dashboard(varsFromName("hello"));
1316
1767
  for (const [rel, content] of Object.entries(sample)) {
1317
- if (rel === "package.json" || rel === "tsconfig.json" || rel === "README.md") continue;
1768
+ if (rel === "package.json" || rel === "tsconfig.json") continue;
1318
1769
  files[`plugins/hello/${rel}`] = content;
1319
1770
  }
1320
1771
  }
1321
1772
  return files;
1322
1773
  }
1323
- var readme = (name) => `# ${name}
1774
+ var readme2 = (name) => `# ${name}
1324
1775
 
1325
1776
  Created with [fluxplugin](https://www.npmjs.com/package/fluxplugin).
1326
1777
 
@@ -1350,30 +1801,218 @@ plugins/ plugins loaded at startup (users can also install .tgz from the P
1350
1801
  \`\`\`
1351
1802
  `;
1352
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 });
1847
+ }
1848
+ }
1849
+
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}
1902
+
1903
+ ${pkg2.manifest.description ?? ""}
1904
+
1905
+ Permissions: ${(pkg2.manifest.permissions ?? []).join(", ") || "none"}
1906
+
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
+ }
1932
+ }
1933
+
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
1945
+
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
+ }
1981
+
1353
1982
  // src/cli/cli.ts
1354
1983
  var HELP = `fluxplugin <command>
1355
1984
 
1356
- init [dir] create a new app (Electron + React) that supports plugins
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
1357
1987
  dev [--web] run your app with plugin hot reload (Electron, or a browser with --web)
1358
1988
  build bundle your app (in an app folder) or a plugin (in a plugin folder)
1359
1989
  start [--web] run the built app
1360
- 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)
1361
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
1362
1997
  package [dir] [--out d] [--sign-key file] build + integrity (+ signature) + .tgz
1363
1998
  install <dir|.tgz> [--dir plugins] uninstall <id> enable <id> disable <id> list
1364
1999
  keygen [--out name] create an ed25519 signing key pair
1365
2000
  tailwind [--dir plugins] [--out file] generate @source lines for Tailwind v4`;
1366
2001
  var log = (...a) => console.log(...a);
1367
- async function readManifest2(root) {
1368
- return JSON.parse(await fs8.readFile(path9.join(root, "plugin.json"), "utf8"));
2002
+ async function readManifest(root) {
2003
+ return JSON.parse(await fs11.readFile(path14.join(root, "plugin.json"), "utf8"));
1369
2004
  }
1370
2005
  async function run(argv) {
1371
2006
  const { values: f, positionals: pos } = parseArgs({
1372
2007
  args: argv,
1373
2008
  allowPositionals: true,
1374
- 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" } }
1375
2010
  });
1376
2011
  let [cmd, arg] = pos;
2012
+ if (cmd === "store") {
2013
+ cmd = "store:" + (pos[1] ?? "");
2014
+ arg = pos[2];
2015
+ }
1377
2016
  if (cmd === "plugin") {
1378
2017
  const sub = pos[1];
1379
2018
  if (sub !== "new") throw new Error("usage: fluxplugin plugin new <name>");
@@ -1381,20 +2020,20 @@ async function run(argv) {
1381
2020
  arg = pos[2];
1382
2021
  }
1383
2022
  if (!cmd || f.help) return log(HELP), cmd ? 0 : 1;
1384
- const root = path9.resolve(arg ?? ".");
2023
+ const root = path14.resolve(arg ?? ".");
1385
2024
  try {
1386
2025
  switch (cmd) {
1387
2026
  case "init": {
1388
- const target = path9.resolve(arg ?? ".");
1389
- const entries = await fs8.readdir(target).catch(() => []);
2027
+ const target = path14.resolve(arg ?? ".");
2028
+ const entries = await fs11.readdir(target).catch(() => []);
1390
2029
  if (entries.length) throw new Error(`${target} is not empty`);
1391
- const name = (f.name ?? path9.basename(target)).toLowerCase().replace(/[^a-z0-9-_.]+/g, "-");
1392
- const files = hostFiles({ name, fluxSpec: process.env["FLUXPLUGIN_SPEC"] ?? `^${await ownVersion()}` }, !f["no-sample"]);
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"]);
1393
2032
  for (const [rel2, content] of Object.entries(files)) {
1394
- await fs8.mkdir(path9.dirname(path9.join(target, rel2)), { recursive: true });
1395
- await fs8.writeFile(path9.join(target, rel2), content);
2033
+ await fs11.mkdir(path14.dirname(path14.join(target, rel2)), { recursive: true });
2034
+ await fs11.writeFile(path14.join(target, rel2), content);
1396
2035
  }
1397
- const rel = path9.relative(process.cwd(), target) || ".";
2036
+ const rel = path14.relative(process.cwd(), target) || ".";
1398
2037
  log(`\u2714 created ${name} in ${target}
1399
2038
 
1400
2039
  Next steps:
@@ -1410,15 +2049,17 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
1410
2049
  if (!arg) throw new Error("usage: fluxplugin plugin new <name> [-t basic|dashboard|backend]");
1411
2050
  const tpl = f.template ?? "basic";
1412
2051
  if (!(tpl in TEMPLATES)) throw new Error(`unknown template "${tpl}"`);
1413
- const base = await isHostProject(process.cwd()) && !path9.isAbsolute(arg) && !arg.includes("/") ? path9.join((await loadConfig({}, process.cwd())).pluginsDir, arg) : path9.resolve(arg);
1414
- if (await fs8.stat(base).catch(() => null)) throw new Error(`${base} already exists`);
1415
- const files = TEMPLATES[tpl](varsFromName(path9.basename(base)));
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);
1416
2057
  for (const [rel, content] of Object.entries(files)) {
1417
- await fs8.mkdir(path9.dirname(path9.join(base, rel)), { recursive: true });
1418
- await fs8.writeFile(path9.join(base, rel), content);
2058
+ await fs11.mkdir(path14.dirname(path14.join(base, rel)), { recursive: true });
2059
+ await fs11.writeFile(path14.join(base, rel), content);
1419
2060
  }
1420
2061
  log(`\u2714 created ${tpl} plugin in ${base}
1421
- next: fluxplugin build ${path9.relative(process.cwd(), base)}`);
2062
+ next: fluxplugin build ${path14.relative(process.cwd(), base)}`);
1422
2063
  return 0;
1423
2064
  }
1424
2065
  case "build": {
@@ -1450,30 +2091,57 @@ Add a plugin: npm run plugin:new my-plugin -- -t backend`);
1450
2091
  }
1451
2092
  case "start": {
1452
2093
  const cwd = process.cwd();
1453
- if (!await fs8.stat(path9.join(cwd, "dist/index.html")).catch(() => null)) throw new Error("nothing built yet: run `fluxplugin build` first");
2094
+ if (!await fs11.stat(path14.join(cwd, "dist/index.html")).catch(() => null)) throw new Error("nothing built yet: run `fluxplugin build` first");
1454
2095
  const { child } = startApp(cwd, { web: f.web, port: f.port ? Number(f.port) : void 0 });
1455
2096
  return await new Promise((r) => child.on("exit", (c) => r(c ?? 0)));
1456
2097
  }
2098
+ case "doctor":
2099
+ return await doctor(process.cwd());
1457
2100
  case "validate":
1458
- return validate(root);
2101
+ return await validate(root);
1459
2102
  case "package":
1460
- return pack(root, f.out, f["sign-key"]);
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
+ }
1461
2129
  case "keygen": {
1462
2130
  const k = generateSigningKeyPair();
1463
2131
  const base = f.out ?? "flux-signing";
1464
- await fs8.writeFile(`${base}.private.key`, k.privateKey, { mode: 384 });
1465
- await fs8.writeFile(`${base}.public.key`, k.publicKey);
2132
+ await fs11.writeFile(`${base}.private.key`, k.privateKey, { mode: 384 });
2133
+ await fs11.writeFile(`${base}.public.key`, k.publicKey);
1466
2134
  log(`wrote ${base}.private.key (keep secret) and ${base}.public.key (distribute to hosts as a trusted key)`);
1467
2135
  return 0;
1468
2136
  }
1469
2137
  case "tailwind": {
1470
2138
  const cfg = await loadConfig(f);
1471
- const names = (await fs8.readdir(cfg.pluginsDir).catch(() => [])).sort();
1472
- const lines = names.map((n) => `@source "${path9.join(cfg.pluginsDir, n, "src").replace(/\\/g, "/")}";`);
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, "/")}";`);
1473
2141
  const css = `/* generated by fluxplugin tailwind */
1474
2142
  ${lines.join("\n")}
1475
2143
  `;
1476
- if (f.out) await fs8.writeFile(f.out, css);
2144
+ if (f.out) await fs11.writeFile(f.out, css);
1477
2145
  else log(css);
1478
2146
  return 0;
1479
2147
  }
@@ -1482,7 +2150,7 @@ ${lines.join("\n")}
1482
2150
  case "enable":
1483
2151
  case "disable":
1484
2152
  case "list":
1485
- return manage(cmd, arg, f);
2153
+ return await manage(cmd, arg, f);
1486
2154
  default:
1487
2155
  log(HELP);
1488
2156
  return 1;
@@ -1493,14 +2161,14 @@ ${lines.join("\n")}
1493
2161
  }
1494
2162
  }
1495
2163
  async function validate(root) {
1496
- const raw = await readManifest2(root);
2164
+ const raw = await readManifest(root);
1497
2165
  const v = validateManifest(raw);
1498
2166
  let bad = !v.ok;
1499
2167
  v.issues.forEach((i) => log(`\u2716 ${i.path || "<root>"}: ${i.message}`));
1500
2168
  v.warnings.forEach((w) => log(`! ${w}`));
1501
2169
  if (v.manifest) {
1502
2170
  for (const rel of [v.manifest.main, v.manifest.renderer, ...v.manifest.styles ?? []].filter(Boolean))
1503
- if (!await fs8.stat(path9.join(root, rel)).catch(() => null)) log(`\u2716 missing file ${rel} (run build)`), bad = true;
2171
+ if (!await fs11.stat(path14.join(root, rel)).catch(() => null)) log(`\u2716 missing file ${rel} (run build)`), bad = true;
1504
2172
  try {
1505
2173
  await verifyPlugin(root, raw, v.manifest);
1506
2174
  } catch (e) {
@@ -1511,47 +2179,38 @@ async function validate(root) {
1511
2179
  log(bad ? "invalid" : `\u2714 ${v.manifest?.id}@${v.manifest?.version} is valid`);
1512
2180
  return bad ? 1 : 0;
1513
2181
  }
1514
- async function pack(root, out = ".", keyFile) {
1515
- await buildPlugin(root);
1516
- let raw = await readManifest2(root);
1517
- const m = validateManifest(raw).manifest;
1518
- if (!m) throw new Error("invalid manifest");
1519
- raw["integrity"] = await computeIntegrity(root, m);
1520
- if (keyFile) raw = signManifest(raw, (await fs8.readFile(keyFile, "utf8")).trim());
1521
- const stage = await fs8.mkdtemp(path9.join((await import("node:os")).tmpdir(), "flux-pack-"));
1522
- await copyDir(root, stage);
1523
- await fs8.rm(path9.join(stage, "src"), { recursive: true, force: true });
1524
- await fs8.writeFile(path9.join(stage, "plugin.json"), JSON.stringify(raw, null, 2));
1525
- const buf = await createTarGz(stage);
1526
- await fs8.rm(stage, { recursive: true, force: true });
1527
- await fs8.mkdir(out, { recursive: true });
1528
- const file = path9.join(out, `${m.id}-${m.version}.tgz`);
1529
- await fs8.writeFile(file, buf);
1530
- log(`packaged ${file}
1531
- sha256 ${crypto2.createHash("sha256").update(buf).digest("hex")}${keyFile ? "\n signed" : ""}`);
1532
- return 0;
1533
- }
1534
2182
  async function manage(cmd, arg, f) {
1535
2183
  const cfg = await loadConfig(f);
1536
- const state = new JsonStateFile(path9.join(cfg.dataDir, "plugins-state.json"));
1537
- const inst = new PluginInstaller(cfg.pluginsDir, path9.join(cfg.dataDir, "backups"));
2184
+ const state = new JsonStateFile(path14.join(cfg.dataDir, "plugins-state.json"));
2185
+ const inst = new PluginInstaller(cfg.pluginsDir, path14.join(cfg.dataDir, "backups"));
1538
2186
  if (cmd === "list") {
1539
2187
  const { disabled } = await state.readAll();
1540
2188
  for (const m of await inst.installed()) log(`${disabled.includes(m.id) ? "\u25CB" : "\u25CF"} ${m.id}@${m.version} ${m.name}`);
1541
2189
  return 0;
1542
2190
  }
1543
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
+ }
1544
2203
  if (cmd === "install") {
1545
- const tmp = path9.join(cfg.dataDir, ".install", String(Date.now()));
1546
- if (arg.endsWith(".tgz")) await extractTarGz(await fs8.readFile(arg), tmp);
1547
- else await copyDir(path9.resolve(arg), tmp);
1548
- const v = validateManifest(await readManifest2(tmp));
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));
1549
2208
  if (!v.manifest) throw new Error("invalid plugin: " + v.issues.map((i) => i.message).join("; "));
1550
- const reg = new LocalRegistry(path9.dirname(tmp), "cli");
2209
+ const reg = new LocalRegistry(path14.dirname(tmp), "cli");
1551
2210
  inst.addRegistry(reg);
1552
2211
  const [entry] = (await reg.search(v.manifest.id)).filter((e) => e.id === v.manifest.id);
1553
2212
  const r = await inst.install({ ...entry, location: tmp });
1554
- await fs8.rm(tmp, { recursive: true, force: true });
2213
+ await fs11.rm(tmp, { recursive: true, force: true });
1555
2214
  log(`installed ${r.id}@${r.version}${r.previousVersion ? ` (was ${r.previousVersion}; rollback available)` : ""}`);
1556
2215
  } else if (cmd === "uninstall") {
1557
2216
  await inst.uninstall(arg);
@@ -1565,13 +2224,49 @@ async function manage(cmd, arg, f) {
1565
2224
  async function ownVersion() {
1566
2225
  for (const rel of ["../package.json", "../../package.json"]) {
1567
2226
  try {
1568
- const j = JSON.parse(await fs8.readFile(new URL(rel, import.meta.url), "utf8"));
2227
+ const j = JSON.parse(await fs11.readFile(new URL(rel, import.meta.url), "utf8"));
1569
2228
  if (j.name === "fluxplugin" && j.version) return j.version;
1570
2229
  } catch {
1571
2230
  }
1572
2231
  }
1573
2232
  return "1.0.0";
1574
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
+ }
1575
2270
 
1576
2271
  // src/cli/bin.ts
1577
2272
  process.exit(await run(process.argv.slice(2)));