From 664ed5222f0d62f4751608f2aa7aaae1e82f8260 Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Fri, 1 May 2026 23:40:06 +0200 Subject: [PATCH] feat: harden plugin template scaffolding Add AGENTS.md with usage_rules-generated guidance, host-aware smoke checks, TypeScript asset defaults, and plugin contract docs. Split Tribes path dependencies for dev/test so templates compile entry-module beams for host loading while preserving runtime-backed tests, and update rename.sh for bridge modules and TS assets. --- .gitignore | 4 + AGENTS.md | 2017 ++++++++++++++++++++++++++++++++++++++ README.md | 14 +- assets/js/my_plugin.js | 15 - assets/package-lock.json | 19 +- assets/package.json | 6 +- assets/ts/my_plugin.ts | 17 + assets/tsconfig.json | 14 + docs/checklist.md | 19 + docs/development.md | 48 + docs/plugin-contract.md | 43 + mix.exs | 19 +- mix.lock | 8 + scripts/plugin | 54 +- scripts/rename.sh | 11 +- 15 files changed, 2279 insertions(+), 29 deletions(-) create mode 100644 AGENTS.md delete mode 100644 assets/js/my_plugin.js create mode 100644 assets/ts/my_plugin.ts create mode 100644 assets/tsconfig.json create mode 100644 docs/checklist.md create mode 100644 docs/development.md create mode 100644 docs/plugin-contract.md diff --git a/.gitignore b/.gitignore index 83832e5..c17babd 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ devenv.local.nix /node_modules/ /assets/node_modules/ +# Built plugin assets +/priv/static/*.css +/priv/static/*.js + # Generated on crash erl_crash.dump diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..566db6e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,2017 @@ +# Tribes Plugin Template Agent Guide + +This repository is a template for Tribes plugins. Preserve template placeholders +unless a task explicitly asks to rename the template. + +## Required Workflow + +- Use `scripts/plugin` for plugin-aware commands: `scripts/plugin validate`, `scripts/plugin test`, `scripts/plugin precommit`, and `scripts/plugin smoke`. +- Use `devenv shell -- ` when installing or building Node assets from outside the devenv shell. Do not run raw `npm install` directly. +- Run `scripts/plugin smoke` after changing `mix.exs`, `manifest.json`, entry modules, or host-facing dependency setup. +- Keep commits semantic, for example `feat: add template smoke checks`, and include a body except for minimal patches. + +## Plugin Contract + +- `manifest.json` `entry_module` must point at `Tribes.Plugins.MyPlugin.Plugin` until the template is renamed. +- Keep the host bridge module at `lib/tribes/plugins/my_plugin/plugin.ex`; after renaming it should become `lib/tribes/plugins//plugin.ex`. +- The dev `:tribes` dependency is compile-only so host plugin loading can build `_build/dev` beams without starting a nested host app. +- The test `:tribes` dependency is runtime-enabled so host-backed tests can start `Tribes.Repo` and related services. +- If the plugin adds cluster-synced Ash resources, use `AshNostrSync` deliberately and document replication semantics in `docs/plugin-contract.md`. + +## Frontend + +- Prefer TypeScript in `assets/ts` for browser code. The default build emits browser-ready files to `priv/static`. +- Keep CSS selectors plugin-scoped, normally prefixed with `.my-plugin`, to avoid collisions with the host or other plugins. +- Use the package scripts in `assets/package.json`; do not bypass them with ad-hoc asset commands. + + + +## usage_rules usage +_A config-driven dev tool for Elixir projects to manage AGENTS.md files and agent skills from dependencies_ + +## Using Usage Rules + +Many packages have usage rules, which you should *thoroughly* consult before taking any +action. These usage rules contain guidelines and rules *directly from the package authors*. +They are your best source of knowledge for making decisions. + +## Modules & functions in the current app and dependencies + +When looking for docs for modules & functions that are dependencies of the current project, +or for Elixir itself, use `mix usage_rules.docs` + +``` +# Search a whole module +mix usage_rules.docs Enum + +# Search a specific function +mix usage_rules.docs Enum.zip + +# Search a specific function & arity +mix usage_rules.docs Enum.zip/1 +``` + + +## Searching Documentation + +You should also consult the documentation of any tools you are using, early and often. The best +way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have +found what you are looking for, use the links in the search results to get more detail. For example: + +``` +# Search docs for all packages in the current application, including Elixir +mix usage_rules.search_docs Enum.zip + +# Search docs for specific packages +mix usage_rules.search_docs Req.get -p req + +# Search docs for multi-word queries +mix usage_rules.search_docs "making requests" -p req + +# Search only in titles (useful for finding specific functions/modules) +mix usage_rules.search_docs "Enum.zip" --query-by title +``` + + + + +## usage_rules:elixir usage +# Elixir Core Usage Rules + +## Pattern Matching +- Use pattern matching over conditional logic when possible +- Prefer to match on function heads instead of using `if`/`else` or `case` in function bodies +- `%{}` matches ANY map, not just empty maps. Use `map_size(map) == 0` guard to check for truly empty maps + +## Error Handling +- Use `{:ok, result}` and `{:error, reason}` tuples for operations that can fail +- Avoid raising exceptions for control flow +- Use `with` for chaining operations that return `{:ok, _}` or `{:error, _}` + +## Common Mistakes to Avoid +- Elixir has no `return` statement, nor early returns. The last expression in a block is always returned. +- Don't use `Enum` functions on large collections when `Stream` is more appropriate +- Avoid nested `case` statements - refactor to a single `case`, `with` or separate functions +- Don't use `String.to_atom/1` on user input (memory leak risk) +- Lists and enumerables cannot be indexed with brackets. Use pattern matching or `Enum` functions +- Prefer `Enum` functions like `Enum.reduce` over recursion +- When recursion is necessary, prefer to use pattern matching in function heads for base case detection +- Using the process dictionary is typically a sign of unidiomatic code +- Only use macros if explicitly requested +- There are many useful standard library functions, prefer to use them where possible + +## Function Design +- Use guard clauses: `when is_binary(name) and byte_size(name) > 0` +- Prefer multiple function clauses over complex conditional logic +- Name functions descriptively: `calculate_total_price/2` not `calc/2` +- Predicate function names should not start with `is` and should end in a question mark. +- Names like `is_thing` should be reserved for guards + +## Data Structures +- Use structs over maps when the shape is known: `defstruct [:name, :age]` +- Prefer keyword lists for options: `[timeout: 5000, retries: 3]` +- Use maps for dynamic key-value data +- Prefer to prepend to lists `[new | list]` not `list ++ [new]` + +## Mix Tasks + +- Use `mix help` to list available mix tasks +- Use `mix help task_name` to get docs for an individual task +- Read the docs and options fully before using tasks + +## Testing +- Run tests in a specific file with `mix test test/my_test.exs` and a specific test with the line number `mix test path/to/test.exs:123` +- Limit the number of failed tests with `mix test --max-failures n` +- Use `@tag` to tag specific tests, and `mix test --only tag` to run only those tests +- Use `assert_raise` for testing expected exceptions: `assert_raise ArgumentError, fn -> invalid_function() end` +- Use `mix help test` to for full documentation on running tests + +## Debugging + +- Use `dbg/1` to print values while debugging. This will display the formatted value and other relevant information in the console. + + + +## usage_rules usage +_A config-driven dev tool for Elixir projects to manage AGENTS.md files and agent skills from dependencies_ + +## Using Usage Rules + +Many packages have usage rules, which you should *thoroughly* consult before taking any +action. These usage rules contain guidelines and rules *directly from the package authors*. +They are your best source of knowledge for making decisions. + +## Modules & functions in the current app and dependencies + +When looking for docs for modules & functions that are dependencies of the current project, +or for Elixir itself, use `mix usage_rules.docs` + +``` +# Search a whole module +mix usage_rules.docs Enum + +# Search a specific function +mix usage_rules.docs Enum.zip + +# Search a specific function & arity +mix usage_rules.docs Enum.zip/1 +``` + + +## Searching Documentation + +You should also consult the documentation of any tools you are using, early and often. The best +way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have +found what you are looking for, use the links in the search results to get more detail. For example: + +``` +# Search docs for all packages in the current application, including Elixir +mix usage_rules.search_docs Enum.zip + +# Search docs for specific packages +mix usage_rules.search_docs Req.get -p req + +# Search docs for multi-word queries +mix usage_rules.search_docs "making requests" -p req + +# Search only in titles (useful for finding specific functions/modules) +mix usage_rules.search_docs "Enum.zip" --query-by title +``` + + + + +## usage_rules:otp usage +# OTP Usage Rules + +## GenServer Best Practices +- Keep state simple and serializable +- Handle all expected messages explicitly +- Use `handle_continue/2` for post-init work +- Implement proper cleanup in `terminate/2` when necessary + +## Process Communication +- Use `GenServer.call/3` for synchronous requests expecting replies +- Use `GenServer.cast/2` for fire-and-forget messages. +- When in doubt, use `call` over `cast`, to ensure back-pressure +- Set appropriate timeouts for `call/3` operations + +## Fault Tolerance +- Set up processes such that they can handle crashing and being restarted by supervisors +- Use `:max_restarts` and `:max_seconds` to prevent restart loops + +## Task and Async +- Use `Task.Supervisor` for better fault tolerance +- Handle task failures with `Task.yield/2` or `Task.shutdown/2` +- Set appropriate task timeouts +- Use `Task.async_stream/3` for concurrent enumeration with back-pressure + + + +## phoenix:ecto usage +## Ecto Guidelines + +- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` +- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs` +- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` +- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed +- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields +- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct +- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied + + + +## phoenix:elixir usage +## Elixir guidelines + +- Elixir lists **do not support index based access via the access syntax** + + **Never do this (invalid)**: + + i = 0 + mylist = ["blue", "green"] + mylist[i] + + Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: + + i = 0 + mylist = ["blue", "green"] + Enum.at(mylist, i) + +- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc + you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: + + # INVALID: we are rebinding inside the `if` and the result never gets assigned + if connected?(socket) do + socket = assign(socket, :val, val) + end + + # VALID: we rebind the result of the `if` to a new variable + socket = + if connected?(socket) do + assign(socket, :val, val) + end + +- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors +- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets +- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) +- Don't use `String.to_atom/1` on user input (memory leak risk) +- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards +- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` +- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option + +## Mix guidelines + +- Read the docs and options before using tasks (by using `mix help task_name`) +- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` +- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason + +## Test guidelines + +- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests +- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests + - Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message: + + ref = Process.monitor(pid) + assert_receive {:DOWN, ^ref, :process, ^pid, :normal} + + - Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages + + + + +## phoenix:html usage +## Phoenix HTML guidelines + +- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` +- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated +- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]` +- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`) +- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) + +- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals. + + **Never do this (invalid)**: + + <%= if condition do %> + ... + <% else if other_condition %> + ... + <% end %> + + Instead **always** do this: + + <%= cond do %> + <% condition -> %> + ... + <% condition2 -> %> + ... + <% true -> %> + ... + <% end %> + +- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
+
+      
+        let obj = {key: "val"}
+      
+
+  Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
+
+- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
+
+      Text
+
+  and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
+
+  and **never** do this, since it's invalid (note the missing `[` and `]`):
+
+       ...
+      => Raises compile syntax error on invalid HEEx attr syntax
+
+- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
+- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
+- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
+
+  **Always** do this:
+
+      
+ {@my_assign} + <%= if @some_block_condition do %> + {@another_assign} + <% end %> +
+ + and **Never** do this – the program will terminate with a syntax error: + + <%!-- THIS IS INVALID NEVER EVER DO THIS --%> +
+ {if @invalid_block_construct do} + {end} +
+ + + +## phoenix:liveview usage +## Phoenix LiveView guidelines + +- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews +- **Avoid LiveComponent's** unless you have a strong, specific need for them +- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive` + +### LiveView streams + +- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations: + - basic append of N items - `stream(socket, :messages, [new_msg])` + - resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items) + - prepend to stream - `stream(socket, :messages, [new_msg], at: -1)` + - deleting items - `stream_delete(socket, :messages, msg)` + +- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be: + +
+
+ {msg.text} +
+
+ +- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**: + + def handle_event("filter", %{"filter" => filter}, socket) do + # re-fetch the messages based on the filter + messages = list_messages(filter) + + {:noreply, + socket + |> assign(:messages_empty?, messages == []) + # reset the stream with the new messages + |> stream(:messages, messages, reset: true)} + end + +- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes: + +
+ +
+ {task.name} +
+
+ + The above only works if the empty state is the only HTML block alongside the stream for-comprehension. + +- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items + along with the updated assign: + + def handle_event("edit_message", %{"message_id" => message_id}, socket) do + message = Chat.get_message!(message_id) + edit_form = to_form(Chat.change_message(message, %{content: message.content})) + + # re-insert message so @editing_message_id toggle logic takes effect for that stream item + {:noreply, + socket + |> stream_insert(:messages, message) + |> assign(:editing_message_id, String.to_integer(message_id)) + |> assign(:edit_form, edit_form)} + end + + And in the template: + +
+
+ {message.username} + <%= if @editing_message_id == message.id do %> + <%!-- Edit mode --%> + <.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit"> + ... + + <% end %> +
+
+ +- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections + +### LiveView JavaScript interop + +- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute +- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised + +LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx, +and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor. + +#### Inline colocated js hooks + +**Never** write raw embedded ` + +- colocated hooks are automatically integrated into the app.js bundle +- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber` + +#### External phx-hook + +External JS hooks (`
`) must be placed in `assets/js/` and passed to the +LiveSocket constructor: + + const MyHook = { + mounted() { ... } + } + let liveSocket = new LiveSocket("/live", Socket, { + hooks: { MyHook } + }); + +#### Pushing events between client and server + +Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle. +**Always** return or rebind the socket on `push_event/3` when pushing events: + + # re-bind socket so we maintain event state to be pushed + socket = push_event(socket, "my_event", %{...}) + + # or return the modified socket directly: + def handle_event("some_event", _, socket) do + {:noreply, push_event(socket, "my_event", %{...})} + end + +Pushed events can then be picked up in a JS hook with `this.handleEvent`: + + mounted() { + this.handleEvent("my_event", data => console.log("from server:", data)); + } + +Clients can also push an event to the server and receive a reply with `this.pushEvent`: + + mounted() { + this.el.addEventListener("click", e => { + this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply)); + }) + } + +Where the server handled it via: + + def handle_event("my_event", %{"one" => 1}, socket) do + {:reply, %{two: 2}, socket} + end + +### LiveView tests + +- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions +- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions +- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests +- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc +- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")` +- Instead of relying on testing text content, which can change, favor testing for the presence of key elements +- Focus on testing outcomes rather than implementation details +- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be +- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie: + + html = render(view) + document = LazyHTML.from_fragment(html) + matches = LazyHTML.filter(document, "your-complex-selector") + IO.inspect(matches, label: "Matches") + +### Form handling + +#### Creating a form from params + +If you want to create a form based on `handle_event` params: + + def handle_event("submitted", params, socket) do + {:noreply, assign(socket, form: to_form(params))} + end + +When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys. + +You can also specify a name to nest the params: + + def handle_event("submitted", %{"user" => user_params}, socket) do + {:noreply, assign(socket, form: to_form(user_params, as: :user))} + end + +#### Creating a form from changesets + +When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema: + + defmodule MyApp.Users.User do + use Ecto.Schema + ... + end + +And then you create a changeset that you pass to `to_form`: + + %MyApp.Users.User{} + |> Ecto.Changeset.change() + |> to_form() + +Once the form is submitted, the params will be available under `%{"user" => user_params}`. + +In the template, the form form assign can be passed to the `<.form>` function component: + + <.form for={@form} id="todo-form" phx-change="validate" phx-submit="save"> + <.input field={@form[:field]} type="text" /> + + +Always give the form an explicit, unique DOM ID, like `id="todo-form"`. + +#### Avoiding form errors + +**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**: + + <%!-- ALWAYS do this (valid) --%> + <.form for={@form} id="my-form"> + <.input field={@form[:field]} type="text" /> + + +And **never** do this: + + <%!-- NEVER do this (invalid) --%> + <.form for={@changeset} id="my-form"> + <.input field={@changeset[:field]} type="text" /> + + +- You are FORBIDDEN from accessing the changeset in the template as it will cause errors +- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset + + + +## phoenix:phoenix usage +## Phoenix guidelines + +- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. + +- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: + + scope "/admin", AppWeb.Admin do + pipe_through :browser + + live "/users", UserLive, :index + end + + the UserLive route would point to the `AppWeb.Admin.UserLive` module + +- `Phoenix.View` no longer is needed or included with Phoenix, don't use it + + + +## ash usage +_A declarative, extensible framework for building Elixir applications._ + +# Rules for working with Ash + +## Understanding Ash + +Ash is an opinionated, composable framework for building applications in Elixir. It provides a declarative approach to modeling your domain with resources at the center. Read documentation *before* attempting to use its features. Do not assume that you have prior knowledge of the framework or its conventions. + + + + +## ash:actions usage +# Actions + +- Create specific, well-named actions rather than generic ones +- Put all business logic inside action definitions +- Use hooks like `Ash.Changeset.after_action/2`, `Ash.Changeset.before_action/2` to add additional logic + inside the same transaction. +- Use hooks like `Ash.Changeset.after_transaction/2`, `Ash.Changeset.before_transaction/2` to add additional logic + outside the transaction. +- Use action arguments for inputs that need validation +- Use preparations to modify queries before execution +- Preparations support `where` clauses for conditional execution +- Use `only_when_valid?` to skip preparations when the query is invalid +- Use changes to modify changesets before execution +- Use validations to validate changesets before execution +- Prefer domain code interfaces to call actions instead of directly building queries/changesets and calling functions in the `Ash` module +- A resource could be *only generic actions*. This can be useful when you are using a resource only to model behavior. +- Instead of defining functions in the domain, you should be defining actions and exposing them through code interface calls in the domain. Use standard actions when they fit what you're doing and generic actions when you need arbitrary functionality. + +## Error Handling + +Functions to call actions, like `Ash.create` and code interfaces like `MyApp.Accounts.register_user` all return ok/error tuples. All have `!` variations, like `Ash.create!` and `MyApp.Accounts.register_user!`. Use the `!` variations when you want to "let it crash", like if looking something up that should definitely exist, or calling an action that should always succeed. Always prefer the raising `!` variation over something like `{:ok, user} = MyApp.Accounts.register_user(...)`. + +All Ash code returns errors in the form of `{:error, error_class}`. Ash categorizes errors into four main classes: + +1. **Forbidden** (`Ash.Error.Forbidden`) - Occurs when a user attempts an action they don't have permission to perform +2. **Invalid** (`Ash.Error.Invalid`) - Occurs when input data doesn't meet validation requirements +3. **Framework** (`Ash.Error.Framework`) - Occurs when there's an issue with how Ash is being used +4. **Unknown** (`Ash.Error.Unknown`) - Occurs for unexpected errors that don't fit the other categories + +These error classes help you catch and handle errors at an appropriate level of granularity. An error class will always be the "worst" (highest in the above list) error class from above. Each error class can contain multiple underlying errors, accessible via the `errors` field on the exception. + +## Using Validations + +Validations ensure that data meets your business requirements before it gets processed by an action. Unlike changes, validations cannot modify the changeset - they can only validate it or add errors. + +Validations work on both changesets and queries. Built-in validations that support queries include: +- `action_is`, `argument_does_not_equal`, `argument_equals`, `argument_in` +- `compare`, `confirm`, `match`, `negate`, `one_of`, `present`, `string_length` +- Custom validations that implement the `supports/1` callback + +Common validation patterns: + +```elixir +# Built-in validations with custom messages +validate compare(:age, greater_than_or_equal_to: 18) do + message "You must be at least 18 years old" +end +validate match(:email, "@") +validate one_of(:status, [:active, :inactive, :pending]) + +# Conditional validations with where clauses +validate present(:phone_number) do + where present(:contact_method) and eq(:contact_method, "phone") +end + +# only_when_valid? - skip validation if prior validations failed +validate expensive_validation() do + only_when_valid? true +end + +# Action-specific vs global validations +actions do + create :sign_up do + validate present([:email, :password]) # Only for this action + end + + read :search do + argument :email, :string + validate match(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/) # Validates query arguments + end +end + +validations do + validate present([:title, :body]), on: [:create, :update] # Multiple actions +end +``` + +- Create **custom validation modules** for complex validation logic: + ```elixir + defmodule MyApp.Validations.UniqueUsername do + use Ash.Resource.Validation + + @impl true + def init(opts), do: {:ok, opts} + + @impl true + def validate(changeset, _opts, _context) do + # Validation logic here + # Return :ok or {:error, message} + end + end + + # Usage in resource: + validate {MyApp.Validations.UniqueUsername, []} + ``` + +- Make validations **atomic** when possible to ensure they work correctly with direct database operations by implementing the `atomic/3` callback in custom validation modules. + + ```elixir + defmodule MyApp.Validations.IsEven do + # transform and validate opts + + use Ash.Resource.Validation + + @impl true + def init(opts) do + if is_atom(opts[:attribute]) do + {:ok, opts} + else + {:error, "attribute must be an atom!"} + end + end + + @impl true + # This is optional, but useful to have in addition to validation + # so you get early feedback for validations that can otherwise + # only run in the datalayer + def validate(changeset, opts, _context) do + value = Ash.Changeset.get_attribute(changeset, opts[:attribute]) + + if is_nil(value) || (is_number(value) && rem(value, 2) == 0) do + :ok + else + {:error, field: opts[:attribute], message: "must be an even number"} + end + end + + @impl true + def atomic(changeset, opts, context) do + {:atomic, + # the list of attributes that are involved in the validation + [opts[:attribute]], + # the condition that should cause the error + # here we refer to the new value or the current value + expr(rem(^atomic_ref(opts[:attribute]), 2) != 0), + # the error expression + expr( + error(^InvalidAttribute, %{ + field: ^opts[:attribute], + # the value that caused the error + value: ^atomic_ref(opts[:attribute]), + # the message to display + message: ^(context.message || "%{field} must be an even number"), + vars: %{field: ^opts[:attribute]} + }) + ) + } + end + end + ``` + +- **Avoid redundant validations** - Don't add validations that duplicate attribute constraints: + ```elixir + # WRONG - redundant validation + attribute :name, :string do + allow_nil? false + constraints min_length: 1 + end + + validate present(:name) do # Redundant! allow_nil? false already handles this + message "Name is required" + end + + validate attribute_does_not_equal(:name, "") do # Redundant! min_length: 1 already handles this + message "Name cannot be empty" + end + + # CORRECT - let attribute constraints handle basic validation + attribute :name, :string do + allow_nil? false + constraints min_length: 1 + end + ``` + +## Using Preparations + +Preparations modify queries before they're executed. They are used to add filters, sorts, or other query modifications based on the query context. + +Common preparation patterns: + +```elixir +# Built-in preparations +prepare build(sort: [created_at: :desc]) +prepare build(filter: [active: true]) + +# Conditional preparations with where clauses +prepare build(filter: [visible: true]) do + where argument_equals(:include_hidden, false) +end + +# only_when_valid? - skip preparation if prior validations failed +prepare expensive_preparation() do + only_when_valid? true +end + +# Action-specific vs global preparations +actions do + read :recent do + prepare build(sort: [created_at: :desc], limit: 10) + end +end + +preparations do + prepare build(filter: [deleted: false]), on: [:read, :update] +end +``` + +## Using Changes + +Changes allow you to modify the changeset before it gets processed by an action. Unlike validations, changes can manipulate attribute values, add attributes, or perform other data transformations. + +Common change patterns: + +```elixir +# Built-in changes with conditions +change set_attribute(:status, "pending") +change relate_actor(:creator) do + where present(:actor) +end +change atomic_update(:counter, expr(^counter + 1)) + +# Action-specific vs global changes +actions do + create :sign_up do + change set_attribute(:joined_at, expr(now())) # Only for this action + end +end + +changes do + change set_attribute(:updated_at, expr(now())), on: :update # Multiple actions + change manage_relationship(:items, type: :append), on: [:create, :update] +end +``` + +- Create **custom change modules** for reusable transformation logic: + ```elixir + defmodule MyApp.Changes.SlugifyTitle do + use Ash.Resource.Change + + def change(changeset, _opts, _context) do + title = Ash.Changeset.get_attribute(changeset, :title) + + if title do + slug = title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-") + Ash.Changeset.change_attribute(changeset, :slug, slug) + else + changeset + end + end + end + + # Usage in resource: + change {MyApp.Changes.SlugifyTitle, []} + ``` + +- Create a **change module with lifecycle hooks** to handle complex multi-step operations: + + ```elixir + defmodule MyApp.Changes.ProcessOrder do + use Ash.Resource.Change + + def change(changeset, _opts, context) do + changeset + |> Ash.Changeset.before_transaction(fn changeset -> + # Runs before the transaction starts + # Use for external API calls, logging, etc. + MyApp.ExternalService.reserve_inventory(changeset, scope: context) + changeset + end) + |> Ash.Changeset.before_action(fn changeset -> + # Runs inside the transaction before the main action + # Use for related database changes in the same transaction + Ash.Changeset.change_attribute(changeset, :processed_at, DateTime.utc_now()) + end) + |> Ash.Changeset.after_action(fn changeset, result -> + # Runs inside the transaction after the main action, only on success + # Use for related database changes that depend on the result + MyApp.Inventory.update_stock_levels(result, scope: context) + {changeset, result} + end) + |> Ash.Changeset.after_transaction(fn changeset, + {:ok, result} -> + # Runs after the transaction completes (success or failure) + # Use for notifications, external systems, etc. + MyApp.Mailer.send_order_confirmation(result, scope: context) + {changeset, result} + + {:error, error} -> + # Runs after the transaction completes (success or failure) + # Use for notifications, external systems, etc. + MyApp.Mailer.send_order_issue_notice(result, scope: context) + {:error, error} + end) + end + end + + # Usage in resource: + change {MyApp.Changes.ProcessOrder, []} + ``` + +## Atomic Changes + +Atomic changes execute directly in the database as part of the update query, without requiring the record to be loaded first. This provides better performance and correct behavior under concurrent updates. + +**Why atomic matters:** +- Avoids race conditions (e.g., incrementing a counter) +- Better performance (no round-trip to load the record) +- Required for bulk operations to work efficiently + +**Built-in atomic changes:** +```elixir +# Increment a counter atomically +change atomic_update(:view_count, expr(view_count + 1)) + +# Set a value using an expression +change set_attribute(:updated_at, expr(now())) +``` + +**Making custom changes atomic:** +Implement the `atomic/3` callback to support atomic execution: + +```elixir +defmodule MyApp.Changes.IncrementVersion do + use Ash.Resource.Change + + @impl true + def change(changeset, _opts, _context) do + # Fallback for non-atomic execution + current = Ash.Changeset.get_attribute(changeset, :version) || 0 + Ash.Changeset.change_attribute(changeset, :version, current + 1) + end + + @impl true + def atomic(_changeset, _opts, _context) do + # Atomic implementation - runs in the database + {:atomic, %{version: expr(coalesce(version, 0) + 1)}} + end +end +``` + +## Using `require_atomic? false` + +By default, update and destroy actions require all changes and validations to support atomic execution. If they don't, the action will raise an error. + +**IMPORTANT:** When you see `require_atomic? false` on an action, carefully consider whether it is truly necessary. This option should be used sparingly. + +**When `require_atomic? false` is needed:** +- The action has `before_action` or `around_action` hooks that need to read or modify the record +- A change reads the current record state (e.g., `Ash.Changeset.get_data/2`) and cannot be rewritten atomically +- Complex validations that cannot be expressed as database expressions + +**When `require_atomic? false` is NOT needed:** +- Simple attribute transformations (these can usually be made atomic) +- Setting timestamps or default values (use `expr(now())` instead) +- Incrementing counters (use `atomic_update/2`) +- After-action hooks (these don't prevent atomic execution) +- After-transaction hooks (these don't prevent atomic execution) + +```elixir +actions do + update :update do + # AVOID unless truly necessary + require_atomic? false + end + + update :increment_views do + # GOOD - fully atomic, no need to disable + change atomic_update(:view_count, expr(view_count + 1)) + end +end +``` + +If you find yourself adding `require_atomic? false`, first check if your changes and validations can be rewritten with `atomic/3` callbacks. Only disable atomic requirements when the action genuinely needs to read or manipulate the record in hooks. + +## Custom Modules vs. Anonymous Functions + +Prefer to put code in its own module and refer to that in changes, preparations, validations etc. + +For example, prefer this: + +```elixir +defmodule MyApp.MyDomain.MyResource.Changes.SlugifyName do + use Ash.Resource.Change + + def change(changeset, _, _) do + Ash.Changeset.before_action(changeset, fn changeset, _ -> + slug = MyApp.Slug.get() + Ash.Changeset.force_change_attribute(changeset, :slug, slug) + end) + end +end + +change MyApp.MyDomain.MyResource.Changes.SlugifyName +``` + +## Action Types + +- **Read**: For retrieving records +- **Create**: For creating records +- **Update**: For changing records +- **Destroy**: For removing records +- **Generic**: For custom operations that don't fit the other types + + + + + +## ash:aggregates usage +# Aggregates + +Aggregates allow you to retrieve summary information over groups of related data, like counts, sums, or averages. Define aggregates in the `aggregates` block of a resource. + +Aggregates can work over relationships or directly over unrelated resources: + +```elixir +aggregates do + # Related aggregates - use relationship path + count :published_post_count, :posts do + filter expr(published == true) + end + + sum :total_sales, :orders, :amount + + exists :is_admin, :roles do + filter expr(name == "admin") + end + + # Unrelated aggregates - use resource module directly + count :matching_profiles_count, Profile do + filter expr(name == parent(name)) + end + + sum :total_report_score, Report, :score do + filter expr(author_name == parent(name)) + end + + exists :has_reports, Report do + filter expr(author_name == parent(name)) + end +end +``` + +For unrelated aggregates, use `parent/1` to reference fields from the source resource. + +## Aggregate Types + +- **count**: Counts related items meeting criteria +- **sum**: Sums a field across related items +- **exists**: Returns boolean indicating if matching related items exist (also supports unrelated resources) +- **first**: Gets the first related value matching criteria +- **list**: Lists the related values for a specific field +- **max**: Gets the maximum value of a field +- **min**: Gets the minimum value of a field +- **avg**: Gets the average value of a field + +## Using Aggregates + +```elixir +# Using code interface options (preferred) +users = MyDomain.list_users!( + load: [:published_post_count, :total_sales], + query: [ + filter: [published_post_count: [greater_than: 5]], + sort: [published_post_count: :desc] + ] +) + +# Manual query building (for complex cases) +User |> Ash.Query.filter(published_post_count > 5) |> Ash.read!() + +# Loading on existing records +Ash.load!(users, :published_post_count) +``` + +### Join Filters + +For complex aggregates involving multiple relationships, use join filters: + +```elixir +aggregates do + sum :redeemed_deal_amount, [:redeems, :deal], :amount do + # Filter on the aggregate as a whole + filter expr(redeems.redeemed == true) + + # Apply filters to specific relationship steps + join_filter :redeems, expr(redeemed == true) + join_filter [:redeems, :deal], expr(active == parent(require_active)) + end +end +``` + +## Inline Aggregates + +Use aggregates inline within expressions: + +```elixir +# Related inline aggregates +calculate :grade_percentage, :decimal, expr( + count(answers, query: [filter: expr(correct == true)]) * 100 / + count(answers) +) + +# Unrelated inline aggregates +calculate :profile_count, :integer, expr( + count(Profile, filter: expr(name == parent(name))) +) + +calculate :stats, :map, expr(%{ + profiles: count(Profile, filter: expr(active == true)), + reports: count(Report, filter: expr(author_name == parent(name))), + has_active_profile: exists(Profile, active == true and name == parent(name)) +}) +``` + + + + +## ash:authorization usage +# Authorization + +- When performing administrative actions, you can bypass authorization with `authorize?: false` +- To run actions as a particular user, look that user up and pass it as the `actor` option +- Always set the actor on the query/changeset/input, not when calling the action +- Use policies to define authorization rules + +```elixir +# Good +Post +|> Ash.Query.for_read(:read, %{}, actor: current_user) +|> Ash.read!() + +# BAD, DO NOT DO THIS +Post +|> Ash.Query.for_read(:read, %{}) +|> Ash.read!(actor: current_user) +``` + +## Policies + +To use policies, add the `Ash.Policy.Authorizer` to your resource: + +```elixir +defmodule MyApp.Post do + use Ash.Resource, + domain: MyApp.Blog, + authorizers: [Ash.Policy.Authorizer] + + # Rest of resource definition... +end +``` + +## Policy Basics + +Policies determine what actions on a resource are permitted for a given actor. Define policies in the `policies` block: + +```elixir +policies do + # A simple policy that applies to all read actions + policy action_type(:read) do + # Authorize if record is public + authorize_if expr(public == true) + + # Authorize if actor is the owner + authorize_if relates_to_actor_via(:owner) + end + + # A policy for create actions + policy action_type(:create) do + # Only allow active users to create records + forbid_unless actor_attribute_equals(:active, true) + + # Ensure the record being created relates to the actor + authorize_if relating_to_actor(:owner) + end +end +``` + +## Policy Evaluation Flow + +Policies evaluate from top to bottom with the following logic: + +1. All policies that apply to an action must pass for the action to be allowed +2. Within each policy, checks evaluate from top to bottom +3. The first check that produces a decision determines the policy result +4. If no check produces a decision, the policy defaults to forbidden + +## IMPORTANT: Policy Check Logic + +**the first check that yields a result determines the policy outcome** + +```elixir +# WRONG - This is OR logic, not AND logic! +policy action_type(:update) do + authorize_if actor_attribute_equals(:admin?, true) # If this passes, policy passes + authorize_if relates_to_actor_via(:owner) # Only checked if first fails +end +``` + +To require BOTH conditions in that example, you would use `forbid_unless` for the first condition: + +```elixir +# CORRECT - This requires BOTH conditions +policy action_type(:update) do + forbid_unless actor_attribute_equals(:admin?, true) # Must be admin + authorize_if relates_to_actor_via(:owner) # AND must be owner +end +``` + +Alternative patterns for AND logic: +- Use multiple separate policies (each must pass independently) +- Use a single complex expression with `expr(condition1 and condition2)` +- Use `forbid_unless` for required conditions, then `authorize_if` for the final check + +## Bypass Policies + +Use bypass policies to allow certain actors to bypass other policy restrictions. This should be used almost exclusively for admin bypasses. + +```elixir +policies do + # Bypass policy for admins - if this passes, other policies don't need to pass + bypass actor_attribute_equals(:admin, true) do + authorize_if always() + end + + # Regular policies follow... + policy action_type(:read) do + # ... + end +end +``` + +## Field Policies + +Field policies control access to specific fields (attributes, calculations, aggregates): + +```elixir +field_policies do + # Only supervisors can see the salary field + field_policy :salary do + authorize_if actor_attribute_equals(:role, :supervisor) + end + + # Allow access to all other fields + field_policy :* do + authorize_if always() + end +end +``` + +## Policy Checks + +There are two main types of checks used in policies: + +1. **Simple checks** - Return true/false answers (e.g., "is the actor an admin?") +2. **Filter checks** - Return filters to apply to data (e.g., "only show records owned by the actor") + +You can use built-in checks or create custom ones: + +```elixir +# Built-in checks +authorize_if actor_attribute_equals(:role, :admin) +authorize_if relates_to_actor_via(:owner) +authorize_if expr(public == true) + +# Custom check module +authorize_if MyApp.Checks.ActorHasPermission +``` + +### Custom Policy Checks + +Create custom checks by implementing `Ash.Policy.SimpleCheck` or `Ash.Policy.FilterCheck`: + +```elixir +# Simple check - returns true/false +defmodule MyApp.Checks.ActorHasRole do + use Ash.Policy.SimpleCheck + + def match?(%{role: actor_role}, _context, opts) do + actor_role == (opts[:role] || :admin) + end + def match?(_, _, _), do: false +end + +# Filter check - returns query filter +defmodule MyApp.Checks.VisibleToUserLevel do + use Ash.Policy.FilterCheck + + def filter(actor, _authorizer, _opts) do + expr(visibility_level <= ^actor.user_level) + end +end + +# Usage +policy action_type(:read) do + authorize_if {MyApp.Checks.ActorHasRole, role: :manager} + authorize_if MyApp.Checks.VisibleToUserLevel +end +``` + + + + +## ash:calculations usage +# Calculations + +Calculations allow you to define derived values based on a resource's attributes or related data. Define calculations in the `calculations` block of a resource: + +```elixir +calculations do + # Simple expression calculation + calculate :full_name, :string, expr(first_name <> " " <> last_name) + + # Expression with conditions + calculate :status_label, :string, expr( + cond do + status == :active -> "Active" + status == :pending -> "Pending Review" + true -> "Inactive" + end + ) + + # Using module calculations for more complex logic + calculate :risk_score, :integer, {MyApp.Calculations.RiskScore, min: 0, max: 100} +end +``` + +## Expression Calculations + +Expression calculations use Ash expressions and can be pushed down to the data layer when possible: + +```elixir +calculations do + # Simple string concatenation + calculate :full_name, :string, expr(first_name <> " " <> last_name) + + # Math operations + calculate :total_with_tax, :decimal, expr(amount * (1 + tax_rate)) + + # Date manipulation + calculate :days_since_created, :integer, expr( + date_diff(^now(), inserted_at, :day) + ) +end +``` + +## Expressions + +In order to use expressions outside of resources, changes, preparations etc. you will need to use `Ash.Expr`. + +It provides both `expr/1` and template helpers like `actor/1` and `arg/1`. + +For example: + +```elixir +import Ash.Expr + +Author +|> Ash.Query.aggregate(:count_of_my_favorited_posts, :count, [:posts], query: [ + filter: expr(favorited_by(user_id: ^actor(:id))) +]) +``` + +See the expressions guide for more information on what is available in expresisons and +how to use them. + +## Module Calculations + +For complex calculations, create a module that implements `Ash.Resource.Calculation`: + +```elixir +defmodule MyApp.Calculations.FullName do + use Ash.Resource.Calculation + + # Validate and transform options + @impl true + def init(opts) do + {:ok, Map.put_new(opts, :separator, " ")} + end + + # Specify what data needs to be loaded + @impl true + def load(_query, _opts, _context) do + [:first_name, :last_name] + end + + # Implement the calculation logic + @impl true + def calculate(records, opts, _context) do + Enum.map(records, fn record -> + [record.first_name, record.last_name] + |> Enum.reject(&is_nil/1) + |> Enum.join(opts.separator) + end) + end +end + +# Usage in a resource +calculations do + calculate :full_name, :string, {MyApp.Calculations.FullName, separator: ", "} +end +``` + +## Calculations with Arguments + +You can define calculations that accept arguments: + +```elixir +calculations do + calculate :full_name, :string, expr(first_name <> ^arg(:separator) <> last_name) do + argument :separator, :string do + allow_nil? false + default " " + constraints [allow_empty?: true, trim?: false] + end + end +end +``` + +## Using Calculations + +```elixir +# Using code interface options (preferred) +users = MyDomain.list_users!(load: [full_name: [separator: ", "]]) + +# Filtering and sorting +users = MyDomain.list_users!( + query: [ + filter: [full_name: [separator: " ", value: "John Doe"]], + sort: [full_name: {[separator: " "], :asc}] + ] +) + +# Manual query building (for complex cases) +User |> Ash.Query.load(full_name: [separator: ", "]) |> Ash.read!() + +# Loading on existing records +Ash.load!(users, :full_name) +``` + +### Code Interface for Calculations + +Define calculation functions on your domain for standalone use: + +```elixir +# In your domain +resource User do + define_calculation :full_name, args: [:first_name, :last_name, {:optional, :separator}] +end + +# Then call it directly +MyDomain.full_name("John", "Doe", ", ") # Returns "John, Doe" +``` + + + + +## ash:code_interfaces usage +# Code Interfaces + +Domains and Resources can define code interfaces. Prefer writing code interfaces instead of regular elixir functions. + +Use code interfaces on domains to define the contract for calling into Ash resources. See the [Code interface guide for more](https://hexdocs.pm/ash/code-interfaces.html). + +Define code interfaces on the domain, like this: + +```elixir +resource ResourceName do + define :fun_name, action: :action_name +end +``` + +For more complex interfaces with custom transformations: + +```elixir +define :custom_action do + action :action_name + args [:arg1, :arg2] + + custom_input :arg1, MyType do + transform do + to :target_field + using &MyModule.transform_function/1 + end + end +end +``` + +Prefer using the primary read action for "get" style code interfaces, and using `get_by` when the field you are looking up by is the primary key or has an `identity` on the resource. + +```elixir +resource ResourceName do + define :get_thing, action: :read, get_by: [:id] +end +``` + +**Avoid direct Ash calls in web modules** - Don't use `Ash.get!/2` and `Ash.load!/2` directly in LiveViews/Controllers, similar to avoiding `Repo.get/2` outside context modules: + +You can also pass additional inputs in to code interfaces before the options: + +```elixir +resource ResourceName do + define :create, action: :action_name, args: [:field1] +end +``` + +```elixir +Domain.create!(field1_value, %{field2: field2_value}, actor: current_user) +``` + +You should generally prefer using this map of extra inputs over defining optional arguments. + +```elixir +# BAD - in LiveView/Controller +group = MyApp.Resource |> Ash.get!(id) |> Ash.load!(rel: [:nested]) + +# GOOD - use code interface with get_by +resource DashboardGroup do + define :get_dashboard_group_by_id, action: :read, get_by: [:id] +end + +# Then call: +MyApp.Domain.get_dashboard_group_by_id!(id, load: [rel: [:nested]]) +``` + +**Code interface options** - Prefer passing options directly to code interface functions rather than building queries manually: + +```elixir +# PREFERRED - Use the query option for filter, sort, limit, etc. +# the query option is passed to `Ash.Query.build/2` +posts = MyApp.Blog.list_posts!( + query: [ + filter: [status: :published], + sort: [published_at: :desc], + limit: 10 + ], + load: [author: :profile, comments: [:author]] +) + +# All query-related options go in the query parameter +users = MyApp.Accounts.list_users!( + query: [filter: [active: true], sort: [created_at: :desc]], + load: [:profile] +) + +# AVOID - Verbose manual query building +query = MyApp.Post |> Ash.Query.filter(...) |> Ash.Query.load(...) +posts = Ash.read!(query) +``` + +Supported options: `load:`, `query:` (which accepts `filter:`, `sort:`, `limit:`, `offset:`, etc.), `page:`, `stream?:` + +**Using Scopes in LiveViews** - When using `Ash.Scope`, the scope will typically be assigned to `scope` in LiveViews and used like so: + +```elixir +# In your LiveView +MyApp.Blog.create_post!("new post", scope: socket.assigns.scope) +``` + +Inside action hooks and callbacks, use the provided `context` parameter as your scope instead: + +```elixir +|> Ash.Changeset.before_transaction(fn changeset, context -> + MyApp.ExternalService.reserve_inventory(changeset, scope: context) + changeset +end) +``` + +## Authorization Functions + +For each action defined in a code interface, Ash automatically generates corresponding authorization check functions: + +- `can_action_name?(actor, params \\ %{}, opts \\ [])` - Returns `true`/`false` for authorization checks +- `can_action_name(actor, params \\ %{}, opts \\ [])` - Returns `{:ok, true/false}` or `{:error, reason}` + +Example usage: +```elixir +# Check if user can create a post +if MyApp.Blog.can_create_post?(current_user) do + # Show create button +end + +# Check if user can update a specific post +if MyApp.Blog.can_update_post?(current_user, post) do + # Show edit button +end + +# Check if user can destroy a specific comment +if MyApp.Blog.can_destroy_comment?(current_user, comment) do + # Show delete button +end +``` + +These functions are particularly useful for conditional rendering of UI elements based on user permissions. + + + + +## ash:code_structure usage +# Code Structure & Organization + +- Organize code around domains and resources +- Each resource should be focused and well-named +- Create domain-specific actions rather than generic CRUD operations +- Put business logic inside actions rather than in external modules +- Use resources to model your domain entities + + + +## ash:data_layers usage +# Data Layers + +Data layers determine how resources are stored and retrieved. Examples of data layers: + +- **Postgres**: For storing resources in PostgreSQL (via `AshPostgres`) +- **ETS**: For in-memory storage (`Ash.DataLayer.Ets`) +- **Mnesia**: For distributed storage (`Ash.DataLayer.Mnesia`) +- **Embedded**: For resources embedded in other resources (`data_layer: :embedded`) (typically JSON under the hood) +- **Ash.DataLayer.Simple**: For resources that aren't persisted at all. Leave off the data layer, as this is the default. + +Specify a data layer when defining a resource: + +```elixir +defmodule MyApp.Post do + use Ash.Resource, + domain: MyApp.Blog, + data_layer: AshPostgres.DataLayer + + postgres do + table "posts" + repo MyApp.Repo + end + + # ... attributes, relationships, etc. +end +``` + +For embedded resources: + +```elixir +defmodule MyApp.Address do + use Ash.Resource, + data_layer: :embedded + + attributes do + attribute :street, :string + attribute :city, :string + attribute :state, :string + attribute :zip, :string + end +end +``` + +Each data layer has its own configuration options and capabilities. Refer to the rules & documentation of the specific data layer package for more details. + + + + +## ash:exist_expressions usage +# Exists Expressions + +Use `exists/2` to check for the existence of records, either through relationships or unrelated resources: + +### Related Exists + +```elixir +# Check if user has any admin roles +Ash.Query.filter(User, exists(roles, name == "admin")) + +# Check if post has comments with high scores +Ash.Query.filter(Post, exists(comments, score > 50)) +``` + +### Unrelated Exists + +```elixir +# Check if any profile exists with the same name +Ash.Query.filter(User, exists(Profile, name == parent(name))) + +# Check if user has any reports +Ash.Query.filter(User, exists(Report, author_name == parent(name))) + +# Complex existence checks +Ash.Query.filter(User, + active == true and + exists(Profile, active == true and name == parent(name)) +) +``` + +Unrelated exists expressions automatically apply authorization using the target resource's primary read action. Use `parent/1` to reference fields from the source resource. + + + +## ash:generating_code usage +# Generating Code + +Use `mix ash.gen.*` tasks as a basis for code generation when possible. Check the task docs with `mix help `. +Be sure to use `--yes` to bypass confirmation prompts. Use `--yes --dry-run` to preview the changes. + + + + +## ash:migrations usage +# Migrations and Schema Changes + +After creating or modifying Ash code, run `mix ash.codegen ` to ensure any required additional changes are made (like migrations are generated). The name of the migration should be lower_snake_case. In a longer running dev session it's usually better to use `mix ash.codegen --dev` as you go and at the end run the final codegen with a sensible name describing all the changes made in the session. + + + + +## ash:query_filter usage +# Ash.Query.filter is a macro + +**Important**: You must `require Ash.Query` if you want to use `Ash.Query.filter/2`, as it is a macro. + +If you see errors like the following: + +``` +Ash.Query.filter(MyResource, id == ^id) +error: misplaced operator ^id + +The pin operator ^ is supported only inside matches or inside custom macros... +``` + +``` +iex(3)> Ash.Query.filter(MyResource, something == true) +error: undefined variable "something" +└─ iex:3 +``` + +You are very likely missing a `require Ash.Query` + +## Common Query Operations + +- **Filter**: `Ash.Query.filter(query, field == value)` +- **Sort**: `Ash.Query.sort(query, field: :asc)` +- **Load relationships**: `Ash.Query.load(query, [:author, :comments])` +- **Limit**: `Ash.Query.limit(query, 10)` +- **Offset**: `Ash.Query.offset(query, 20)` + + + + +## ash:querying_data usage +# Querying Data + +Use `Ash.Query` to build queries for reading data from your resources. The query module provides a declarative way to filter, sort, and load data. + + + + +## ash:relationships usage +# Relationships + +Relationships describe connections between resources and are a core component of Ash. Define relationships in the `relationships` block of a resource. + +## Best Practices for Relationships + +- Be descriptive with relationship names (e.g., use `:authored_posts` instead of just `:posts`) +- Configure foreign key constraints in your data layer if they have them (see `references` in AshPostgres) +- Always choose the appropriate relationship type based on your domain model + +### Relationship Types + +- For Polymorphic relationships, you can model them using `Ash.Type.Union`; see the “Polymorphic Relationships” guide for more information. + +```elixir +relationships do + # belongs_to - adds foreign key to source resource + belongs_to :owner, MyApp.User do + allow_nil? false + attribute_type :integer # defaults to :uuid + end + + # has_one - foreign key on destination resource + has_one :profile, MyApp.Profile + + # has_many - foreign key on destination resource, returns list + has_many :posts, MyApp.Post do + filter expr(published == true) + sort published_at: :desc + end + + # many_to_many - requires join resource + many_to_many :tags, MyApp.Tag do + through MyApp.PostTag + source_attribute_on_join_resource :post_id + destination_attribute_on_join_resource :tag_id + end +end +``` + +The join resource must be defined separately: + +```elixir +defmodule MyApp.PostTag do + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + attributes do + uuid_primary_key :id + # Add additional attributes if you need metadata on the relationship + attribute :added_at, :utc_datetime_usec do + default &DateTime.utc_now/0 + end + end + + relationships do + belongs_to :post, MyApp.Post, primary_key?: true, allow_nil?: false + belongs_to :tag, MyApp.Tag, primary_key?: true, allow_nil?: false + end + + actions do + defaults [:read, :destroy, create: :*, update: :*] + end +end +``` + +## Loading Relationships + +```elixir +# Using code interface options (preferred) +post = MyDomain.get_post!(id, load: [:author, comments: [:author]]) + +# Complex loading with filters +posts = MyDomain.list_posts!( + query: [load: [comments: [filter: [is_approved: true], limit: 5]]] +) + +# Manual query building (for complex cases) +MyApp.Post +|> Ash.Query.load(comments: MyApp.Comment |> Ash.Query.filter(is_approved == true)) +|> Ash.read!() + +# Loading on existing records +Ash.load!(post, :author) +``` + +Prefer to use the `strict?` option when loading to only load necessary fields on related data. + +```elixir +MyApp.Post +|> Ash.Query.load([comments: [:title]], strict?: true) +``` + +## Managing Relationships + +There are two primary ways to manage relationships in Ash: + +### 1. Using `change manage_relationship/2-3` in Actions +Use this when input comes from action arguments: + +```elixir +actions do + update :update do + # Define argument for the related data + argument :comments, {:array, :map} do + allow_nil? false + end + + argument :new_tags, {:array, :map} + + # Link argument to relationship management + change manage_relationship(:comments, type: :append) + + # For different argument and relationship names + change manage_relationship(:new_tags, :tags, type: :append) + end +end +``` + +### 2. Using `Ash.Changeset.manage_relationship/3-4` in Custom Changes +Use this when building values programmatically: + +```elixir +defmodule MyApp.Changes.AssignTeamMembers do + use Ash.Resource.Change + + def change(changeset, _opts, context) do + members = determine_team_members(changeset, context.actor) + + Ash.Changeset.manage_relationship( + changeset, + :members, + members, + type: :append_and_remove + ) + end +end +``` + +### Quick Reference - Management Types +- `:append` - Add new related records, ignore existing +- `:append_and_remove` - Add new related records, remove missing +- `:remove` - Remove specified related records +- `:direct_control` - Full CRUD control (create/update/destroy) +- `:create` - Only create new records + +### Quick Reference - Common Options +- `on_lookup: :relate` - Look up and relate existing records +- `on_no_match: :create` - Create if no match found +- `on_match: :update` - Update existing matches +- `on_missing: :destroy` - Delete records not in input +- `value_is_key: :name` - Use field as key for simple values + +For comprehensive documentation, see the [Managing Relationships](https://hexdocs.pm/ash/relationships.html#managing-relationships) section. + +### Examples + +Creating a post with tags: +```elixir +MyDomain.create_post!(%{ + title: "New Post", + body: "Content here...", + tags: [%{name: "elixir"}, %{name: "ash"}] # Creates new tags +}) + +# Updating a post to replace its tags +MyDomain.update_post!(post, %{ + tags: [tag1.id, tag2.id] # Replaces tags with existing ones by ID +}) +``` + + + + +## ash:testing usage +# Testing + +When testing resources: +- Test your domain actions through the code interface +- Use test utilities in `Ash.Test` +- Test authorization policies work as expected using `Ash.can?` +- Use `authorize?: false` in tests where authorization is not the focus +- Write generators using `Ash.Generator` +- Prefer to use raising versions of functions whenever possible, as opposed to pattern matching + +## Preventing Deadlocks in Concurrent Tests + +When running tests concurrently, using fixed values for identity attributes can cause deadlock errors. Multiple tests attempting to create records with the same unique values will conflict. + +### Use Globally Unique Values + +Always use globally unique values for identity attributes in tests: + +```elixir +# BAD - Can cause deadlocks in concurrent tests +%{email: "test@example.com", username: "testuser"} + +# GOOD - Use globally unique values +%{ + email: "test-#{System.unique_integer([:positive])}@example.com", + username: "user_#{System.unique_integer([:positive])}", + slug: "post-#{System.unique_integer([:positive])}" +} +``` + +### Creating Reusable Test Generators + +For better organization, create a generator module: + +```elixir +defmodule MyApp.TestGenerators do + use Ash.Generator + + def user(opts \\ []) do + changeset_generator( + User, + :create, + defaults: [ + email: "user-#{System.unique_integer([:positive])}@example.com", + username: "user_#{System.unique_integer([:positive])}" + ], + overrides: opts + ) + end +end + +# In your tests +test "concurrent user creation" do + users = MyApp.TestGenerators.generate_many(user(), 10) + # Each user has unique identity attributes +end +``` + +This applies to ANY field used in identity constraints, not just primary keys. Using globally unique values prevents frustrating intermittent test failures in CI environments. + + + diff --git a/README.md b/README.md index ee687ac..9fd75bd 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,12 @@ cd your-plugin 3. Edit `manifest.json` — set description, capabilities, requirements 4. Implement your plugin in `lib/your_plugin/plugin.ex` -5. Run validation and tests: +5. Run validation, smoke checks, and tests: ```bash mix deps.get mix tribes.plugin.validate +scripts/plugin smoke mix test ``` @@ -27,6 +28,7 @@ If you are using the shared `tribes` devenv, run through the local helper instea ```bash devenv shell -- plugin validate +devenv shell -- plugin smoke devenv shell -- plugin test devenv shell -- plugin precommit ``` @@ -68,6 +70,7 @@ Inside the plugin repo's devenv shell, the `plugin` helper forwards to the host ```bash plugin validate +plugin smoke plugin test plugin precommit ``` @@ -75,7 +78,7 @@ plugin precommit 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. +you in development. The default template uses TypeScript under `assets/ts`. ## Project Structure @@ -90,8 +93,10 @@ your_plugin/ │ │ └── application.ex # OTP supervision tree (optional) │ └── your_plugin_web/ │ └── live/ # LiveView pages -├── assets/ # JS/CSS (one bundle per plugin) +├── assets/ # TS/CSS (one bundle per plugin) +│ ├── ts/ # TypeScript browser entry points │ ├── package.json # Optional build script used by dev + Guix packaging +│ ├── tsconfig.json # TypeScript compiler settings │ └── package-lock.json # Optional, recommended for reproducible builds ├── priv/ │ ├── static/ # Built assets for release @@ -156,6 +161,7 @@ Run them with: ```bash mix tribes.plugin.validate +scripts/plugin smoke mix test ``` @@ -166,7 +172,7 @@ so the plugin test suite reuses the host database and services. ```bash MIX_ENV=prod mix compile -npm run build --prefix assets +devenv shell -- npm run build --prefix assets mkdir -p dist/your_plugin cp -r _build/prod/lib/your_plugin/ebin dist/your_plugin/ diff --git a/assets/js/my_plugin.js b/assets/js/my_plugin.js deleted file mode 100644 index c8116f1..0000000 --- a/assets/js/my_plugin.js +++ /dev/null @@ -1,15 +0,0 @@ -// 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/assets/package-lock.json b/assets/package-lock.json index 261eb12..0bde9dc 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -6,7 +6,24 @@ "packages": { "": { "name": "my-plugin-assets", - "version": "0.1.0" + "version": "0.1.0", + "devDependencies": { + "typescript": "^6.0.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } } } } diff --git a/assets/package.json b/assets/package.json index a333a51..57fda4c 100644 --- a/assets/package.json +++ b/assets/package.json @@ -3,6 +3,10 @@ "private": true, "version": "0.1.0", "scripts": { - "build": "mkdir -p ../priv/static && cp -r css/. ../priv/static && cp -r js/. ../priv/static" + "build": "npm run check && mkdir -p ../priv/static && cp -r css/. ../priv/static && tsc --project tsconfig.json", + "check": "tsc --project tsconfig.json --noEmit" + }, + "devDependencies": { + "typescript": "^6.0.3" } } diff --git a/assets/ts/my_plugin.ts b/assets/ts/my_plugin.ts new file mode 100644 index 0000000..4648dc2 --- /dev/null +++ b/assets/ts/my_plugin.ts @@ -0,0 +1,17 @@ +// Plugin TypeScript entry point. +// +// This file is compiled to /priv/static/my_plugin.js, served by the host at +// /plugins-assets/my_plugin/my_plugin.js, and included when declared in +// manifest.json assets.global_js. + +declare global { + interface Window { + TribesPluginHooks?: Record; + } +} + +window.TribesPluginHooks = window.TribesPluginHooks ?? {}; + +console.info("my_plugin loaded"); + +export {}; diff --git a/assets/tsconfig.json b/assets/tsconfig.json new file mode 100644 index 0000000..3324755 --- /dev/null +++ b/assets/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "skipLibCheck": true, + "outDir": "../priv/static", + "rootDir": "ts" + }, + "include": ["ts/**/*.ts"] +} diff --git a/docs/checklist.md b/docs/checklist.md new file mode 100644 index 0000000..8782d44 --- /dev/null +++ b/docs/checklist.md @@ -0,0 +1,19 @@ +# Checklist + +## After Renaming + +- Run `./scripts/rename.sh your_plugin YourPlugin`. +- Confirm `manifest.json` `name`, `otp_app`, and `entry_module`. +- Confirm the bridge module path is `lib/tribes/plugins/your_plugin/plugin.ex`. +- Rename asset files and update manifest asset names if needed. +- Run `mix deps.get`. +- Run `scripts/plugin smoke`. +- Run `scripts/plugin test`. + +## Before Committing + +- Run `mix format`. +- Run `scripts/plugin precommit`. +- If assets changed, run `devenv shell -- npm run build --prefix assets`. +- Check `git status --short` for generated files that should not be committed. +- Commit with a semantic subject and a useful body unless the patch is minimal. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..16faecd --- /dev/null +++ b/docs/development.md @@ -0,0 +1,48 @@ +# Development + +## Local Setup + +This template expects a sibling Tribes checkout at `../tribes`. + +```bash +mix deps.get +devenv shell -- plugin test +``` + +To load the plugin in the host during development: + +```bash +cd ../tribes +ln -s ../tribes-plugin-template plugins/my_plugin +iex --sname dev -S mix phx.server +``` + +Set `TRIBES_HOST_ROOT=/path/to/tribes` if your host checkout is not a sibling. + +## Command Wrapper + +Use `scripts/plugin` for host-aware workflows: + +```bash +scripts/plugin validate +scripts/plugin test +scripts/plugin precommit +scripts/plugin smoke +``` + +Inside the template devenv shell, the `plugin` command is available and forwards +through the same wrapper. + +## Assets + +Browser code lives in `assets/ts` and is compiled to `priv/static`: + +```bash +devenv shell -- npm run build --prefix assets +``` + +Use the wrapper form for installs too: + +```bash +devenv shell -- npm install --prefix assets +``` diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md new file mode 100644 index 0000000..f5580f9 --- /dev/null +++ b/docs/plugin-contract.md @@ -0,0 +1,43 @@ +# Plugin Contract + +## Manifest + +`manifest.json` is the runtime contract consumed by Tribes: + +- `name` and `otp_app` must match the Mix application name. +- `entry_module` must be a loadable `Tribes.Plugins.*.Plugin` module. +- `provides` declares capabilities exported by the plugin. +- `requires` declares hard host capabilities. +- `enhances_with` declares optional host capabilities. +- `migrations` should be `true` when `priv/repo/migrations` contains plugin migrations. +- `children` should be `true` when the plugin starts its own supervision tree. + +## Entry Modules + +The template uses a thin host bridge: + +```elixir +defmodule Tribes.Plugins.MyPlugin.Plugin do + defdelegate register(context), to: MyPlugin.Plugin +end +``` + +Keep plugin implementation code in `MyPlugin.Plugin`. Keep the bridge stable so +the host plugin manager can load it from the plugin build output. + +## Host Dependencies + +The template intentionally splits the `:tribes` path dependency by environment: + +- In `:dev`, `:tribes` is compile-only. This lets `mix compile` create the + entry-module beam expected by the host plugin manager without starting a + nested Tribes application. +- In `:test`, `:tribes` is runtime-enabled. Host-backed tests need the real + repository, endpoint pipeline, and plugin contract helpers. + +## Ash Resources + +For cluster-synced plugin data, add Ash resources under the plugin namespace and +use `extensions: [AshNostrSync]` only for data that should replicate across the +cluster. Prefer one UUID primary key per synced resource. Document any local-only +tables, retention policies, or side effects in plugin docs. diff --git a/mix.exs b/mix.exs index 62c5b8c..e6fed79 100644 --- a/mix.exs +++ b/mix.exs @@ -9,7 +9,8 @@ defmodule MyPlugin.MixProject do elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), - aliases: aliases() + aliases: aliases(), + usage_rules: usage_rules() ] end @@ -37,12 +38,24 @@ defmodule MyPlugin.MixProject do # For CI or standalone development, this can be replaced with a published # package once tribes_plugin_api is released. {:tribes_plugin_api, path: "../tribes/tribes_plugin_api", runtime: false}, - {:tribes, path: "../tribes", only: :test}, + {:ash, "~> 3.0", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:lazy_html, ">= 0.1.0", only: :test}, {:phoenix, "~> 1.8"}, {:phoenix_html, "~> 4.1"}, - {:phoenix_live_view, "~> 1.1.0"} + {:phoenix_live_view, "~> 1.1.0"}, + {:usage_rules, "~> 1.2", only: :dev} + ] ++ tribes_deps(Mix.env()) + end + + defp tribes_deps(:dev), do: [{:tribes, path: "../tribes", only: :dev, runtime: false}] + defp tribes_deps(:test), do: [{:tribes, path: "../tribes", only: :test}] + defp tribes_deps(_), do: [] + + defp usage_rules do + [ + file: "AGENTS.md", + usage_rules: [:elixir, :otp, :phoenix, ~r/^phoenix_/, :ash] ] end diff --git a/mix.lock b/mix.lock index 86ac64e..4037cd0 100644 --- a/mix.lock +++ b/mix.lock @@ -43,8 +43,10 @@ "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, + "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, @@ -57,6 +59,7 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, "parrhesia": {:hex, :parrhesia, "0.12.0", "67f33b62a6d7d32ce8d801e3dd09f3e8f46d82a21e9b15dfda6a161d2c7f7e09", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:lib_secp256k1, "~> 0.7", [hex: :lib_secp256k1, repo: "hexpm", optional: false]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus, "~> 1.1", [hex: :telemetry_metrics_prometheus, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}, {:websockex, "~> 0.4", [hex: :websockex, repo: "hexpm", optional: false]}], "hexpm", "e91f757972746c73f888706cb084ba745ebf1a602a0f2f6a7c380555c6066cc3"}, "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_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, @@ -75,8 +78,11 @@ "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, + "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, "spark": {:hex, :spark, "2.6.1", "b0100216d3883c6a281cb2434af45afbd808695aadb034923cbaf7d8a2ba46ab", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "77bbefa5263bb6b70e1195bc0fc662ddb8ef5937a356a77ae072e56983ad13f0"}, + "spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"}, "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, "swoosh": {:hex, :swoosh, "1.25.0", "d60dcba6d1ce538b1994f8712a3d55bc9519ffba4654cc4665a75683881d11dd", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c59db3d838b595b95954a3d0a13782e56881cecfe7ba7b793b1a1a6775273a6e"}, @@ -85,8 +91,10 @@ "telemetry_metrics_prometheus": {:hex, :telemetry_metrics_prometheus, "1.1.0", "1cc23e932c1ef9aa3b91db257ead31ea58d53229d407e059b29bb962c1505a13", [:mix], [{:plug_cowboy, "~> 2.1", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}], "hexpm", "d43b3659b3244da44fe0275b717701542365d4519b79d9ce895b9719c1ce4d26"}, "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "usage_rules": {:hex, :usage_rules, "1.2.6", "a7b3f8d6e5d265701139d5714749c37c54bb82230a4c51ec54a12a1e4769b9d1", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "608411b9876a16a9d62a427dbaf42faf458e4cd0a508b3bd7e5ee71502073582"}, "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"}, "websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"}, diff --git a/scripts/plugin b/scripts/plugin index 46ca82c..402f1f4 100644 --- a/scripts/plugin +++ b/scripts/plugin @@ -7,6 +7,7 @@ Usage: plugin validate plugin test [mix test args...] plugin precommit [mix precommit args...] + plugin smoke plugin shell EOF } @@ -22,6 +23,52 @@ host_root="${TRIBES_HOST_ROOT:-$plugin_root/../tribes}" host_root="$(cd "$host_root" && pwd -P)" host_script="$host_root/scripts/plugin" +json_field() { + local field="$1" + + FIELD="$field" mix run --no-start -e ' + field = System.fetch_env!("FIELD") + manifest = "manifest.json" |> File.read!() |> Jason.decode!() + value = Map.fetch!(manifest, field) + IO.write(value) + ' +} + +run_smoke() { + cd "$plugin_root" + + mix compile + + local otp_app + local entry_module + otp_app="$(json_field otp_app)" + entry_module="$(json_field entry_module)" + + local beam_path="_build/dev/lib/$otp_app/ebin/Elixir.$entry_module.beam" + [[ -f "$beam_path" ]] || fail "expected entry module beam at $beam_path" + + cd "$host_root" + PLUGIN_ROOT="$plugin_root" ENTRY_MODULE="$entry_module" mix run --no-start -e ' + plugin_root = System.fetch_env!("PLUGIN_ROOT") + entry_module = System.fetch_env!("ENTRY_MODULE") + + plugin_root + |> Path.join("_build/dev/lib/*/ebin") + |> Path.wildcard() + |> Enum.each(&(:code.add_patha(String.to_charlist(&1)))) + + module = String.to_atom("Elixir." <> entry_module) + + case Code.ensure_loaded(module) do + {:module, ^module} -> + IO.puts("Loaded #{entry_module}") + + other -> + raise "failed to load #{entry_module}: #{inspect(other)}" + end + ' +} + command_name="${1:-}" if [[ -z "$command_name" ]]; then usage @@ -30,7 +77,7 @@ fi shift case "$command_name" in - validate | test | precommit | shell) ;; + validate | test | precommit | smoke | shell) ;; -h | --help | help) usage exit 0 @@ -43,6 +90,11 @@ esac [[ -x "$host_script" || -f "$host_script" ]] || fail "expected host plugin script at $host_script" +if [[ "$command_name" == "smoke" ]]; then + run_smoke "$@" + exit 0 +fi + if [[ "${DEVENV_ROOT:-}" == "$plugin_root" ]]; then command -v devenv >/dev/null 2>&1 || fail "devenv is required when running from the plugin devenv shell" cd "$host_root" diff --git a/scripts/rename.sh b/scripts/rename.sh index 2feba00..03f977f 100755 --- a/scripts/rename.sh +++ b/scripts/rename.sh @@ -44,6 +44,9 @@ fi if [ -d "test/my_plugin" ]; then mv "test/my_plugin" "test/$SNAKE" fi +if [ -d "lib/tribes/plugins/my_plugin" ]; then + mv "lib/tribes/plugins/my_plugin" "lib/tribes/plugins/$SNAKE" +fi # Replace in all text files (portable across GNU/BSD sed) sed_in_place() { @@ -67,13 +70,13 @@ sed_in_place() { while IFS= read -r -d '' file; do sed_in_place "$file" done < <( - 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' \) \ + find . \( -name ".devenv" -o -name ".git" -o -name "deps" -o -name "node_modules" \) -prune -o -type f \ + \( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.ts' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) \ -print0 ) # Rename asset files -for ext in js css; do +for ext in js ts css; do if [ -f "assets/$ext/my_plugin.$ext" ]; then mv "assets/$ext/my_plugin.$ext" "assets/$ext/$SNAKE.$ext" fi @@ -84,4 +87,4 @@ done echo "Done. Review the changes, then:" echo " 1. Edit manifest.json — set description, capabilities" -echo " 2. Run: mix deps.get && mix test" +echo " 2. Run: mix deps.get && scripts/plugin smoke && scripts/plugin test"