Files
bear-cli/lib/bear_cli/cli.ex
T
alex 870011f8a7 feat: 實作 bear CLI PAT 模式 MVP(login/whoami/token/logout/status)
- 新增 Elixir + Mix escript 專案(Req 0.5+、Jason)
- login --token <PAT>:以 PAT 驗證 /userinfo 後寫入憑證檔(0600)
- whoami:GET /userinfo 顯示身分 claims(支援 --json)
- token:印出 access token 供 pipe(--refresh 在 PAT 模式忽略)
- logout:清除本機憑證;status:純本機判定
- 憑證檔採原子寫入(rename)、0600、O_CREAT|O_EXCL 防 symlink
- 全域選項:--issuer/--config/--json/-v/-h/--version
- 29 個單元測試;mix precommit(compile --warnings-as-errors + format + test)
- 更新 README.md 與 docs/commands.md 反映 PAT 模式 MVP

Device Flow 登入待伺服器端 P1(bear 倉庫)完成後再接。issue #2
2026-08-31 11:14:12 +08:00

415 lines
11 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
defmodule BearCli.CLI do
@moduledoc """
`bear` 指令的命令列入口與指令分派。
支援指令:`login`(PAT 模式)、`whoami`、`token`、`logout`、`status`。
"""
alias BearCli.{Api, Config, Credentials}
@global_switches [
issuer: :string,
config: :string,
json: :boolean,
verbose: :boolean,
help: :boolean,
version: :boolean
]
@global_aliases [v: :verbose, h: :help]
@login_switches [token: :string, scope: :string, "client-id": :string]
@token_switches [refresh: :boolean]
# -- escript 入口 --
@doc "escript 主函式。執行後以 `System.halt/1` 設定退出碼。"
def main(argv) do
start_apps()
code = argv |> parse() |> dispatch()
System.halt(code)
end
# Req 預設用 Finch 作為 HTTP adapter,escript 需自行啟動一個 pool。
defp start_apps do
_ = Application.ensure_all_started(:req)
_ = Application.ensure_all_started(:finch)
case Finch.start_link(name: BearCli.Finch) do
{:ok, _} -> :ok
{:error, _} -> :ok
end
:ok
end
# -- 解析 --
@doc """
解析 argv,回傳:
- `{:version, opts}`
- `{:help, opts}`
- `{:ok, command, opts}`(opts 為 atom key 的 map)
- `{:error, message, exit_code}`
"""
def parse(argv) do
argv = Enum.map(argv, &to_string/1)
case OptionParser.parse_head(argv, strict: @global_switches, aliases: @global_aliases) do
{global, rest, []} ->
do_parse(global, rest)
{_global, _rest, [{opt, _} | _]} ->
{:error, "未知選項:#{opt}", 2}
end
end
defp do_parse(global, rest) do
cond do
global[:version] ->
{:version, %{}}
global[:help] == true and rest == [] ->
{:help, %{}}
rest == [] ->
{:help, %{}}
true ->
[command | args] = rest
parse_command(command, args, global)
end
end
defp parse_command("login", args, global) do
with {:ok, opts} <- parse_opts(args, @global_switches ++ @login_switches) do
{:ok, :login, merge_opts(global, opts)}
end
end
defp parse_command("whoami", args, global) do
with {:ok, opts} <- parse_opts(args, @global_switches) do
{:ok, :whoami, merge_opts(global, opts)}
end
end
defp parse_command("token", args, global) do
with {:ok, opts} <- parse_opts(args, @global_switches ++ @token_switches) do
{:ok, :token, merge_opts(global, opts)}
end
end
defp parse_command("logout", args, global) do
with {:ok, opts} <- parse_opts(args, @global_switches) do
{:ok, :logout, merge_opts(global, opts)}
end
end
defp parse_command("status", args, global) do
with {:ok, opts} <- parse_opts(args, @global_switches) do
{:ok, :status, merge_opts(global, opts)}
end
end
defp parse_command(unknown, _args, _global) do
{:error, "未知指令:#{unknown}(可用 bear --help 查看說明)", 2}
end
defp parse_opts(args, switches) do
case OptionParser.parse(args, strict: switches) do
{opts, [], []} ->
{:ok, opts}
{_opts, extra, []} ->
{:error, "不接受的參數:#{Enum.join(extra, " ")}", 2}
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
end
defp merge_opts(global, opts) do
Map.merge(Map.new(global), Map.new(opts))
end
# -- 分派 --
@doc """
執行已解析的指令,回傳退出碼。`opts[:api]` 可注入假的 userinfo 函式供測試。
"""
def dispatch(parsed, opts \\ []) do
api = opts[:api] || (&Api.userinfo/2)
case parsed do
{:version, _} ->
IO.puts(BearCli.version())
0
{:help, _} ->
print_help()
0
{:error, message, code} ->
IO.puts(:stderr, "錯誤:#{message}")
code
{:ok, command, cmd_opts} ->
cond do
cmd_opts[:version] ->
IO.puts(BearCli.version())
0
cmd_opts[:help] ->
print_help()
0
true ->
run(command, cmd_opts, api)
end
end
end
# -- 指令實作 --
defp run(:login, opts, api) do
case opts[:token] do
nil ->
IO.puts(:stderr, "Device Flow 登入尚未開放(伺服器端 P1 未實作)。")
IO.puts(:stderr, "請改用個人存取權杖登入:bear login --token <PAT>")
2
"" ->
fail(opts, "token 不可為空", 2)
token ->
token = if token == "-", do: read_token_from_stdin(), else: token
do_login(opts, token, api)
end
end
defp run(:whoami, opts, api) do
case Credentials.load() do
{:ok, creds} ->
issuer = resolve_issuer(opts, creds["issuer"])
token = creds["access_token"]
if blank?(token) do
fail(opts, "憑證檔缺少 access_token,請重新 bear login", 7)
else
if opts[:verbose], do: IO.puts(:stderr, "issuer: #{issuer}")
case api.(issuer, token) do
{:ok, claims} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, claims: claims}))
else
print_claims(claims)
end
0
{:error, :unauthorized, desc} ->
fail(opts, "token 無效或已過期(#{desc}),請重新 bear login", 1)
{:error, :network, reason} ->
fail(opts, "無法連線到 #{issuer}:#{reason}", 6)
{:error, :server_error, status} ->
fail(opts, "伺服器回應 #{status}", 6)
end
end
:error ->
fail(opts, "未登入(請先執行 bear login)", 3)
end
end
defp run(:token, opts, _api) do
case Credentials.load() do
{:ok, creds} ->
if opts[:refresh] do
IO.puts(:stderr, "警告:PAT 模式無 refresh token,--refresh 已忽略。")
end
case creds["access_token"] do
token when is_binary(token) and token != "" ->
# token 指令只輸出 token 本體(單行、無前綴),供 pipe 使用。
IO.puts(token)
0
_ ->
fail_token("憑證檔缺少 access_token,請重新 bear login", 7)
end
:error ->
fail_token("未登入(請先執行 bear login)", 3)
end
end
defp run(:logout, opts, _api) do
case Credentials.delete() do
:ok ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true}))
else
IO.puts("已登出(本機憑證已清除)。")
IO.puts("注意:PAT 本身仍在伺服器端有效,如需撤銷請至 Bear 網頁「個人存取權杖」頁面。")
end
0
{:error, reason} ->
fail(opts, "清除憑證失敗:#{inspect(reason)}", 1)
end
end
defp run(:status, opts, _api) do
case Credentials.load() do
{:ok, creds} ->
if opts[:json] do
IO.puts(
Jason.encode!(%{
ok: true,
logged_in: true,
email: creds["email"],
issuer: creds["issuer"],
mode: "pat"
})
)
else
IO.puts("已登入:#{creds["email"] || "(未知)"}")
IO.puts("模式:個人存取權杖(PAT)")
IO.puts("issuer:#{creds["issuer"]}")
end
0
:error ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: false, logged_in: false, error: "未登入", code: 3}))
else
IO.puts("未登入")
end
3
end
end
# -- 指令實作輔助 --
defp do_login(opts, token, api) do
issuer = resolve_issuer(opts)
if opts[:verbose], do: IO.puts(:stderr, "issuer: #{issuer}")
case api.(issuer, token) do
{:ok, claims} ->
email = claims["email"]
creds = %{"issuer" => issuer, "access_token" => token, "email" => email}
case Credentials.save(creds) do
:ok ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, email: email}))
else
IO.puts("已登入:#{email || "(無 email claim)"}")
end
0
{:error, reason} ->
fail(opts, "憑證檔寫入失敗:#{inspect(reason)}", 1)
end
{:error, :unauthorized, desc} ->
fail(opts, "token 無效或已過期(#{desc})", 1)
{:error, :network, reason} ->
fail(opts, "無法連線到 #{issuer}:#{reason}", 6)
{:error, :server_error, status} ->
fail(opts, "伺服器回應 #{status}", 6)
end
end
# -- 輔助 --
# 優先取 --issuer flag;否則取憑證內記錄的 issuer;最後才回落 config/env/預設。
defp resolve_issuer(opts, stored_issuer \\ nil) do
opts[:issuer] || stored_issuer || Config.resolve_issuer(nil, opts[:config])
end
defp read_token_from_stdin do
IO.read(:stdio, :line) |> to_string() |> String.trim()
end
defp fail(opts, message, code) do
if opts[:json] do
IO.puts(Jason.encode!(%{ok: false, error: message, code: code}))
else
IO.puts(:stderr, "錯誤:#{message}")
end
code
end
# token 指令的錯誤一律走 stderr,避免污染 stdout(pipe 的對象)。
defp fail_token(message, code) do
IO.puts(:stderr, "錯誤:#{message}")
code
end
@preferred_claims ~w(sub email name preferred_username nickname picture)
defp print_claims(claims) do
preferred =
Enum.filter(@preferred_claims, &Map.has_key?(claims, &1))
rest =
claims
|> Map.drop(@preferred_claims)
|> Enum.sort_by(fn {k, _} -> to_string(k) end)
Enum.each(preferred, fn k ->
IO.puts("#{String.pad_trailing(k, 20)}: #{format_value(claims[k])}")
end)
Enum.each(rest, fn {k, v} ->
IO.puts("#{String.pad_trailing(to_string(k), 20)}: #{format_value(v)}")
end)
end
defp format_value(v) when is_binary(v), do: v
defp format_value(v) when is_number(v), do: to_string(v)
defp format_value(v) when is_boolean(v), do: to_string(v)
defp format_value(v) when is_nil(v), do: ""
defp format_value(v), do: Jason.encode!(v)
defp blank?(nil), do: true
defp blank?(""), do: true
defp blank?(_), do: false
defp print_help do
IO.puts("""
Bear CLI — Bear SSO 終端機客戶端
用法:bear <指令> [選項]
指令:
login --token <PAT> 以個人存取權杖登入(PAT 模式;Device Flow 尚未開放)
whoami 顯示目前登入身分
token [--refresh] 印出 access token(PAT 模式下 --refresh 為無作用)
logout 登出(清除本機憑證)
status 顯示登入狀態
全域選項:
--issuer URL Bear(OIDC Provider)位址(預設 https://alterminal.com)
--config PATH 設定檔路徑(預設 ~/.config/bear/config.json)
--json 機器可讀輸出(JSON)
-v, --verbose 顯示詳細日誌
-h, --help 顯示說明
--version 顯示版本
""")
end
end