diff --git a/.github/workflows/burrito-release.yaml b/.github/workflows/burrito-release.yaml index c03c41a..e2fc373 100644 --- a/.github/workflows/burrito-release.yaml +++ b/.github/workflows/burrito-release.yaml @@ -48,7 +48,7 @@ jobs: run: mix deps.get - name: Build all Burrito targets - run: MIX_ENV=prod mix release linear_cli + run: MIX_ENV=prod mix release lc - name: Upload binaries to the GitHub release env: @@ -79,7 +79,7 @@ jobs: working-directory: app - name: Build the linux_x86_64 target (container's payload) - run: MIX_ENV=prod BURRITO_TARGET=linux_x86_64 mix release linear_cli + run: MIX_ENV=prod BURRITO_TARGET=linux_x86_64 mix release lc working-directory: app - name: Build the image diff --git a/app/.gitignore b/app/.gitignore index 2019493..433eee3 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -24,7 +24,7 @@ app-*.tar # The escript built via "mix escript.build" (local dev convenience only - # the real distribution target is the Burrito release, see Phase 8). -/linear_cli +/lc # Burrito's per-target output binaries (mix release, see documents/phase-8-plan.adoc). /burrito_out/ diff --git a/app/lib/linear_cli/application.ex b/app/lib/linear_cli/application.ex index d68b296..48e18a0 100644 --- a/app/lib/linear_cli/application.ex +++ b/app/lib/linear_cli/application.ex @@ -5,36 +5,57 @@ defmodule LinearCli.Application do @impl true def start(_type, _args) do - children = daemon_children() - - opts = [strategy: :one_for_one, name: LinearCli.Supervisor] - Supervisor.start_link(children, opts) + if System.get_env("LINEAR_CLI_DAEMON") == "true" do + start_daemon() + else + start_interactive() + end end # Only the daemon run mode (LINEAR_CLI_DAEMON=true, set by the mix - # release's daemon startup, never by the interactive escript/Burrito - # binary) starts the repo + Oban. Confirmed empirically that the escript - # boots this whole application on every invocation - without this gate, - # every interactive command would also open a database connection and - # boot Oban's full supervision tree. See documents/phase-7-plan.adoc. + # release's daemon startup) starts the repo + Oban and stays alive. + # Confirmed empirically that the escript boots this whole application on + # every invocation - without this gate, every interactive command would + # also open a database connection and boot Oban's full supervision tree. + # See documents/phase-7-plan.adoc. # # Which repo/engine actually starts is resolved fresh on every boot via # LinearCli.ObanRepo.{repo,oban_engine}/0, not baked in at compile time - # see that module's moduledoc for why this has to be a runtime choice. - defp daemon_children do - if System.get_env("LINEAR_CLI_DAEMON") == "true" do - repo = LinearCli.ObanRepo.repo() - ensure_db_ready!(repo) + defp start_daemon do + repo = LinearCli.ObanRepo.repo() + ensure_db_ready!(repo) - oban_opts = - :linear_cli - |> Application.fetch_env!(Oban) - |> Keyword.merge(repo: repo, engine: LinearCli.ObanRepo.oban_engine()) + oban_opts = + :linear_cli + |> Application.fetch_env!(Oban) + |> Keyword.merge(repo: repo, engine: LinearCli.ObanRepo.oban_engine()) - [repo, {Oban, oban_opts}] - else - [] + opts = [strategy: :one_for_one, name: LinearCli.Supervisor] + Supervisor.start_link([repo, {Oban, oban_opts}], opts) + end + + # `mix escript.build`'s `main_module: LinearCli.CLI` makes the escript + # runtime call `LinearCli.CLI.main/1` itself once boot finishes here - so + # this must NOT also call it, or every interactive command would run + # twice. A Burrito-wrapped release has no such runtime: it boots via + # `-s elixir start_cli`, which only recognizes Elixir's own CLI flags + # (`--help`/`--version`) and otherwise tries to run the first arg as a + # script file (see documents/phase-8-plan.adoc's Burrito verification - + # it only exercised the daemon boot-and-stay-alive path, not this one). + # `LinearCli.CLI.main/2` never reaches this call site as a Burrito + # release, so it has to happen here instead, per Burrito's own + # "Application Entry Point" README section. `running_standalone?/0` + # (checks the `__BURRITO` env var the Zig wrapper sets) is what + # distinguishes that case from escript/`mix run`. + defp start_interactive do + if Burrito.Util.running_standalone?() do + LinearCli.CLI.main(Burrito.Util.Args.argv()) + System.halt(0) end + + opts = [strategy: :one_for_one, name: LinearCli.Supervisor] + Supervisor.start_link([], opts) end # Only SQLite needs its containing directory prepared before connecting - diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 146197e..7f023c7 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -10,8 +10,21 @@ defmodule LinearCli.CLI do alias LinearCli.CLI.Commands def main(argv, halt \\ &System.halt/1) do - argv = argv |> normalize_aliases() |> normalize_help() - {subcommand_path, parse_result} = Optimus.parse!(spec(), argv, halt) + argv = argv |> normalize_aliases() |> normalize_help() |> default_to_issue_list() + + # Optimus.parse!/3 returns *either* {subcommand_path, parse_result} + # (a subcommand matched) *or* a bare %Optimus.ParseResult{} (nothing + # did - e.g. only global options were given, no subcommand token at + # all). Assuming the tupled shape unconditionally crashed on that + # second case with a bare MatchError. Normalize both to a uniform + # {subcommand_path, parse_result} pair - an empty path hits dispatch/3's + # existing "incomplete path" fallback (prints top-level help, exit 1), + # exactly as it already does for e.g. `lc project` alone. + {subcommand_path, parse_result} = + case Optimus.parse!(spec(), argv, halt) do + {path, result} -> {path, result} + %Optimus.ParseResult{} = result -> {[], result} + end try do dispatch(subcommand_path, parse_result, halt) @@ -45,16 +58,41 @@ defmodule LinearCli.CLI do Enum.map(argv, &Map.get(@flag_aliases, &1, &1)) end + # Ported from exe/scripts/lc.sh's own `[ "$#" -eq 0 ]` branch exactly + # (including its stderr text) - a bare `lc` invocation defaults to + # `issue list` rather than dumping top-level help. + @doc false + def default_to_issue_list([]) do + IO.puts(:stderr, "No subcommand provided, defaulting to 'lc issue list'") + IO.puts(:stderr, "lc --help to see subcommands") + ["issue", "list"] + end + + def default_to_issue_list(argv), do: argv + # Optimus only special-cases bare top-level `--help` and the `help ` # form - `issue list --help` isn't recognized, and since `issue list` allows # unknown args (for bare issue ids), `--help` would silently be treated as an # issue id to look up instead of showing help. Rewrite ` --help ...` # into `help ` ourselves so `--help`/`-h` works at every subcommand # level, the way most CLIs expect. + # + # `help ` only accepts bare subcommand names, not flags/values mixed + # in - `lc issue update --close --help` (real usage, see bin/lclose) has + # `--close` between the path and `--help`. Take only the leading run of + # tokens that don't look like a flag/value (subcommand names never start + # with "-" in this spec) rather than everything before `--help` verbatim, + # so those extra tokens get dropped instead of breaking `help`'s own parse. defp normalize_help(argv) do case Enum.split_while(argv, &(&1 not in ["--help", "-h"])) do - {before, [_ | _]} when before != [] -> ["help" | before] - _ -> argv + {before, [_ | _]} -> + case Enum.take_while(before, &(not String.starts_with?(&1, "-"))) do + [] -> argv + path -> ["help" | path] + end + + _ -> + argv end end @@ -180,7 +218,7 @@ defmodule LinearCli.CLI do def spec do Optimus.new!( - name: "linear-cli", + name: "lc", description: "CLI for interacting with Linear.app.", version: to_string(Application.spec(:linear_cli, :vsn) || "0.1.0"), about: "A CLI for interacting with Linear.app. Loosely based on the GitHub CLI", diff --git a/app/mix.exs b/app/mix.exs index ac7b7cf..b99a85a 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -18,7 +18,7 @@ defmodule LinearCli.MixProject do end defp escript do - [main_module: LinearCli.CLI] + [main_module: LinearCli.CLI, name: "lc"] end # Burrito-wrapped release, both the interactive CLI and (with @@ -29,7 +29,7 @@ defmodule LinearCli.MixProject do # documents/phase-8-plan.adoc. macOS Intel intentionally not targeted. defp releases do [ - linear_cli: [ + lc: [ steps: [:assemble, &Burrito.wrap/1], burrito: [ targets: [ diff --git a/app/test/linear_cli/cli_test.exs b/app/test/linear_cli/cli_test.exs index 0beaccc..441c0e9 100644 --- a/app/test/linear_cli/cli_test.exs +++ b/app/test/linear_cli/cli_test.exs @@ -114,16 +114,17 @@ defmodule LinearCli.CLITest do # --help makes Optimus print help and halt (its own internal halt call, # now wired to ours) - inject a no-op halt so this doesn't kill the test # VM. Optimus.parse!/3 assumes halt never returns; with a fake one it - # returns :ok instead of {subcommand_path, parse_result}, which blows up - # our pattern match in main/1. Real usage never hits that MatchError - # (real halt terminates the process) - it's only an artifact of faking - # halt here, so rescue it rather than treat it as a failure. + # returns :ok instead of {subcommand_path, parse_result} (or a bare + # %Optimus.ParseResult{}), which blows up main/1's own case statement - + # a CaseClauseError. Real usage never hits that (real halt terminates + # the process) - it's only an artifact of faking halt here, so rescue it + # rather than treat it as a failure. output = capture_io(fn -> try do LinearCli.CLI.main(["issue", "list", "--help"], fn _code -> :ok end) rescue - MatchError -> :ok + CaseClauseError -> :ok end end) diff --git a/bin/lclose b/bin/lclose new file mode 100755 index 0000000..0e196ff --- /dev/null +++ b/bin/lclose @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +if [[ "$*" =~ "--help" ]] +then + printf "This wrapper adds the --close option to the 'issue update' command.\n" >&2 + printf "It is used to close one or many issues. The issues are specified by their ID/slugs.\n" >&2 + printf "For closing multiple issues, you really want to pass --reason so you do not get prompted for each issue.\n\n" >&2 + exec lc issue update --close --help +fi +exec lc issue update --close "$@" diff --git a/bin/lcls b/bin/lcls new file mode 100755 index 0000000..71ee051 --- /dev/null +++ b/bin/lcls @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec lc issue list "$@" diff --git a/bin/lcomment b/bin/lcomment new file mode 100755 index 0000000..f6395bd --- /dev/null +++ b/bin/lcomment @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec lc issue update --comment - "$@" diff --git a/bin/lcreate b/bin/lcreate new file mode 100755 index 0000000..d4e72d0 --- /dev/null +++ b/bin/lcreate @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec lc issue create "$@" diff --git a/bin/lproj b/bin/lproj new file mode 100755 index 0000000..f314437 --- /dev/null +++ b/bin/lproj @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec lc project "$@" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4baad09 --- /dev/null +++ b/install.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Builds a native Burrito release of `lc` for the current machine and +# installs it, plus the bin/ wrapper scripts (lcreate, lcls, lclose, +# lcomment, lproj), onto a directory already on $PATH. Fallback path for +# machines without Homebrew - see rubyists/homebrew-tap once it exists. +# +# Records exactly what it installed and where in a manifest file, so +# uninstall.sh can remove precisely those files even if $PATH changes +# between install and uninstall. + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +app_dir="$repo_root/app" +state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/linear-cli-ex" +manifest="$state_dir/manifest" + +detect_target() { + case "$(uname -s)" in + Darwin) + case "$(uname -m)" in + arm64) printf '%s\n' macos_aarch64 ;; + *) + printf 'error: unsupported macOS architecture: %s (only aarch64 is built)\n' "$(uname -m)" >&2 + exit 1 + ;; + esac + ;; + Linux) + case "$(uname -m)" in + x86_64) printf '%s\n' linux_x86_64 ;; + *) + printf 'error: unsupported Linux architecture: %s (only x86_64 is built)\n' "$(uname -m)" >&2 + exit 1 + ;; + esac + ;; + *) + printf 'error: unsupported OS: %s (use the container image on Windows)\n' "$(uname -s)" >&2 + exit 1 + ;; + esac +} + +# Prefer an install dir the user already has on $PATH (so no shell rc +# editing is required) over inventing a new one. $LC_INSTALL_DIR always +# wins if set, for anyone who wants a specific target. +pick_install_dir() { + if [ -n "${LC_INSTALL_DIR:-}" ] + then + printf '%s\n' "$LC_INSTALL_DIR" + return + fi + + local dir + IFS=: read -ra path_dirs <<< "$PATH" + for dir in "${path_dirs[@]}" + do + if [ -n "$dir" ] && [ -d "$dir" ] && [ -w "$dir" ] + then + printf '%s\n' "$dir" + return + fi + done + + printf '%s\n' "$HOME/.local/bin" +} + +build_lc() { + local target="$1" + + if ! cd "$app_dir" + then + printf 'error: could not cd into %s\n' "$app_dir" >&2 + exit 1 + fi + + rm -rf _build/prod + + have_mise=0 + if command -v mise >/dev/null 2>&1 + then + have_mise=1 + else + printf 'warning: mise not found on PATH; building with whatever Erlang/Elixir are active\n' >&2 + fi + + if [ "$have_mise" -eq 1 ] + then + MIX_ENV=prod BURRITO_TARGET="$target" mise exec -- mix release lc --overwrite + else + MIX_ENV=prod BURRITO_TARGET="$target" mix release lc --overwrite + fi + + if [ "$?" -ne 0 ] + then + printf 'error: build failed (mix release lc, target %s)\n' "$target" >&2 + exit 1 + fi +} + +target=$(detect_target) || exit 1 +install_dir=$(pick_install_dir) + +printf 'Building lc (%s)...\n' "$target" +build_lc "$target" + +if ! mkdir -p "$install_dir" +then + printf 'error: could not create install dir %s\n' "$install_dir" >&2 + exit 1 +fi + +if ! mkdir -p "$state_dir" +then + printf 'error: could not create state dir %s\n' "$state_dir" >&2 + exit 1 +fi + +: > "$manifest" +for name in lc lcreate lcls lclose lcomment lproj +do + src="$app_dir/burrito_out/lc_${target}" + [ "$name" = lc ] || src="$repo_root/bin/$name" + + if ! install -m 755 "$src" "$install_dir/$name" + then + printf 'error: failed to install %s to %s\n' "$name" "$install_dir" >&2 + exit 1 + fi + + printf '%s\n' "$install_dir/$name" >> "$manifest" +done + +printf 'Installed lc, lcreate, lcls, lclose, lcomment, lproj to %s\n' "$install_dir" +printf '(uninstall.sh will remove exactly these files - manifest at %s)\n' "$manifest" + +case ":$PATH:" in + *":$install_dir:"*) ;; + *) + printf '\n' + printf 'warning: %s is not on your $PATH.\n' "$install_dir" + printf 'Add it to your shell profile, e.g.:\n' + printf ' export PATH="%s:$PATH"\n' "$install_dir" + ;; +esac diff --git a/mise.toml b/mise.toml index bfd02d7..58044c2 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,8 @@ [tools] -elixir = "latest" +# Pinned to match .github/workflows/*.yaml's erlef/setup-beam@v1 exactly - +# Burrito's precompiled-ERTS catalog lags bleeding-edge OTP patches, so +# floating on "latest" (as this used to) drifts local builds away from what +# Burrito can actually fetch, breaking `mix release` locally while CI still +# works. See documents/phase-8-plan.adoc. +erlang = "29.0.3" +elixir = "1.20.3" diff --git a/oci/Containerfile b/oci/Containerfile index a64bb35..50c17ef 100644 --- a/oci/Containerfile +++ b/oci/Containerfile @@ -3,21 +3,31 @@ # one build artifact, reused for both the CLI distribution and this image. # Build with ci/build_image.sh (which finds this via its # Dockerfile/Containerfile/oci/Containerfile search order - build context -# is still the repo root, so the COPY path below is unaffected by this -# file's own location) after app/burrito_out/linear_cli_linux_x86_64 +# is still the repo root, so the COPY paths below are unaffected by this +# file's own location) after app/burrito_out/lc_linux_x86_64 # already exists (`MIX_ENV=prod BURRITO_TARGET=linux_x86_64 mix release -# linear_cli`). +# lc`). FROM alpine:3.22 ARG APP_VERSION LABEL org.opencontainers.image.version="${APP_VERSION}" # ca-certificates: Linear's API is HTTPS-only, and Alpine's base image -# doesn't bundle a CA trust store. -RUN apk add --no-cache ca-certificates +# doesn't bundle a CA trust store. bash: the bin/ wrapper scripts below +# have `#!/usr/bin/env bash` shebangs and (bin/lclose) use bash-only +# `[[ ... =~ ... ]]` syntax - Alpine's default /bin/sh (busybox ash) +# can't run them. +RUN apk add --no-cache ca-certificates bash -COPY app/burrito_out/linear_cli_linux_x86_64 /linear_cli -RUN chmod +x /linear_cli +# The bin/ wrapper scripts (bin/lcreate, bin/lcls, bin/lclose, bin/lcomment, +# bin/lproj - ported verbatim from vendor/ruby-linear-cli/exe/scripts/) all +# `exec lc ...`, resolving it via PATH - both go in /usr/local/bin/ together +# so that resolution actually works, rather than the bare `/lc` this used +# before (which isn't on PATH at all). +COPY app/burrito_out/lc_linux_x86_64 /usr/local/bin/lc +COPY bin/lcreate bin/lcls bin/lclose bin/lcomment bin/lproj /usr/local/bin/ +RUN chmod +x /usr/local/bin/lc /usr/local/bin/lcreate /usr/local/bin/lcls \ + /usr/local/bin/lclose /usr/local/bin/lcomment /usr/local/bin/lproj ENV LINEAR_CLI_DAEMON=true -ENTRYPOINT ["/linear_cli"] +ENTRYPOINT ["/usr/local/bin/lc"] diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..1997ff9 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Removes exactly what install.sh installed, using the manifest it wrote - +# not a guess at where things might be, so this stays correct even if +# $PATH changed since install. + +state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/linear-cli-ex" +manifest="$state_dir/manifest" + +if [ ! -f "$manifest" ] +then + printf 'error: no manifest at %s - nothing to uninstall (or it was installed another way, e.g. Homebrew: use `brew uninstall lc` instead)\n' "$manifest" >&2 + exit 1 +fi + +removed=0 +failed=0 +while IFS= read -r path +do + [ -n "$path" ] || continue + + if [ -e "$path" ] + then + if rm -f "$path" + then + printf 'removed %s\n' "$path" + removed=$((removed + 1)) + else + printf 'error: failed to remove %s\n' "$path" >&2 + failed=$((failed + 1)) + fi + fi +done < "$manifest" + +rm -f "$manifest" +rmdir "$state_dir" 2>/dev/null + +if [ "$failed" -ne 0 ] +then + printf '%d file(s) removed, %d failed\n' "$removed" "$failed" >&2 + exit 1 +fi + +printf '%d file(s) removed\n' "$removed"