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
4 changes: 2 additions & 2 deletions .github/workflows/burrito-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
61 changes: 41 additions & 20 deletions app/lib/linear_cli/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 -
Expand Down
48 changes: 43 additions & 5 deletions app/lib/linear_cli/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <path...>`
# 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 `<path...> --help ...`
# into `help <path...>` ourselves so `--help`/`-h` works at every subcommand
# level, the way most CLIs expect.
#
# `help <path>` 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
Comment on lines +88 to +92

_ ->
argv
end
end

Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions app/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: [
Expand Down
11 changes: 6 additions & 5 deletions app/test/linear_cli/cli_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 9 additions & 0 deletions bin/lclose
Original file line number Diff line number Diff line change
@@ -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 "$@"
2 changes: 2 additions & 0 deletions bin/lcls
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec lc issue list "$@"
2 changes: 2 additions & 0 deletions bin/lcomment
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec lc issue update --comment - "$@"
2 changes: 2 additions & 0 deletions bin/lcreate
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec lc issue create "$@"
2 changes: 2 additions & 0 deletions bin/lproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
exec lc project "$@"
144 changes: 144 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion mise.toml
Original file line number Diff line number Diff line change
@@ -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"
Loading