From 647d5537ffe6d6df3333095de77f95cebccdb87e Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Wed, 25 Mar 2026 12:42:19 +0100 Subject: [PATCH] skeleton --- .formatter.exs | 3 + .github/workflows/ci.yml | 71 +++++++++++++++++++ .gitignore | 25 +++++++ README.md | 103 ++++++++++++++++++++++++++++ assets/css/my_plugin.css | 13 ++++ assets/js/my_plugin.js | 15 ++++ lib/my_plugin/application.ex | 21 ++++++ lib/my_plugin/plugin.ex | 81 ++++++++++++++++++++++ lib/my_plugin_web/live/home_live.ex | 30 ++++++++ manifest.json | 16 +++++ mix.exs | 46 +++++++++++++ priv/repo/migrations/.gitkeep | 0 priv/static/.gitkeep | 0 scripts/rename.sh | 68 ++++++++++++++++++ test/contract_test.exs | 86 +++++++++++++++++++++++ test/my_plugin/manifest_test.exs | 59 ++++++++++++++++ test/my_plugin/plugin_test.exs | 63 +++++++++++++++++ test/test_helper.exs | 1 + 18 files changed, 701 insertions(+) create mode 100644 .formatter.exs create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/css/my_plugin.css create mode 100644 assets/js/my_plugin.js create mode 100644 lib/my_plugin/application.ex create mode 100644 lib/my_plugin/plugin.ex create mode 100644 lib/my_plugin_web/live/home_live.ex create mode 100644 manifest.json create mode 100644 mix.exs create mode 100644 priv/repo/migrations/.gitkeep create mode 100644 priv/static/.gitkeep create mode 100755 scripts/rename.sh create mode 100644 test/contract_test.exs create mode 100644 test/my_plugin/manifest_test.exs create mode 100644 test/my_plugin/plugin_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d304ff3 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ba7c41f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + MIX_ENV: test + + steps: + - uses: actions/checkout@v4 + + - uses: erlef/setup-beam@v1 + with: + elixir-version: "1.19" + otp-version: "27" + + - name: Cache deps + uses: actions/cache@v4 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + + - name: Install dependencies + run: mix deps.get + + - name: Compile (warnings as errors) + run: mix compile --warnings-as-errors + + - name: Check formatting + run: mix format --check-formatted + + - name: Run tests + run: mix test + + - name: Validate manifest + run: | + elixir -e ' + manifest = "manifest.json" |> File.read!() |> Jason.decode!() + required = ["name", "version", "entry_module", "host_api", "provides", "requires"] + missing = Enum.filter(required, &(not Map.has_key?(manifest, &1))) + if missing != [] do + IO.puts(:stderr, "Missing manifest fields: #{inspect(missing)}") + System.halt(1) + end + IO.puts("Plugin: #{manifest["name"]} v#{manifest["version"]}") + ' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a5eb8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Dependencies and build +/deps/ +/_build/ +/dist/ + +# Node +/node_modules/ +/assets/node_modules/ + +# Generated on crash +erl_crash.dump + +# Editor and OS +.elixir_ls/ +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Env files +.env +.env.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b4da80 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# Tribes Plugin Template + +Template for creating [Tribes](https://github.com/your-org/tribes) plugins. + +## Getting Started + +1. Click **"Use this template"** on GitHub to create your own repo +2. Clone and rename: + +```bash +git clone https://github.com/you/your-plugin.git +cd your-plugin +./scripts/rename.sh your_plugin YourPlugin +``` + +3. Edit `manifest.json` — set description, capabilities, requirements +4. Implement your plugin in `lib/your_plugin/plugin.ex` +5. Run tests: + +```bash +mix deps.get +mix test +``` + +## Development + +For local development alongside a Tribes checkout: + +```bash +# Symlink into the host plugins directory +cd /path/to/tribes +ln -s /path/to/your-plugin plugins/your_plugin + +# Start Tribes dev server — your plugin loads automatically +iex --sname dev -S mix phx.server +``` + +Edit your plugin source. Phoenix code reloader picks up changes. + +## Project Structure + +``` +your_plugin/ +├── manifest.json # Plugin metadata (Nix build + runtime) +├── mix.exs # Dependencies +├── lib/ +│ ├── your_plugin/ +│ │ ├── plugin.ex # Tribes.Plugin entry point +│ │ └── application.ex # OTP supervision tree (optional) +│ └── your_plugin_web/ +│ └── live/ # LiveView pages +├── assets/ # JS/CSS (one bundle per plugin) +├── priv/ +│ ├── static/ # Built assets for release +│ └── repo/migrations/ # Ecto migrations +└── test/ +``` + +## Manifest + +`manifest.json` declares your plugin's identity and capabilities: + +```json +{ + "name": "your_plugin", + "provides": ["some_capability@1"], + "requires": ["ecto@1"], + "enhances_with": ["inference@1"] +} +``` + +- **provides** — capabilities this plugin makes available +- **requires** — hard dependencies (build fails without them) +- **enhances_with** — optional dependencies (plugin degrades gracefully) + +See the [Plugin System docs](https://github.com/your-org/tribes/blob/master/docs/PLUGINS.md) for the full specification. + +## Testing + +Three test levels: + +- **Unit tests** (`test/your_plugin/`) — plugin logic in isolation +- **Manifest tests** (`test/your_plugin/manifest_test.exs`) — manifest schema validation +- **Contract tests** (`test/contract_test.exs`) — runtime spec matches manifest + +Run all: `mix test` + +## Building for Release + +```bash +MIX_ENV=prod mix compile + +mkdir -p dist/your_plugin +cp -r _build/prod/lib/your_plugin/ebin dist/your_plugin/ +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`. + +## Licence + +TODO: Choose a licence. diff --git a/assets/css/my_plugin.css b/assets/css/my_plugin.css new file mode 100644 index 0000000..aa1e98a --- /dev/null +++ b/assets/css/my_plugin.css @@ -0,0 +1,13 @@ +/* + * Plugin CSS entry point. + * + * Served at /plugins-assets/my_plugin/my_plugin.css + * and included in the page layout if declared in manifest.json assets.global_css. + * + * Prefix all selectors with your plugin name to avoid collisions + * with host or other plugin styles. + */ + +.my-plugin { + /* Plugin-scoped styles go here */ +} diff --git a/assets/js/my_plugin.js b/assets/js/my_plugin.js new file mode 100644 index 0000000..c8116f1 --- /dev/null +++ b/assets/js/my_plugin.js @@ -0,0 +1,15 @@ +// Plugin JavaScript entry point. +// +// This file is served by the host at /plugins-assets/my_plugin/my_plugin.js +// and included in the page layout if declared in manifest.json assets.global_js. +// +// To register LiveView hooks: +// +// window.TribesPluginHooks = window.TribesPluginHooks || {}; +// window.TribesPluginHooks["MyPluginHook"] = { +// mounted() { +// console.log("MyPlugin hook mounted"); +// } +// }; + +console.log("my_plugin loaded"); diff --git a/lib/my_plugin/application.ex b/lib/my_plugin/application.ex new file mode 100644 index 0000000..5553a9b --- /dev/null +++ b/lib/my_plugin/application.ex @@ -0,0 +1,21 @@ +defmodule MyPlugin.Application do + @moduledoc """ + OTP Application for this plugin. + + Uncomment the `mod:` entry in mix.exs to activate this supervision tree. + Add plugin-specific GenServers, workers, or supervisors as children here. + """ + + use Application + + @impl true + def start(_type, _args) do + children = [ + # Add your supervised processes here, e.g.: + # {MyPlugin.Worker, []} + ] + + opts = [strategy: :one_for_one, name: MyPlugin.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/my_plugin/plugin.ex b/lib/my_plugin/plugin.ex new file mode 100644 index 0000000..f0310ff --- /dev/null +++ b/lib/my_plugin/plugin.ex @@ -0,0 +1,81 @@ +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. + + # @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"] || [], + nav_items: [ + %{ + label: "My Plugin", + path: "/plugins/my_plugin", + icon: nil, + requires: [], + order: 50 + } + ], + pages: [ + %{ + path: "/plugins/my_plugin", + live_view: MyPluginWeb.HomeLive, + layout: nil + } + ], + 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 + # In a release, manifest.json sits alongside ebin/ in the plugin directory. + # In dev mode (path dep), it's at the project root. + case :code.priv_dir(:my_plugin) do + {:error, :bad_name} -> + # Dev mode fallback: relative to project root + Path.join(__DIR__, "../../../manifest.json") |> Path.expand() + + priv_dir -> + priv_dir |> to_string() |> Path.join("../manifest.json") |> Path.expand() + end + end + + defp migrations_path(manifest) do + if manifest["migrations"] do + case :code.priv_dir(:my_plugin) do + {:error, :bad_name} -> nil + priv_dir -> priv_dir |> to_string() |> Path.join("repo/migrations") + end + end + end +end diff --git a/lib/my_plugin_web/live/home_live.ex b/lib/my_plugin_web/live/home_live.ex new file mode 100644 index 0000000..8130455 --- /dev/null +++ b/lib/my_plugin_web/live/home_live.ex @@ -0,0 +1,30 @@ +defmodule MyPluginWeb.HomeLive do + @moduledoc """ + Example LiveView page for the plugin. + + This page is registered in the plugin spec and mounted by the host + at /plugins/my_plugin. + """ + + # In dev mode (plugin loaded as path dep), you can use host macros: + # use TribesWeb, :live_view + # + # For release builds (standalone OTP app), use Phoenix.LiveView directly: + use Phoenix.LiveView + + def mount(_params, _session, socket) do + {:ok, assign(socket, :page_title, "My Plugin")} + end + + def render(assigns) do + ~H""" +
+

My Plugin

+

+ This is a Tribes plugin. Edit this page in + lib/my_plugin_web/live/home_live.ex. +

+
+ """ + end +end diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..c4964d6 --- /dev/null +++ b/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "my_plugin", + "version": "0.1.0", + "description": "TODO: Describe what this plugin does", + "entry_module": "MyPlugin.Plugin", + "host_api": "1", + "provides": [], + "requires": ["ecto@1"], + "enhances_with": [], + "assets": { + "global_js": ["my_plugin.js"], + "global_css": ["my_plugin.css"] + }, + "migrations": false, + "children": false +} diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..5c4eb2a --- /dev/null +++ b/mix.exs @@ -0,0 +1,46 @@ +defmodule MyPlugin.MixProject do + use Mix.Project + + def project do + [ + app: :my_plugin, + version: "0.1.0", + elixir: "~> 1.18", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + deps: deps(), + aliases: aliases() + ] + end + + def application do + [ + extra_applications: [:logger] + # Uncomment if your plugin needs its own supervision tree: + # mod: {MyPlugin.Application, []} + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + # Host dependency — provides Tribes.Plugin behaviour, types, and test helpers. + # + # For local development alongside a tribes checkout: + {:tribes, path: "../tribes", only: [:dev, :test]}, + # + # For CI or standalone development (when not co-located with tribes): + # {:tribes, github: "your-org/tribes", branch: "master", only: [:dev, :test]}, + + {:jason, "~> 1.2"} + ] + end + + defp aliases do + [ + test: ["test"] + ] + end +end diff --git a/priv/repo/migrations/.gitkeep b/priv/repo/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/priv/static/.gitkeep b/priv/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/rename.sh b/scripts/rename.sh new file mode 100755 index 0000000..701e381 --- /dev/null +++ b/scripts/rename.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Renames the template plugin to your chosen name. +# +# Usage: +# ./scripts/rename.sh +# +# Example: +# ./scripts/rename.sh billing_reports BillingReports + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " + echo "Example: $0 billing_reports BillingReports" + exit 1 +fi + +SNAKE="$1" +MODULE="$2" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$ROOT" + +# Validate inputs +if ! echo "$SNAKE" | grep -qE '^[a-z][a-z0-9_]*$'; then + echo "Error: snake_case_name must match [a-z][a-z0-9_]*" + exit 1 +fi + +if ! echo "$MODULE" | grep -qE '^[A-Z][a-zA-Z0-9]*$'; then + echo "Error: ModuleName must start with uppercase and contain only alphanumeric characters" + exit 1 +fi + +echo "Renaming my_plugin -> $SNAKE, MyPlugin -> $MODULE" + +# Rename directories +if [ -d "lib/my_plugin" ]; then + mv "lib/my_plugin" "lib/$SNAKE" +fi +if [ -d "lib/my_plugin_web" ]; then + mv "lib/my_plugin_web" "lib/${SNAKE}_web" +fi +if [ -d "test/my_plugin" ]; then + mv "test/my_plugin" "test/$SNAKE" +fi + +# Replace in all text files +find . -type f \( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) -exec \ + sed -i '' \ + -e "s/my_plugin/$SNAKE/g" \ + -e "s/MyPlugin/$MODULE/g" \ + -e "s/my-plugin/$SNAKE/g" \ + {} + + +# Rename asset files +for ext in js css; do + if [ -f "assets/$ext/my_plugin.$ext" ]; then + mv "assets/$ext/my_plugin.$ext" "assets/$ext/$SNAKE.$ext" + fi + if [ -f "priv/static/my_plugin.$ext" ]; then + mv "priv/static/my_plugin.$ext" "priv/static/$SNAKE.$ext" + fi +done + +echo "Done. Review the changes, then:" +echo " 1. Edit manifest.json — set description, capabilities" +echo " 2. Run: mix deps.get && mix test" diff --git a/test/contract_test.exs b/test/contract_test.exs new file mode 100644 index 0000000..1038b2a --- /dev/null +++ b/test/contract_test.exs @@ -0,0 +1,86 @@ +defmodule MyPlugin.ContractTest do + @moduledoc """ + Contract compliance tests. + + These verify that the plugin conforms to the Tribes plugin contract. + When Tribes.Plugin.ContractTest is available (host dep loaded), + replace this file with: + + defmodule MyPlugin.ContractTest do + use Tribes.Plugin.ContractTest, + plugin: MyPlugin.Plugin, + otp_app: :my_plugin + end + + Until then, this file provides a standalone equivalent. + """ + + use ExUnit.Case, async: true + + @plugin MyPlugin.Plugin + @manifest_path Path.join(__DIR__, "../manifest.json") |> Path.expand() + + setup_all do + manifest = @manifest_path |> File.read!() |> Jason.decode!() + spec = @plugin.register(%{pubsub: nil, repo: nil}) + %{manifest: manifest, spec: spec} + end + + test "runtime provides matches manifest provides", %{manifest: manifest, spec: spec} do + normalise = fn cap -> + if String.contains?(cap, "@"), do: cap, else: "#{cap}@1" + end + + manifest_caps = manifest["provides"] |> Enum.map(normalise) |> Enum.sort() + runtime_caps = spec.provides |> Enum.map(normalise) |> Enum.sort() + + assert manifest_caps == runtime_caps, + "manifest provides #{inspect(manifest_caps)} != runtime provides #{inspect(runtime_caps)}" + end + + test "runtime requires matches manifest requires", %{manifest: manifest, spec: spec} do + normalise = fn cap -> + if String.contains?(cap, "@"), do: cap, else: "#{cap}@1" + end + + manifest_caps = manifest["requires"] |> Enum.map(normalise) |> Enum.sort() + runtime_caps = spec.requires |> Enum.map(normalise) |> Enum.sort() + + assert manifest_caps == runtime_caps, + "manifest requires #{inspect(manifest_caps)} != runtime requires #{inspect(runtime_caps)}" + end + + test "spec has all required keys", %{spec: spec} do + required_keys = [ + :name, + :version, + :provides, + :requires, + :nav_items, + :pages, + :children, + :global_js, + :global_css, + :migrations_path, + :hooks + ] + + for key <- required_keys do + assert Map.has_key?(spec, key), "spec must contain #{inspect(key)}" + end + end + + test "name in spec matches manifest", %{manifest: manifest, spec: spec} do + assert spec.name == manifest["name"] + end + + test "migrations_path exists if manifest declares migrations", %{manifest: manifest, spec: spec} do + if manifest["migrations"] do + assert is_binary(spec.migrations_path), + "migrations_path must be set when manifest declares migrations: true" + + assert File.dir?(spec.migrations_path), + "migrations_path #{inspect(spec.migrations_path)} must be an existing directory" + end + end +end diff --git a/test/my_plugin/manifest_test.exs b/test/my_plugin/manifest_test.exs new file mode 100644 index 0000000..d789aad --- /dev/null +++ b/test/my_plugin/manifest_test.exs @@ -0,0 +1,59 @@ +defmodule MyPlugin.ManifestTest do + use ExUnit.Case, async: true + + @manifest_path Path.join(__DIR__, "../../manifest.json") |> Path.expand() + + setup_all do + content = File.read!(@manifest_path) + manifest = Jason.decode!(content) + %{manifest: manifest} + end + + describe "manifest.json" do + test "is valid JSON", %{manifest: manifest} do + assert is_map(manifest) + end + + test "has required fields", %{manifest: manifest} do + required = ["name", "version", "entry_module", "host_api", "provides", "requires"] + + for field <- required do + assert Map.has_key?(manifest, field), + "manifest.json must contain #{inspect(field)}" + end + end + + test "name matches OTP app name", %{manifest: manifest} do + assert manifest["name"] == "my_plugin" + end + + test "entry_module is a valid Elixir module name", %{manifest: manifest} do + module_name = manifest["entry_module"] + assert is_binary(module_name) + assert String.starts_with?(module_name, "Elixir.") or not String.contains?(module_name, " ") + end + + test "provides contains valid capability identifiers", %{manifest: manifest} do + for cap <- manifest["provides"] do + assert Regex.match?(~r/^[a-z][a-z0-9_]*(@\d+)?$/, cap), + "invalid capability identifier: #{inspect(cap)}" + end + end + + test "requires contains valid capability identifiers", %{manifest: manifest} do + for cap <- manifest["requires"] do + assert Regex.match?(~r/^[a-z][a-z0-9_]*(@\d+)?$/, cap), + "invalid capability identifier: #{inspect(cap)}" + end + end + + test "host_api is a string version", %{manifest: manifest} do + assert is_binary(manifest["host_api"]) + end + + test "entry_module matches actual plugin module", %{manifest: manifest} do + module = String.to_atom("Elixir.#{manifest["entry_module"]}") + assert Code.ensure_loaded?(module), "entry_module #{manifest["entry_module"]} must be loadable" + end + end +end diff --git a/test/my_plugin/plugin_test.exs b/test/my_plugin/plugin_test.exs new file mode 100644 index 0000000..299486a --- /dev/null +++ b/test/my_plugin/plugin_test.exs @@ -0,0 +1,63 @@ +defmodule MyPlugin.PluginTest do + use ExUnit.Case, async: true + + describe "register/1" do + setup do + context = %{pubsub: nil, repo: nil} + %{spec: MyPlugin.Plugin.register(context)} + end + + test "returns plugin name and version", %{spec: spec} do + assert spec.name == "my_plugin" + assert is_binary(spec.version) + end + + test "provides is a list of capability strings", %{spec: spec} do + assert is_list(spec.provides) + + for cap <- spec.provides do + assert is_binary(cap), "capability must be a string, got: #{inspect(cap)}" + end + end + + test "requires is a list of capability strings", %{spec: spec} do + assert is_list(spec.requires) + + for cap <- spec.requires do + assert is_binary(cap), "capability must be a string, got: #{inspect(cap)}" + end + end + + test "nav items have required fields", %{spec: spec} do + assert is_list(spec.nav_items) + + for item <- spec.nav_items do + assert is_binary(item.label), "nav item must have a label" + assert is_binary(item.path), "nav item must have a path" + assert is_integer(item.order), "nav item must have an integer order" + end + end + + test "pages reference existing modules", %{spec: spec} do + assert is_list(spec.pages) + + for page <- spec.pages do + assert is_binary(page.path), "page must have a path" + assert is_atom(page.live_view), "page must have a live_view module" + assert Code.ensure_loaded?(page.live_view), "module #{page.live_view} must be loadable" + end + end + + test "children are valid child specs", %{spec: spec} do + assert is_list(spec.children) + end + + test "asset lists are string lists", %{spec: spec} do + assert is_list(spec.global_js) + assert is_list(spec.global_css) + + for js <- spec.global_js, do: assert(is_binary(js)) + for css <- spec.global_css, do: assert(is_binary(css)) + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()