Packages

Route policy and runtime contracts for Phoenix apps that go mobile.

Current section

Files

Jump to
crosswake lib mix tasks crosswake.shell.status.ex
Raw

lib/mix/tasks/crosswake.shell.status.ex

defmodule Mix.Tasks.Crosswake.Shell.Status do
use Mix.Task
@shortdoc "Report whether generated native shells are up to date (exit 0/2/1)"
@moduledoc """
Read-only staleness check for generated Crosswake native shells.
Locates `.crosswake/shell.json` manifests written by `mix crosswake.gen.shell`,
compares the stamped `template_version` against the live current epoch, and
reports a calm prose summary.
## Usage
mix crosswake.shell.status
mix crosswake.shell.status --target PATH
mix crosswake.shell.status --format json
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | All found shells are up-to-date, OR no `.crosswake/shell.json` found at all (not-a-shell) |
| 2 | At least one shell is behind — regeneration recommended |
| 1 | Error — bad or unreadable manifest (use `mix crosswake.gen.shell` to regenerate) |
The not-a-shell path exits 0 so that CI in projects that have not adopted `gen.shell`
is never broken by adding this check to a pipeline.
## Manifest Locations Probed (no `--target`)
Without `--target`, the task probes three locations relative to the current working directory:
- `native/ios/crosswake_shell/.crosswake/shell.json`
- `native/android/crosswake_shell/.crosswake/shell.json`
- `.crosswake/shell.json` (generic — for `--target` runs and single-platform directories)
## Options
* `--target PATH` — check only `PATH/.crosswake/shell.json`
* `--format json` — emit a machine-readable JSON payload
"""
@switches [target: :string, format: :string]
@platform_roots [
{"ios", "native/ios/crosswake_shell"},
{"android", "native/android/crosswake_shell"}
]
@impl Mix.Task
def run(args) do
{opts, _argv, invalid} = OptionParser.parse(args, strict: @switches)
if invalid != [] do
Mix.raise("[crosswake] shell.status: invalid options: #{inspect(invalid)}")
end
format = Keyword.get(opts, :format, "human")
unless format in ["human", "json"] do
Mix.raise("[crosswake] shell.status: unsupported format: #{inspect(format)}")
end
current_version = Mix.Tasks.Crosswake.Gen.Shell.template_version()
cwd = File.cwd!()
platform_results =
case opts[:target] do
nil ->
# Probe per-platform roots + generic cwd manifest
platform_probes =
Enum.map(@platform_roots, fn {platform, rel_root} ->
manifest_path = Path.join([cwd, rel_root, ".crosswake", "shell.json"])
{platform, check_manifest(manifest_path, current_version)}
end)
# Also probe generic cwd manifest (written by --target runs, or test helpers)
generic_path = Path.join([cwd, ".crosswake", "shell.json"])
generic_result = check_manifest(generic_path, current_version)
case generic_result do
:not_found ->
# No generic manifest — return only the platform probes
platform_probes
_ ->
# Generic manifest found — include it under a "generic" key
# (merges into overall verdict alongside any platform manifests found)
found_platform_results =
Enum.reject(platform_probes, fn {_p, r} -> r == :not_found end)
[{"generic", generic_result} | found_platform_results]
end
target_path ->
# --target: check a single directory
manifest_path =
target_path
|> Path.expand()
|> Path.join(".crosswake/shell.json")
platform = detect_platform_from_path(target_path)
[{platform, check_manifest(manifest_path, current_version)}]
end
# Determine the overall status from per-platform results
overall = overall_status(platform_results)
case format do
"json" -> emit_json(platform_results, overall, current_version)
_ -> emit_human(platform_results, overall, current_version)
end
# Exit wiring (D-12)
case overall do
:up_to_date -> :ok
:not_a_shell -> :ok
{:behind, _} -> exit({:shutdown, 2})
{:error, msg} -> Mix.raise("[crosswake] shell.status: #{msg}")
end
end
# ---------------------------------------------------------------------------
# Manifest checking
# ---------------------------------------------------------------------------
defp check_manifest(path, current_version) do
case File.read(path) do
{:error, :enoent} ->
:not_found
{:error, reason} ->
{:error, "could not read #{path}: #{reason}"}
{:ok, content} ->
case Jason.decode(content) do
{:error, _} ->
{:error, "malformed JSON in #{path}"}
{:ok, manifest} ->
case Map.fetch(manifest, "template_version") do
:error ->
{:error, "missing required key `template_version` in #{path}"}
{:ok, stamped} when not is_integer(stamped) ->
{:error, "`template_version` must be an integer in #{path}"}
{:ok, stamped} ->
if stamped >= current_version do
{:up_to_date, stamped}
else
{:behind, stamped}
end
end
end
end
end
# ---------------------------------------------------------------------------
# Overall status aggregation
# ---------------------------------------------------------------------------
defp overall_status(platform_results) do
verdicts = Enum.map(platform_results, fn {_platform, result} -> result end)
cond do
Enum.any?(verdicts, fn
{:error, _} -> true
_ -> false
end) ->
first_error =
Enum.find_value(verdicts, fn
{:error, msg} -> msg
_ -> nil
end)
{:error, first_error}
Enum.all?(verdicts, &(&1 == :not_found)) ->
:not_a_shell
Enum.any?(verdicts, fn
{:behind, _} -> true
_ -> false
end) ->
max_behind =
Enum.map(verdicts, fn
{:behind, stamped} -> stamped
_ -> nil
end)
|> Enum.reject(&is_nil/1)
|> Enum.min()
{:behind, max_behind}
true ->
:up_to_date
end
end
# ---------------------------------------------------------------------------
# Human-readable output
# ---------------------------------------------------------------------------
defp emit_human(platform_results, overall, current_version) do
found_results = Enum.reject(platform_results, fn {_p, r} -> r == :not_found end)
case overall do
:not_a_shell ->
Mix.shell().info(
"[crosswake] no .crosswake/shell.json found — nothing to check."
)
:up_to_date ->
Mix.shell().info("[crosswake] generated shells are up to date.")
{:behind, _} ->
Enum.each(found_results, fn {platform, result} ->
case result do
{:behind, stamped} ->
behind_count = current_version - stamped
Mix.shell().info(
"[crosswake] #{platform}: #{behind_count} template #{pluralize(behind_count, "version")} behind" <>
" (stamped #{stamped} → current #{current_version})."
)
{:up_to_date, _} ->
Mix.shell().info("[crosswake] #{platform}: up to date.")
_ ->
:ok
end
end)
Mix.shell().info("[crosswake] run `mix crosswake.gen.shell` to regenerate.")
{:error, msg} ->
Mix.shell().error("[crosswake] shell.status error: #{msg}")
end
end
# ---------------------------------------------------------------------------
# JSON output
# ---------------------------------------------------------------------------
defp emit_json(platform_results, overall, current_version) do
platforms_map =
platform_results
|> Enum.reject(fn {_p, r} -> r == :not_found end)
|> Map.new(fn {platform, result} ->
entry =
case result do
{:up_to_date, stamped} ->
%{
status: "up_to_date",
stamped_version: stamped,
current_version: current_version,
versions_behind: 0,
highest_severity: nil
}
{:behind, stamped} ->
%{
status: "behind",
stamped_version: stamped,
current_version: current_version,
versions_behind: current_version - stamped,
highest_severity: "rebuild_required"
}
{:error, msg} ->
%{
status: "error",
stamped_version: nil,
current_version: current_version,
versions_behind: nil,
highest_severity: nil,
error: msg
}
end
{platform, entry}
end)
overall_status_str =
case overall do
:up_to_date -> "up_to_date"
:not_a_shell -> "not_a_shell"
{:behind, _} -> "behind"
{:error, _} -> "error"
end
payload = %{
status: overall_status_str,
current_version: current_version,
platforms: platforms_map
}
Mix.shell().info(Jason.encode!(payload, pretty: true))
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp pluralize(1, word), do: word
defp pluralize(_, word), do: word <> "s"
defp detect_platform_from_path(path) do
cond do
String.contains?(path, "ios") -> "ios"
String.contains?(path, "android") -> "android"
true -> "target"
end
end
end