Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ Full module documentation: [hexdocs.pm/mob_dev](https://hexdocs.pm/mob_dev).

---

## [Unreleased]

### Fixed
- **`mix mob.new_plugin` no longer scaffolds plugins pinned to the abandoned
`mob ~> 0.6`.** `MobDev.Plugin.Scaffold` hard-coded `{:mob, "~> 0.6"}` in the
generated `mix.exs` and `mob_version: "~> 0.6"` in every tier's manifest, so a
freshly scaffolded plugin could not activate against the published mob 0.7.x
(`installed :mob 0.7.x does not satisfy mob_version "~> 0.6"`). The requirement
is now derived at scaffold time from the mob actually resolved in the project
(`Scaffold.detect_mob_requirement/0`), falling back to a single
`@fallback_mob_requirement` constant (`"~> 0.7"`) when mob isn't loadable.
`mix.exs` and the manifest always agree. A `Scaffold` test pins the default and
asserts a generated manifest validates against a matching mob version, so the
pin can't silently lag a future mob release. (#21)

---

## [0.6.14] - 2026-06-23

### Added
Expand All @@ -26,6 +43,10 @@ Full module documentation: [hexdocs.pm/mob_dev](https://hexdocs.pm/mob_dev).
resolves Zigler dotfile sources with `match_dot: true`. Verified on a physical
SM-T577U (arm64-v8a) tablet via a Ghostty VT NIF. (#24)

---

## [0.6.13] - 2026-06-20

### Changed
- **`mix mob.deploy --native` now preserves on-device app data when the signing
key matches.** The Android install path previously ran an unconditional
Expand Down
47 changes: 47 additions & 0 deletions decisions/2026-06-22-scaffold-derives-mob-requirement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Scaffolded plugins derive their mob version requirement

- Date: 2026-06-22
- Status: accepted

## Context

`mix mob.new_plugin` (via `MobDev.Plugin.Scaffold`) hard-coded the mob
dependency requirement in two places: `{:mob, "~> 0.6"}` in the generated
`mix.exs` and `mob_version: "~> 0.6"` in each tier's `priv/mob_plugin.exs`
manifest. Published mob is 0.7.x, so a freshly scaffolded plugin failed
activation with `installed :mob 0.7.x does not satisfy mob_version "~> 0.6"`,
and `mix deps.get` resolved a stale mob. Reported as issue #21, surfaced by the
mob_ci harness which dogfoods `mob.new_plugin --tier 0..4` and had to hand-bump
the generated fixtures.

The literal also lived in five separate template strings, so the two pins could
drift apart and nothing caught a stale value before a user hit it.

## Decision

Derive the requirement instead of hard-coding it.

- `Scaffold.mob_requirement/1` (pure) maps a concrete version to `"~> MAJOR.MINOR"`,
or returns the compiled `@fallback_mob_requirement` on `nil`.
- `Scaffold.detect_mob_requirement/0` (impure) prefers the version of `:mob`
actually resolved in the current project (`Application.spec(:mob, :vsn)` after
`Application.load/1`), falling back to the constant when mob isn't loadable
(scaffolding standalone, outside a host app). The `mix mob.new_plugin` task
calls this and threads the result into `Scaffold.files_for/3`; the templates
stay pure and unit-testable.
- A single `@fallback_mob_requirement "~> 0.7"` is the only literal; `mix.exs`
and all manifests interpolate the threaded value, so they can't disagree.
- A `Scaffold` test pins the default to a parseable `~> X.Y`, asserts every
tier's `mix.exs` and manifest agree, asserts it is not the abandoned `~> 0.6`,
and validates a generated manifest against a version that satisfies the default
(derived from the requirement, so the test tracks future bumps).

## Consequences

- A plugin scaffolded inside a mob 0.7.x app pins `"~> 0.7"`; one scaffolded with
no mob present gets the constant. Both activate against current mob.
- When mob's major.minor moves again, bump `@fallback_mob_requirement` — the
test guards against silently shipping the old floor, but the constant still
needs a human bump (mob_dev does not depend on mob, so CI here can't compare
against the latest published mob automatically).
- `files_for/2` callers keep working via the defaulted third argument.
2 changes: 1 addition & 1 deletion lib/mix/tasks/mob.new_plugin.ex
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ defmodule Mix.Tasks.Mob.NewPlugin do
:ok <- Scaffold.validate_tier(tier) do
dest = opts[:dest] || Path.join([File.cwd!(), "plugins", name])
refuse_if_exists!(dest)
write_files!(dest, Scaffold.files_for(tier, name))
write_files!(dest, Scaffold.files_for(tier, name, Scaffold.detect_mob_requirement()))
print_next_steps(name, tier)
else
{:error, reason} -> Mix.raise(reason)
Expand Down
2 changes: 1 addition & 1 deletion lib/mob_dev/plugin/manifest.ex
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ defmodule MobDev.Plugin.Manifest do

defp check_mob_version(errors, _),
do: [
":mob_version is required and must be a version requirement string (e.g. \"~> 0.6\")"
":mob_version is required and must be a version requirement string (e.g. \"~> 0.7\")"
| errors
]

Expand Down
96 changes: 71 additions & 25 deletions lib/mob_dev/plugin/scaffold.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ defmodule MobDev.Plugin.Scaffold do

@supported_tiers [0, 1, 2, 3, 4]

# Mob version requirement baked into a freshly scaffolded plugin when the
# installed mob can't be detected (e.g. scaffolding outside a host app).
# `detect_mob_requirement/0` prefers the real installed version; this is the
# floor. Keep it tracking the current published mob major.minor — a Scaffold
# test pins it so it can't silently lag a mob release (see issue #21).
@fallback_mob_requirement "~> 0.7"

# Names that pass the snake_case regex but produce a broken or non-buildable
# plugin project. `nil`/`true`/`false` are the killers: the scaffold emits
# `app: :<name>` in mix.exs, and Mix treats `:nil`/`:false` as "no app name"
Expand Down Expand Up @@ -78,83 +85,122 @@ defmodule MobDev.Plugin.Scaffold do
|> Enum.join()
end

@doc """
Builds a `"~> MAJOR.MINOR"` mob version requirement from a concrete version.

`nil` (mob not detectable) yields the compiled `@fallback_mob_requirement`.
Pure so the derivation is unit-testable independent of what's installed.
"""
@spec mob_requirement(String.t() | Version.t() | nil) :: String.t()
def mob_requirement(nil), do: @fallback_mob_requirement
def mob_requirement(%Version{major: major, minor: minor}), do: "~> #{major}.#{minor}"

def mob_requirement(version) when is_binary(version),
do: mob_requirement(Version.parse!(version))

@doc """
Resolves the mob version requirement for a freshly scaffolded plugin.

Prefers the version of `:mob` actually resolved in the current project (so a
plugin scaffolded inside a mob 0.7.x app pins `"~> 0.7"`), falling back to
the compiled `@fallback_mob_requirement` when mob isn't loadable (scaffolding
standalone). Impure — the Mix task calls this and threads the result into
`files_for/3`; the templates themselves stay pure.
"""
@spec detect_mob_requirement() :: String.t()
def detect_mob_requirement do
_ = Application.load(:mob)

case Application.spec(:mob, :vsn) do
nil -> mob_requirement(nil)
vsn -> mob_requirement(List.to_string(vsn))
end
end

@doc """
Returns the file list for a given tier + name. Each entry is
`{relative_path, content}`. `relative_path` is relative to the plugin's
root directory.

`mob_req` is the `mob` version requirement to embed in the generated
`mix.exs` and manifest; defaults to `@fallback_mob_requirement`. The Mix
task passes `detect_mob_requirement/0` so a scaffolded plugin pins the mob
it's being generated against.
"""
@spec files_for(tier(), String.t()) :: [file()]
def files_for(0, name) do
@spec files_for(tier(), String.t(), String.t()) :: [file()]
def files_for(tier, name, mob_req \\ @fallback_mob_requirement)

def files_for(0, name, mob_req) do
[
{"mix.exs", mix_exs(name)},
{"mix.exs", mix_exs(name, mob_req)},
{"lib/#{name}.ex", tier0_lib(name)},
{"test/test_helper.exs", test_helper()},
{"test/#{name}_test.exs", tier0_test(name)}
]
end

def files_for(1, name) do
def files_for(1, name, mob_req) do
nif_name = "#{name}_nif"

[
{"mix.exs", mix_exs(name)},
{"mix.exs", mix_exs(name, mob_req)},
{"lib/#{name}.ex", tier1_lib(name, nif_name)},
{"src/#{nif_name}.erl", tier1_erl_stub(nif_name)},
{"priv/mob_plugin.exs", tier1_manifest(name, nif_name)},
{"priv/mob_plugin.exs", tier1_manifest(name, nif_name, mob_req)},
{"priv/native/jni/#{nif_name}.c", tier1_c(nif_name)},
{"test/test_helper.exs", test_helper()},
{"test/#{name}_test.exs", plugin_test(name)}
]
end

def files_for(2, name) do
def files_for(2, name, mob_req) do
mod = module_name(name)
registry_name = "#{mod}_View"

[
{"mix.exs", mix_exs(name)},
{"mix.exs", mix_exs(name, mob_req)},
{"lib/#{name}.ex", tier2_lib(name, mod)},
{"lib/#{name}/view.ex", tier2_view(mod)},
{"priv/mob_plugin.exs", tier2_manifest(name, mod, registry_name)},
{"priv/mob_plugin.exs", tier2_manifest(name, mod, registry_name, mob_req)},
{"priv/native/android/#{mod}.kt", tier2_kt(mod, registry_name)},
{"priv/native/ios/#{mod}View.swift", tier2_swift(mod)},
{"test/test_helper.exs", test_helper()},
{"test/#{name}_test.exs", plugin_test(name)}
]
end

def files_for(3, name) do
def files_for(3, name, mob_req) do
mod = module_name(name)

[
{"mix.exs", mix_exs(name)},
{"mix.exs", mix_exs(name, mob_req)},
{"lib/#{name}/list_screen.ex", tier3_list_screen(mod)},
{"lib/#{name}/detail_screen.ex", tier3_detail_screen(mod)},
{"priv/mob_plugin.exs", tier3_manifest(name, mod)},
{"priv/mob_plugin.exs", tier3_manifest(name, mod, mob_req)},
{"priv/repo/migrations/20260101000000_create_#{name}_items.exs", tier3_migration(mod)},
{"test/test_helper.exs", test_helper()},
{"test/#{name}_test.exs", plugin_test(name)}
]
end

def files_for(4, name) do
def files_for(4, name, mob_req) do
mod = module_name(name)

[
{"mix.exs", mix_exs(name)},
{"mix.exs", mix_exs(name, mob_req)},
{"lib/#{name}.ex", tier4_lib(mod)},
{"lib/#{name}/worker.ex", tier4_worker(mod)},
{"lib/#{name}/notifications.ex", tier4_notifications(mod)},
{"lib/#{name}/settings_screen.ex", tier4_settings_screen(mod)},
{"priv/mob_plugin.exs", tier4_manifest(name, mod)},
{"priv/mob_plugin.exs", tier4_manifest(name, mod, mob_req)},
{"test/test_helper.exs", test_helper()},
{"test/#{name}_test.exs", plugin_test(name)}
]
end

# ── mix.exs (same for all tiers) ──────────────────────────────────────────

defp mix_exs(name) do
defp mix_exs(name, mob_req) do
mod = module_name(name)

"""
Expand All @@ -176,7 +222,7 @@ defmodule MobDev.Plugin.Scaffold do

defp deps do
[
{:mob, "~> 0.6"}
{:mob, "#{mob_req}"}
]
end
end
Expand Down Expand Up @@ -321,11 +367,11 @@ defmodule MobDev.Plugin.Scaffold do
"""
end

defp tier1_manifest(name, nif_name) do
defp tier1_manifest(name, nif_name, mob_req) do
"""
%{
name: :#{name},
mob_version: "~> 0.6",
mob_version: "#{mob_req}",
plugin_spec_version: 1,
description: "TODO: describe your plugin",
nifs: [
Expand Down Expand Up @@ -431,11 +477,11 @@ defmodule MobDev.Plugin.Scaffold do
"""
end

defp tier2_manifest(name, mod, registry_name) do
defp tier2_manifest(name, mod, registry_name, mob_req) do
"""
%{
name: :#{name},
mob_version: "~> 0.6",
mob_version: "#{mob_req}",
plugin_spec_version: 1,
description: "TODO: describe your plugin",

Expand Down Expand Up @@ -569,11 +615,11 @@ defmodule MobDev.Plugin.Scaffold do
"""
end

defp tier3_manifest(name, mod) do
defp tier3_manifest(name, mod, mob_req) do
"""
%{
name: :#{name},
mob_version: "~> 0.6",
mob_version: "#{mob_req}",
plugin_spec_version: 1,
description: "TODO: describe your plugin",

Expand Down Expand Up @@ -689,11 +735,11 @@ defmodule MobDev.Plugin.Scaffold do
"""
end

defp tier4_manifest(name, mod) do
defp tier4_manifest(name, mod, mob_req) do
"""
%{
name: :#{name},
mob_version: "~> 0.6",
mob_version: "#{mob_req}",
plugin_spec_version: 1,
description: "TODO: describe your plugin",

Expand Down
Loading
Loading