Update plugin template for host-driven dev reload

This commit is contained in:
2026-04-08 13:11:37 +02:00
parent 41d35103dd
commit f6b767fbec
8 changed files with 79 additions and 89 deletions
+30 -7
View File
@@ -27,11 +27,7 @@ mix test
For local development alongside a Tribes checkout:
```bash
# Build plugin code once (host loads BEAM from _build/dev/lib/<otp_app>/ebin)
cd /path/to/your-plugin
mix compile
# Symlink into the host plugins directory
# Symlink into the host plugins directory once
cd /path/to/tribes
ln -s /path/to/your-plugin plugins/your_plugin
@@ -39,7 +35,30 @@ ln -s /path/to/your-plugin plugins/your_plugin
iex --sname dev -S mix phx.server
```
When you change plugin Elixir code, re-run `mix compile` in the plugin repo.
Then edit the plugin in its own repo. In development, the host now watches
symlinked external plugins and automatically:
- runs `mix compile` for Elixir/HEEx or manifest changes
- runs `npm run build --prefix assets` when `assets/package.json` is present
- reloads the plugin in the running Tribes VM
- triggers a Phoenix browser reload after the rebuild finishes
That means the normal loop is:
```bash
cd /path/to/your-plugin
mix deps.get
mix test
# in another terminal
cd /path/to/tribes
iex --sname dev -S mix phx.server
```
If your plugin does not need a custom frontend pipeline, you can skip
`assets/package.json` and write browser-ready files directly under `assets/js`
and `assets/css`; the host dev watcher will copy them into `priv/static` for
you in development.
## Project Structure
@@ -54,6 +73,8 @@ your_plugin/
│ └── your_plugin_web/
│ └── live/ # LiveView pages
├── assets/ # JS/CSS (one bundle per plugin)
│ ├── package.json # Optional build script used by dev + Guix packaging
│ └── package-lock.json # Optional, recommended for reproducible builds
├── priv/
│ ├── static/ # Built assets for release
│ └── repo/migrations/ # Ecto migrations
@@ -106,6 +127,7 @@ This runs Tribes + Parrhesia + plugin migrations via `Tribes.Release`.
```bash
MIX_ENV=prod mix compile
npm run build --prefix assets
mkdir -p dist/your_plugin
cp -r _build/prod/lib/your_plugin/ebin dist/your_plugin/
@@ -113,7 +135,8 @@ cp -r priv dist/your_plugin/
cp manifest.json dist/your_plugin/
```
For Nix-based deployment, add your plugin to the host's `plugins.json`.
For Guix-based deployment, package your plugin in the `guix-tribes` channel and
enable it from the node config.
## Licence
+12
View File
@@ -0,0 +1,12 @@
{
"name": "my-plugin-assets",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "my-plugin-assets",
"version": "0.1.0"
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "my-plugin-assets",
"private": true,
"version": "0.1.0",
"scripts": {
"build": "mkdir -p ../priv/static && cp -r css/. ../priv/static && cp -r js/. ../priv/static"
}
}
+6 -74
View File
@@ -1,30 +1,14 @@
defmodule MyPlugin.Plugin do
@moduledoc """
Tribes plugin entry point.
Implements the `Tribes.Plugin` behaviour. This module is referenced by
`entry_module` in manifest.json and is called by the host's PluginManager
during startup.
"""
# Once Tribes.Plugin.Base is available, replace the manual implementation
# with:
#
# use Tribes.Plugin.Base, otp_app: :my_plugin
#
# and override only register/1.
use Tribes.Plugin.Base, otp_app: :my_plugin
# @behaviour Tribes.Plugin
def register(_context) do
manifest = read_manifest()
%{
name: manifest["name"],
version: manifest["version"],
provides: manifest["provides"] || [],
requires: manifest["requires"] || [],
enhances_with: manifest["enhances_with"] || [],
@impl true
def register(context) do
super(context)
|> Map.merge(%{
nav_items: [
%{
label: "My Plugin",
@@ -43,59 +27,7 @@ defmodule MyPlugin.Plugin do
],
api_routes: [],
plugs: [],
children: [],
global_js: get_in(manifest, ["assets", "global_js"]) || [],
global_css: get_in(manifest, ["assets", "global_css"]) || [],
migrations_path: migrations_path(manifest),
hooks: %{}
}
end
defp read_manifest do
manifest_path()
|> File.read!()
|> Jason.decode!()
end
defp manifest_path do
project_manifest = Path.join(__DIR__, "../../manifest.json") |> Path.expand()
candidates =
case :code.priv_dir(:my_plugin) do
{:error, :bad_name} ->
[project_manifest]
priv_dir ->
priv_dir = to_string(priv_dir)
[
Path.join(priv_dir, "../manifest.json") |> Path.expand(),
project_manifest
]
end
first_existing_path(candidates) || project_manifest
end
defp migrations_path(manifest) do
if manifest["migrations"] do
candidates =
case :code.priv_dir(:my_plugin) do
{:error, :bad_name} ->
[Path.join(__DIR__, "../../priv/repo/migrations") |> Path.expand()]
priv_dir ->
[
Path.join(to_string(priv_dir), "repo/migrations") |> Path.expand(),
Path.join(__DIR__, "../../priv/repo/migrations") |> Path.expand()
]
end
first_existing_path(candidates)
end
end
defp first_existing_path(paths) do
Enum.find(paths, &File.exists?/1)
})
end
end
+8 -6
View File
@@ -26,15 +26,17 @@ defmodule MyPlugin.MixProject do
defp deps do
[
# Host dependency — provides Tribes.Plugin behaviour, types, and test helpers.
# Plugin API dependency for local development alongside a tribes checkout.
#
# For local development alongside a tribes checkout:
{:tribes, path: "../tribes", only: [:dev, :test], runtime: false},
{:tribes_plugin_api, path: "../tribes/tribes_plugin_api", runtime: false},
#
# For CI or standalone development (when not co-located with tribes):
# {:tribes, github: "your-org/tribes", branch: "master", only: [:dev, :test]},
{:jason, "~> 1.2"}
# For CI or standalone development, this can be replaced with a published
# package once tribes_plugin_api is released.
# {:tribes_plugin_api, github: "your-org/tribes", sparse: "tribes_plugin_api"},
{:phoenix, "~> 1.8"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_view, "~> 1.1.0"}
]
end
+13
View File
@@ -0,0 +1,13 @@
%{
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}
+1 -1
View File
@@ -21,7 +21,7 @@ defmodule MyPlugin.ContractTest do
@manifest_path Path.join(__DIR__, "../manifest.json") |> Path.expand()
setup_all do
manifest = @manifest_path |> File.read!() |> Jason.decode!()
manifest = @manifest_path |> File.read!() |> JSON.decode!()
spec = @plugin.register(%{pubsub: nil, repo: nil})
%{manifest: manifest, spec: spec}
end
+1 -1
View File
@@ -5,7 +5,7 @@ defmodule MyPlugin.ManifestTest do
setup_all do
content = File.read!(@manifest_path)
manifest = Jason.decode!(content)
manifest = JSON.decode!(content)
%{manifest: manifest}
end