Skip to content

Commit a99b687

Browse files
committed
Improve CLI entrypoint
1 parent 5160ae4 commit a99b687

3 files changed

Lines changed: 176 additions & 113 deletions

File tree

lib/livebook_cli.ex

Lines changed: 30 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,47 @@
11
defmodule LivebookCLI do
2-
def usage() do
3-
"""
4-
Usage: livebook [command] [options]
2+
alias LivebookCLI.{Task, Utils}
53

6-
Available commands:
4+
@switches [
5+
help: :boolean,
6+
version: :boolean
7+
]
78

8-
livebook server Starts the Livebook web application
9-
10-
The --help and --version options can be given instead of a command for usage and versioning information.
11-
"""
12-
end
9+
@aliases [
10+
h: :help,
11+
v: :version
12+
]
1313

1414
def main(args) do
15-
{:ok, _} = Application.ensure_all_started(:elixir)
16-
17-
extract_priv!()
18-
19-
:ok = Application.load(:livebook)
20-
21-
if unix?() do
22-
Application.put_env(:elixir, :ansi_enabled, true)
23-
end
24-
25-
call(args)
26-
end
27-
28-
defp unix?(), do: match?({:unix, _}, :os.type())
29-
30-
defp call([arg]) when arg in ["--help", "-h"], do: display_help()
31-
defp call([arg]) when arg in ["--version", "-v"], do: display_version()
32-
33-
defp call([task_name | args]) do
34-
case find_task(task_name) do
35-
nil ->
36-
IO.ANSI.format([:red, "Unknown command #{task_name}\n"]) |> IO.puts()
37-
IO.write(usage())
38-
39-
task ->
40-
call_task(task, args)
41-
end
42-
end
43-
44-
defp call(_args), do: IO.write(usage())
45-
46-
defp find_task("server"), do: LivebookCLI.Server
47-
defp find_task(_), do: nil
48-
49-
defp call_task(task, [arg]) when arg in ["--help", "-h"] do
50-
IO.write(task.usage())
51-
end
52-
53-
defp call_task(task, args) do
54-
try do
55-
task.call(args)
56-
rescue
57-
error in OptionParser.ParseError ->
58-
IO.ANSI.format([
59-
:red,
60-
Exception.message(error),
61-
"\n\nFor more information try --help"
62-
])
63-
|> IO.puts()
64-
65-
error ->
66-
IO.ANSI.format([:red, Exception.format(:error, error, __STACKTRACE__), "\n"]) |> IO.puts()
15+
Utils.setup()
16+
17+
case Utils.option_parse(args, strict: @switches, aliases: @aliases) do
18+
{parsed, [], _} when parsed.help -> display_help()
19+
{parsed, [name], _} when parsed.help -> Task.usage(name)
20+
{parsed, _, _} when parsed.version -> display_version()
21+
# We want to keep the switches for the task
22+
{_, [name | _], _} -> Task.call(name, List.delete(args, name))
6723
end
6824
end
6925

7026
defp display_help() do
71-
IO.puts("Livebook is an interactive notebook system for Elixir\n")
72-
IO.write(usage())
73-
end
74-
75-
defp display_version() do
76-
IO.puts(:erlang.system_info(:system_version))
77-
IO.puts("Elixir " <> System.build_info()[:build])
78-
79-
version = Livebook.Config.app_version()
80-
IO.puts("\nLivebook #{version}")
81-
end
82-
83-
import Record
84-
defrecord(:zip_file, extract(:zip_file, from_lib: "stdlib/include/zip.hrl"))
85-
86-
defp extract_priv!() do
87-
archive_dir = Path.join(Livebook.Config.tmp_path(), "escript")
88-
extracted_path = Path.join(archive_dir, "extracted")
89-
in_archive_priv_path = ~c"livebook/priv"
27+
Utils.print_text("""
28+
Livebook is an interactive notebook system for Elixir
9029
91-
# In dev we want to extract fresh directory on every boot
92-
if Livebook.Config.app_version() =~ "-dev" do
93-
File.rm_rf!(archive_dir)
94-
end
95-
96-
# When temporary directory is cleaned by the OS, the directories
97-
# may be left in place, so we use a regular file (extracted) to
98-
# check if the extracted archive is already available
99-
if not File.exists?(extracted_path) do
100-
{:ok, sections} = :escript.extract(:escript.script_name(), [])
101-
archive = Keyword.fetch!(sections, :archive)
102-
103-
file_filter = fn zip_file(name: name) ->
104-
List.starts_with?(name, in_archive_priv_path)
105-
end
106-
107-
case :zip.extract(archive, cwd: String.to_charlist(archive_dir), file_filter: file_filter) do
108-
{:ok, _} ->
109-
:ok
30+
Usage: livebook [command] [options]
11031
111-
{:error, error} ->
112-
print_error_and_exit(
113-
"Livebook failed to extract archive files, reason: #{inspect(error)}"
114-
)
115-
end
32+
Available commands:
11633
117-
File.touch!(extracted_path)
118-
end
34+
livebook server Starts the Livebook web application
11935
120-
priv_dir = Path.join(archive_dir, in_archive_priv_path)
121-
Application.put_env(:livebook, :priv_dir, priv_dir, persistent: true)
36+
The --help and --version options can be given instead of a command for usage and versioning information.\
37+
""")
12238
end
12339

124-
@spec print_error_and_exit(String.t()) :: no_return()
125-
defp print_error_and_exit(message) do
126-
IO.ANSI.format([:red, message]) |> IO.puts()
127-
System.halt(1)
40+
defp display_version() do
41+
Utils.print_text("""
42+
#{:erlang.system_info(:system_version)}
43+
Elixir #{System.build_info()[:build]}
44+
Livebook #{Livebook.Config.app_version()}\
45+
""")
12846
end
12947
end

lib/livebook_cli/task.ex

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,44 @@
11
defmodule LivebookCLI.Task do
2+
import LivebookCLI.Utils
3+
24
@doc """
35
Returns a description of the task usage.
46
"""
5-
@callback usage() :: String.t()
7+
@callback usage() :: IO.chardata()
68

79
@doc """
810
Runs the task with the given list of command line arguments.
911
"""
1012
@callback call(args :: list(String.t())) :: :ok
13+
14+
@doc """
15+
Runs the task with the given list of command line arguments.
16+
"""
17+
@spec call(String.t(), list(String.t())) :: :ok
18+
def call(name, args) do
19+
task = fetch_task!(name)
20+
task.call(args)
21+
22+
:ok
23+
rescue
24+
exception -> log_exception(exception, name, __STACKTRACE__)
25+
end
26+
27+
@doc """
28+
Shows the description of the task usage.
29+
"""
30+
@spec usage(String.t()) :: :ok
31+
def usage(name) do
32+
task = fetch_task!(name)
33+
print_text(task.usage())
34+
35+
:ok
36+
rescue
37+
exception -> log_exception(exception, name, __STACKTRACE__)
38+
end
39+
40+
@spec fetch_task!(String.t()) :: module() | no_return()
41+
defp fetch_task!("server"), do: LivebookCLI.Server
42+
defp fetch_task!("deploy"), do: LivebookCLI.Deploy
43+
defp fetch_task!(name), do: raise("Unknown command #{name}")
1144
end

lib/livebook_cli/utils.ex

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
defmodule LivebookCLI.Utils do
2+
def setup do
3+
{:ok, _} = Application.ensure_all_started(:elixir)
4+
5+
extract_priv!()
6+
7+
:ok = Application.load(:livebook)
8+
9+
if unix?() do
10+
Application.put_env(:elixir, :ansi_enabled, true)
11+
end
12+
end
13+
14+
defp unix?(), do: match?({:unix, _}, :os.type())
15+
16+
def option_parse(argv, opts \\ []) do
17+
{parsed, argv, errors} = OptionParser.parse(argv, opts)
18+
{Enum.into(parsed, %{}), argv, errors}
19+
end
20+
21+
def log_info(message) do
22+
IO.puts(message)
23+
end
24+
25+
if Mix.env() == :dev do
26+
def log_debug(message) do
27+
[:cyan, message]
28+
|> IO.ANSI.format()
29+
|> IO.puts()
30+
end
31+
else
32+
def log_debug(_message) do
33+
:ok
34+
end
35+
end
36+
37+
def log_warning(message) do
38+
[:yellow, message]
39+
|> IO.ANSI.format()
40+
|> IO.warn()
41+
end
42+
43+
def print_text(message) do
44+
message
45+
|> IO.ANSI.format()
46+
|> IO.puts()
47+
end
48+
49+
@spec log_exception(Exception.t(), String.t(), Exception.stacktrace()) :: no_return()
50+
def log_exception(exception, command_name, stacktrace) when is_exception(exception) do
51+
[:red, format_exception(exception, command_name, stacktrace)]
52+
|> IO.ANSI.format()
53+
|> IO.puts()
54+
55+
System.halt(1)
56+
end
57+
58+
defp format_exception(%OptionParser.ParseError{} = exception, command_name, _) do
59+
"""
60+
#{Exception.message(exception)}
61+
62+
For more information try:
63+
64+
livebook #{command_name} --help
65+
"""
66+
end
67+
68+
defp format_exception(%RuntimeError{} = exception, _, _) do
69+
Exception.message(exception)
70+
end
71+
72+
defp format_exception(exception, _, stacktrace) do
73+
Exception.format(:error, exception, stacktrace)
74+
end
75+
76+
import Record
77+
defrecord(:zip_file, extract(:zip_file, from_lib: "stdlib/include/zip.hrl"))
78+
79+
defp extract_priv!() do
80+
archive_dir = Path.join(Livebook.Config.tmp_path(), "escript")
81+
extracted_path = Path.join(archive_dir, "extracted")
82+
in_archive_priv_path = ~c"livebook/priv"
83+
84+
# In dev we want to extract fresh directory on every boot
85+
if Livebook.Config.app_version() =~ "-dev" do
86+
File.rm_rf!(archive_dir)
87+
end
88+
89+
# When temporary directory is cleaned by the OS, the directories
90+
# may be left in place, so we use a regular file (extracted) to
91+
# check if the extracted archive is already available
92+
if not File.exists?(extracted_path) do
93+
{:ok, sections} = :escript.extract(:escript.script_name(), [])
94+
archive = Keyword.fetch!(sections, :archive)
95+
96+
file_filter = fn zip_file(name: name) ->
97+
List.starts_with?(name, in_archive_priv_path)
98+
end
99+
100+
opts = [cwd: String.to_charlist(archive_dir), file_filter: file_filter]
101+
102+
with {:error, error} <- :zip.extract(archive, opts) do
103+
raise "Livebook failed to extract archive files, reason: #{inspect(error)}"
104+
end
105+
106+
File.touch!(extracted_path)
107+
end
108+
109+
priv_dir = Path.join(archive_dir, in_archive_priv_path)
110+
Application.put_env(:livebook, :priv_dir, priv_dir, persistent: true)
111+
end
112+
end

0 commit comments

Comments
 (0)