From a624e0f06d502f61e8c8b553edf2bd4a9f144dcc Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Fri, 14 Aug 2026 16:14:48 -0400 Subject: [PATCH] feat: add mix lc development proxy --- Readme.adoc | 11 +++++ app/usage-rules.md | 12 +++--- lib/mix/tasks/lc.ex | 86 ++++++++++++++++++++++++++++++++++++++ mix.exs | 2 +- test/mix/tasks/lc_test.exs | 51 ++++++++++++++++++++++ 5 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 lib/mix/tasks/lc.ex create mode 100644 test/mix/tasks/lc_test.exs diff --git a/Readme.adoc b/Readme.adoc index 1faee27..24b487e 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -338,6 +338,17 @@ on every commit, and again on every commit about to be pushed): $ mix setup ---- +Run the development checkout directly from the repository root with `mix lc`. +It compiles and starts the application as needed, then forwards arguments, +interactive input, output, and exit status to the same CLI entry point used by +the release binary: + +[source,sh] +---- +$ mix lc whoami +$ mix lc issue list --output json +---- + The project uses ExUnit and `mix format`. Run tests with: [source,sh] diff --git a/app/usage-rules.md b/app/usage-rules.md index 2d97ec9..4989a4f 100644 --- a/app/usage-rules.md +++ b/app/usage-rules.md @@ -26,13 +26,15 @@ - There's no escript build (removed - NIF-backed deps like `exqlite` can't load from inside an escript archive, so it never actually worked once `exqlite` was added). Without a full `mix release lc` - build, invoke `lc` from `app/` via `mix run`: + build, invoke the development checkout from the repo root through its + proxy task: - mix run -e 'LinearCli.CLI.main(["issue", "list"])' + mix lc issue list - `main/2`'s default `halt` (`System.halt/1`) is fine to leave as-is — - only error paths call it, so a successful command just returns and - `mix run` exits 0 normally with real output and real exit codes. + `mix lc` compiles and starts the `app/` project as needed, forwards all + arguments and standard streams to `LinearCli.CLI.main/1` (including + interactive prompt input), and preserves its exit code. It also suppresses + child Mix progress messages so `--output json` stays machine-readable. ## Accessibility diff --git a/lib/mix/tasks/lc.ex b/lib/mix/tasks/lc.ex new file mode 100644 index 0000000..adda503 --- /dev/null +++ b/lib/mix/tasks/lc.ex @@ -0,0 +1,86 @@ +defmodule Mix.Tasks.Lc do + @shortdoc "Runs this checkout's Linear CLI" + + @moduledoc """ + #{@shortdoc}. + + mix lc [LC_ARGS...] + + Proxies every argument to `LinearCli.CLI.main/1` in the sibling `app/` + project. The child `mix run` compiles and starts the application when + needed, so contributors can exercise the development checkout from the + repository root with the same arguments they would give an installed + `lc` binary: + + mix lc issue list + mix lc whoami --output json + + Standard input, standard output, and standard error are inherited by the + child, so interactive prompts work normally and the two output streams + remain separate. The child CLI's exit status is preserved. Child Mix + progress messages are suppressed so they do not corrupt `--output json`; + diagnostics from `lc` itself are not suppressed. + """ + + use Mix.Task + + @entrypoint "LinearCli.CLI.main(System.argv())" + + @impl Mix.Task + def run(argv) do + run(argv, &run_child/3, &System.halt/1) + end + + @doc false + def run(argv, command, halt) do + args = ["run", "-e", @entrypoint, "--" | argv] + + command_opts = [ + cd: Path.expand("app"), + env: [{"MIX_QUIET", "1"}], + stdio: :inherit + ] + + status = command.("mix", args, command_opts) + + if status != 0 do + halt.(status) + end + + :ok + end + + @doc false + def run_child(executable, args, opts) do + :inherit = Keyword.fetch!(opts, :stdio) + + executable = + System.find_executable(executable) || + Mix.raise("could not find #{executable} on PATH") + + port = + Port.open( + {:spawn_executable, executable}, + [ + # Keep file descriptors 0, 1, and 2 attached to the caller. The + # port uses its auxiliary descriptors only to report lifecycle + # events, which preserves prompts and keeps stdout/stderr separate. + :nouse_stdio, + :exit_status, + args: args, + cd: Keyword.fetch!(opts, :cd), + env: port_env(Keyword.fetch!(opts, :env)) + ] + ) + + receive do + {^port, {:exit_status, status}} -> status + end + end + + defp port_env(environment) do + Enum.map(environment, fn {name, value} -> + {String.to_charlist(name), String.to_charlist(value)} + end) + end +end diff --git a/mix.exs b/mix.exs index 33c17e8..f2d8c12 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule RepoTasks.MixProject do # things app/'s own mix.exs has no business knowing about). Kept # dependency-free on purpose: every task here just orchestrates other # already-existing tools (mix release inside app/, the ci/*.sh scripts) - # via System.cmd/3, never runs anything in-process. + # as child OS processes, never runs them in-process. def project do [ app: :repo_tasks, diff --git a/test/mix/tasks/lc_test.exs b/test/mix/tasks/lc_test.exs new file mode 100644 index 0000000..1e2e55e --- /dev/null +++ b/test/mix/tasks/lc_test.exs @@ -0,0 +1,51 @@ +defmodule Mix.Tasks.LcTest do + use ExUnit.Case, async: true + + alias Mix.Tasks.Lc + + test "forwards lc arguments unchanged through the app project" do + caller = self() + + command = fn executable, args, opts -> + send(caller, {:command, executable, args, opts}) + 0 + end + + halt = fn status -> flunk("unexpected halt with status #{status}") end + + assert :ok = Lc.run(["issue", "create", "title with spaces"], command, halt) + + assert_receive {:command, "mix", + [ + "run", + "-e", + "LinearCli.CLI.main(System.argv())", + "--", + "issue", + "create", + "title with spaces" + ], opts} + + assert opts[:cd] == Path.expand("app") + assert opts[:env] == [{"MIX_QUIET", "1"}] + assert opts[:stdio] == :inherit + end + + test "preserves the child lc exit status" do + caller = self() + command = fn _executable, _args, _opts -> 66 end + halt = fn status -> send(caller, {:halt, status}) end + + assert :ok = Lc.run(["issue", "view", "missing"], command, halt) + assert_receive {:halt, 66} + end + + test "returns the exact exit status from an inherited-stdio child" do + assert 37 == + Lc.run_child("sh", ["-c", "exit 37"], + cd: File.cwd!(), + env: [], + stdio: :inherit + ) + end +end