Files
bear-cli/lib/bear_cli/cli.ex
T
alex 0b18d76cfd feat: 實作 vault 指令群 status/unlock/lock/list/get/create/edit/delete/restore/purge/folders/sync/password/rescue(issue #17)
- 對接 alterminal/bear#41 的 /api/v1/vault JSON API(PAT Bearer)
- 客戶端端到端加密,格式與 Web Vault(P2)完全一致:
  - 加密字串 2.<iv>.<ct>.<mac>(AES-256-CBC+HMAC-SHA-256、PKCS#7、encrypt-then-MAC)
  - 主金鑰 PBKDF2-SHA512(迭代數取自 /vault/config 與 profile,不寫死;salt=email 小寫)
  - BIP39 助記詞(12 字、128-bit、英文詞表)+救援路徑
- K_user 僅存記憶體:vault unlock 匯出 BEAR_VAULT_SESSION(base64),
  同 Bitwarden CLI BW_SESSION 慣例;lock 提示 unset;不寫入任何檔案
- docs/commands.md 補 §3.11 規格;README 同步
- 測試:fake API 注入+Bear.Vault.Crypto 密文樣本交叉驗證(雙向);
  BIP39 官方向量;46 個新測試,全套 196 passed
2026-09-10 23:27:47 +08:00

1028 lines
33 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`。
`BEAR_TOKEN` 環境變數(一次性/CI 用的 PAT)提供時,`whoami`/`token`/`status`
優先使用該值,不讀寫本機憑證檔。
"""
alias BearCli.{
Accounts,
Admin,
Api,
Apps,
AuditLogs,
Config,
Credentials,
Jwks,
SelfService,
Vault
}
@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]
# -- apps 指令群(docs/commands.md §3.6)--
# 注意:OptionParser 會把選項名的連字號正規化為底線
# (--client-id → :client_id),switch 定義需用底線 atom。
@apps_verbs ~w(list show create update rotate-secret toggle)
@apps_list_switches [
visibility: :string,
status: :string,
page: :integer,
per_page: :integer
]
@apps_create_switches [
client_id: :string,
url: :string,
title: :string,
method: :string,
visibility: :string,
redirect_url: :keep,
post_logout_redirect_uri: :keep,
scope: :keep,
sub: :string,
jwk_id: :string,
secret: :string
]
@apps_update_switches [
url: :string,
title: :string,
method: :string,
visibility: :string,
redirect_url: :keep,
post_logout_redirect_uri: :keep,
scope: :keep,
sub: :string,
jwk_id: :string
]
@visibilities ~w(public internal)
@statuses ~w(active inactive)
@methods ~w(client_secret PKCE)
# -- 個人自助指令群(docs/commands.md §3.7)--
@profile_verbs ~w(show set)
@sessions_verbs ~w(list revoke revoke-others)
@tokens_verbs ~w(list create revoke)
@mfa_verbs ~w(status setup disable recovery-codes)
@profile_set_switches [
name: :string,
given_name: :string,
family_name: :string,
nickname: :string,
preferred_username: :string,
profile: :string,
picture: :string,
website: :string,
gender: :string,
birthdate: :string,
zoneinfo: :string,
locale: :string,
phone_number: :string,
phone_verified: :boolean,
address_formatted: :string,
address_street_address: :string,
address_locality: :string,
address_region: :string,
address_postal_code: :string,
address_country: :string
]
@email_switches [new: :string]
@tokens_create_switches [name: :string, expires_in: :string]
# -- 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("apps", args, global) do
case args do
[verb | rest] when verb in @apps_verbs ->
switches = apps_switches(verb)
case OptionParser.parse(rest, strict: @global_switches ++ switches) do
{opts, positionals, []} ->
validate_apps(verb, positionals, merge_opts(global, opts_to_map(opts)))
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
[] ->
{:error, "apps 缺少子指令(可用:#{Enum.join(@apps_verbs, " ")})", 2}
[unknown | _] ->
{:error, "未知的 apps 子指令:#{unknown}(可用:#{Enum.join(@apps_verbs, " ")})", 2}
end
end
defp parse_command("profile", args, global) do
case args do
[verb | rest] when verb in @profile_verbs ->
switches = if verb == "set", do: @profile_set_switches, else: []
case OptionParser.parse(rest, strict: @global_switches ++ switches) do
{opts, positionals, []} ->
validate_self_service(
:profile,
verb,
positionals,
merge_opts(global, opts_to_map(opts))
)
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
[] ->
{:error, "profile 缺少子指令(可用:#{Enum.join(@profile_verbs, " ")})", 2}
[unknown | _] ->
{:error, "未知的 profile 子指令:#{unknown}(可用:#{Enum.join(@profile_verbs, " ")})", 2}
end
end
defp parse_command("password", args, global) do
case args do
["change" | rest] ->
case OptionParser.parse(rest, strict: @global_switches) do
{opts, [], []} ->
{:ok, {:self_service, :password, :change}, merge_opts(global, opts_to_map(opts))}
{_opts, extra, []} ->
{:error, "password change 不接受參數:#{Enum.join(extra, " ")}", 2}
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
[] ->
{:error, "password 缺少子指令(可用:change)", 2}
[unknown | _] ->
{:error, "未知的 password 子指令:#{unknown}(可用:change)", 2}
end
end
defp parse_command("email", args, global) do
case args do
["change" | rest] ->
case OptionParser.parse(rest, strict: @global_switches ++ @email_switches) do
{opts, [], []} ->
{:ok, {:self_service, :email, :change}, merge_opts(global, opts_to_map(opts))}
{_opts, extra, []} ->
{:error, "email change 不接受位置參數:#{Enum.join(extra, " ")}", 2}
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
[] ->
{:error, "email 缺少子指令(可用:change)", 2}
[unknown | _] ->
{:error, "未知的 email 子指令:#{unknown}(可用:change)", 2}
end
end
defp parse_command("sessions", args, global) do
case args do
[verb | rest] when verb in @sessions_verbs ->
parse_group_verb("sessions", verb, rest, [], global)
[] ->
{:error, "sessions 缺少子指令(可用:#{Enum.join(@sessions_verbs, " ")})", 2}
[unknown | _] ->
{:error, "未知的 sessions 子指令:#{unknown}(可用:#{Enum.join(@sessions_verbs, " ")})", 2}
end
end
defp parse_command("tokens", args, global) do
case args do
[verb | rest] when verb in @tokens_verbs ->
parse_group_verb("tokens", verb, rest, [], global)
[] ->
{:error, "tokens 缺少子指令(可用:#{Enum.join(@tokens_verbs, " ")})", 2}
[unknown | _] ->
{:error, "未知的 tokens 子指令:#{unknown}(可用:#{Enum.join(@tokens_verbs, " ")})", 2}
end
end
defp parse_command("mfa", args, global) do
case args do
[verb | rest] when verb in @mfa_verbs ->
parse_group_verb("mfa", verb, rest, [], global)
[] ->
{:error, "mfa 缺少子指令(可用:#{Enum.join(@mfa_verbs, " ")})", 2}
[unknown | _] ->
{:error, "未知的 mfa 子指令:#{unknown}(可用:#{Enum.join(@mfa_verbs, " ")})", 2}
end
end
# 管理端指令群(jwks/accounts/audit-logs;解析委由 BearCli.Admin)
defp parse_command(group, args, global) when group in ~w(jwks accounts audit-logs) do
case Admin.parse([group | args], global) do
:error -> {:error, "未知指令:#{group}(可用 bear --help 查看說明)", 2}
parsed -> parsed
end
end
# vault 指令群(issue #17;解析委由 BearCli.Vault)
defp parse_command("vault", args, global) do
Vault.parse(["vault" | args], global)
end
defp parse_command(unknown, _args, _global) do
{:error, "未知指令:#{unknown}(可用 bear --help 查看說明)", 2}
end
# sessions/tokens/mfa 共用:verb 已驗證合法後解析選項與位置參數。
defp parse_group_verb(group, verb, rest, extra_switches, global) do
switches = group_switches(group, verb) ++ extra_switches
case OptionParser.parse(rest, strict: @global_switches ++ switches) do
{opts, positionals, []} ->
validate_self_service(group, verb, positionals, merge_opts(global, opts_to_map(opts)))
{_opts, _extra, [{opt, _} | _]} ->
{:error, "無法解析選項:#{opt}", 2}
end
end
defp group_switches("tokens", "create"), do: @tokens_create_switches
defp group_switches(_group, _verb), do: []
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
# -- apps 解析輔助 --
defp apps_switches("list"), do: @apps_list_switches
defp apps_switches("show"), do: []
defp apps_switches("create"), do: @apps_create_switches
defp apps_switches("update"), do: @apps_update_switches
defp apps_switches("rotate-secret"), do: []
defp apps_switches("toggle"), do: []
# apps 子指令的本地用法驗證(§3.6:用法錯誤 → 退出碼 2)。
defp validate_apps("list", [], opts) do
cond do
invalid_enum?(opts[:visibility], @visibilities, "--visibility") ->
usage_error(opts[:visibility], "--visibility", @visibilities)
invalid_enum?(opts[:status], @statuses, "--status") ->
usage_error(opts[:status], "--status", @statuses)
true ->
{:ok, {Apps, :list}, opts}
end
end
defp validate_apps("list", extra, _opts),
do: {:error, "apps list 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_apps("show", [client_id], opts),
do: {:ok, {Apps, :show}, Map.put(opts, :client_id_arg, client_id)}
defp validate_apps("show", [], _opts), do: {:error, "apps show 缺少 <client-id>", 2}
defp validate_apps("show", extra, _opts),
do: {:error, "apps show 只接受一個 <client-id>(多餘:#{Enum.join(Enum.drop(extra, 1), " ")})", 2}
defp validate_apps("create", [], opts) do
missing =
[{"client-id", opts[:client_id]}, {"url", opts[:url]}, {"title", opts[:title]}]
|> Enum.filter(fn {_name, v} -> blank?(v) end)
|> Enum.map(&elem(&1, 0))
cond do
missing != [] ->
{:error, "apps create 缺少必選參數:#{Enum.join(missing, "、")}", 2}
invalid_enum?(opts[:method], @methods, "--method") ->
usage_error(opts[:method], "--method", @methods)
invalid_enum?(opts[:visibility], @visibilities, "--visibility") ->
usage_error(opts[:visibility], "--visibility", @visibilities)
opts[:method] == "PKCE" and blank?(opts[:jwk_id]) ->
{:error, "method=PKCE 必須給 --jwk-id", 2}
true ->
{:ok, {Apps, :create}, opts}
end
end
defp validate_apps("create", extra, _opts),
do: {:error, "apps create 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_apps("update", [client_id], opts) do
cond do
invalid_enum?(opts[:method], @methods, "--method") ->
usage_error(opts[:method], "--method", @methods)
invalid_enum?(opts[:visibility], @visibilities, "--visibility") ->
usage_error(opts[:visibility], "--visibility", @visibilities)
opts[:method] == "PKCE" and blank?(opts[:jwk_id]) ->
{:error, "method=PKCE 必須給 --jwk-id", 2}
true ->
{:ok, {Apps, :update}, Map.put(opts, :client_id_arg, client_id)}
end
end
defp validate_apps("update", [], _opts), do: {:error, "apps update 缺少 <client-id>", 2}
defp validate_apps("update", extra, _opts),
do: {:error, "apps update 只接受一個 <client-id>(多餘:#{Enum.join(Enum.drop(extra, 1), " ")})", 2}
defp validate_apps("rotate-secret", [client_id], opts),
do: {:ok, {Apps, :"rotate-secret"}, Map.put(opts, :client_id_arg, client_id)}
defp validate_apps("rotate-secret", [], _opts),
do: {:error, "apps rotate-secret 缺少 <client-id>", 2}
defp validate_apps("rotate-secret", extra, _opts),
do:
{:error, "apps rotate-secret 只接受一個 <client-id>(多餘:#{Enum.join(Enum.drop(extra, 1), " ")})",
2}
defp validate_apps("toggle", [client_id], opts),
do: {:ok, {Apps, :toggle}, Map.put(opts, :client_id_arg, client_id)}
defp validate_apps("toggle", [], _opts), do: {:error, "apps toggle 缺少 <client-id>", 2}
defp validate_apps("toggle", extra, _opts),
do: {:error, "apps toggle 只接受一個 <client-id>(多餘:#{Enum.join(Enum.drop(extra, 1), " ")})", 2}
defp invalid_enum?(nil, _allowed, _flag), do: false
defp invalid_enum?(value, allowed, _flag), do: value not in allowed
defp usage_error(value, flag, allowed) do
{:error, "#{flag} 不接受的值:#{value}(可用:#{Enum.join(allowed, "、")})", 2}
end
# -- 個人自助指令群解析輔助(§3.7)--
# 各群組的位置參數驗證;通過後回 {:ok, {:self_service, group, verb}, opts}。
defp validate_self_service(:profile, "show", [], opts),
do: {:ok, {:self_service, :profile, :show}, opts}
defp validate_self_service(:profile, "show", extra, _opts),
do: {:error, "profile show 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service(:profile, "set", [], opts),
do: {:ok, {:self_service, :profile, :set}, opts}
defp validate_self_service(:profile, "set", extra, _opts),
do: {:error, "profile set 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("sessions", "list", [], opts),
do: {:ok, {:self_service, :sessions, :list}, opts}
defp validate_self_service("sessions", "list", extra, _opts),
do: {:error, "sessions list 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("sessions", "revoke", [id], opts),
do: {:ok, {:self_service, :sessions, :revoke}, Map.put(opts, :id_arg, id)}
defp validate_self_service("sessions", "revoke", [], _opts),
do: {:error, "sessions revoke 缺少 <id>", 2}
defp validate_self_service("sessions", "revoke", extra, _opts),
do: {:error, "sessions revoke 只接受一個 <id>(多餘:#{Enum.join(Enum.drop(extra, 1), " ")})", 2}
defp validate_self_service("sessions", "revoke-others", [], opts),
do: {:ok, {:self_service, :sessions, :"revoke-others"}, opts}
defp validate_self_service("sessions", "revoke-others", extra, _opts),
do: {:error, "sessions revoke-others 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("tokens", "list", [], opts),
do: {:ok, {:self_service, :tokens, :list}, opts}
defp validate_self_service("tokens", "list", extra, _opts),
do: {:error, "tokens list 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("tokens", "create", [], opts) do
if blank?(opts[:name]) do
{:error, "tokens create 缺少 --name NAME", 2}
else
{:ok, {:self_service, :tokens, :create}, opts}
end
end
defp validate_self_service("tokens", "create", extra, _opts),
do: {:error, "tokens create 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("tokens", "revoke", [id], opts),
do: {:ok, {:self_service, :tokens, :revoke}, Map.put(opts, :id_arg, id)}
defp validate_self_service("tokens", "revoke", [], _opts),
do: {:error, "tokens revoke 缺少 <id>", 2}
defp validate_self_service("tokens", "revoke", extra, _opts),
do: {:error, "tokens revoke 只接受一個 <id>(多餘:#{Enum.join(Enum.drop(extra, 1), " ")})", 2}
defp validate_self_service("mfa", "status", [], opts),
do: {:ok, {:self_service, :mfa, :status}, opts}
defp validate_self_service("mfa", "status", extra, _opts),
do: {:error, "mfa status 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("mfa", "setup", [], opts),
do: {:ok, {:self_service, :mfa, :setup}, opts}
defp validate_self_service("mfa", "setup", extra, _opts),
do: {:error, "mfa setup 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("mfa", "disable", [], opts),
do: {:ok, {:self_service, :mfa, :disable}, opts}
defp validate_self_service("mfa", "disable", extra, _opts),
do: {:error, "mfa disable 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp validate_self_service("mfa", "recovery-codes", [], opts),
do: {:ok, {:self_service, :mfa, :"recovery-codes"}, opts}
defp validate_self_service("mfa", "recovery-codes", extra, _opts),
do: {:error, "mfa recovery-codes 不接受位置參數:#{Enum.join(extra, " ")}", 2}
defp merge_opts(global, opts) do
Map.merge(Map.new(global), Map.new(opts))
end
# keyword 轉 map:`:keep` 選項(@keep_keys)重複出現時收集為清單,其餘取最後值。
@keep_keys ~w(redirect_url post_logout_redirect_uri scope)a
defp opts_to_map(opts) do
Enum.reduce(opts, %{}, fn
{k, v}, acc when k in @keep_keys ->
Map.update(acc, k, [v], fn existing -> existing ++ [v] end)
{k, v}, acc ->
Map.put(acc, k, v)
end)
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, :vault} ->
# vault 群組專屬說明
_ = Vault.run(:help, %{})
0
{:help, _} ->
print_help()
0
{:error, message, code} ->
IO.puts(:stderr, "錯誤:#{message}")
code
# apps 指令群({:ok, {module, verb}, opts} 形狀;注入的 api 模組同時作為 apps_api)
{:ok, {Apps, verb}, cmd_opts} when is_atom(verb) ->
apps_api = cmd_opts[:apps_api] || opts[:apps_api] || Api
Apps.run(verb, Map.put(cmd_opts, :apps_api, apps_api))
# 個人自助指令群({:ok, {:self_service, group, verb}, opts} 形狀)
{:ok, {:self_service, group, verb}, cmd_opts} when is_atom(group) and is_atom(verb) ->
inject_opts = opts |> Map.new() |> Map.take([:ss_api, :io, :tty?])
ss_opts = Map.merge(inject_opts, Map.take(cmd_opts, [:ss_api, :io, :tty?]))
SelfService.run(group, verb, Map.merge(cmd_opts, ss_opts))
# 管理端指令群(jwks/accounts/audit-logs;各自注入對應的 api 模組)
{:ok, {Jwks, verb}, cmd_opts} when is_atom(verb) ->
jwks_api = cmd_opts[:jwks_api] || opts[:jwks_api] || Api
Jwks.run(verb, Map.put(cmd_opts, :jwks_api, jwks_api))
{:ok, {Accounts, verb}, cmd_opts} when is_atom(verb) ->
accounts_api = cmd_opts[:accounts_api] || opts[:accounts_api] || Api
Accounts.run(verb, Map.put(cmd_opts, :accounts_api, accounts_api))
{:ok, {AuditLogs, verb}, cmd_opts} when is_atom(verb) ->
audit_logs_api = cmd_opts[:audit_logs_api] || opts[:audit_logs_api] || Api
AuditLogs.run(verb, Map.put(cmd_opts, :audit_logs_api, audit_logs_api))
# vault 指令群({:ok, {BearCli.Vault, verb}, opts} 形狀)
{:ok, {BearCli.Vault, verb}, cmd_opts} when is_atom(verb) ->
vault_opts = opts |> Map.new() |> Map.take([:vault_api, :io, :tty?, :session, :email])
Vault.run(verb, Map.merge(cmd_opts, vault_opts))
{: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 Config.env_token() do
# BEAR_TOKEN 優先於憑證檔(不讀檔、不寫檔)。
nil ->
whoami_from_credentials(opts, api)
token ->
call_userinfo(resolve_issuer(opts), token, opts, api)
end
end
defp run(:token, opts, _api) do
cond do
# BEAR_TOKEN 優先於憑證檔(一次性/CI 用,不寫入)。
token = Config.env_token() ->
IO.puts(token)
0
true ->
token_from_credentials(opts)
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
env_token = Config.env_token()
cond do
env_token ->
issuer = resolve_issuer(opts)
if opts[:json] do
IO.puts(
Jason.encode!(%{
ok: true,
logged_in: true,
mode: "env_token",
issuer: issuer
})
)
else
IO.puts("已登入(BEAR_TOKEN 環境變數)")
IO.puts("模式:個人存取權杖(環境變數,不寫入憑證檔)")
IO.puts("issuer:#{issuer}")
end
0
true ->
status_from_credentials(opts)
end
end
# -- 指令實作輔助 --
defp whoami_from_credentials(opts, api) do
case Credentials.load() do
{:ok, creds} ->
token = creds["access_token"]
if blank?(token) do
fail(opts, "憑證檔缺少 access_token,請重新 bear login", 7)
else
call_userinfo(resolve_issuer(opts, creds["issuer"]), token, opts, api)
end
:error ->
fail(opts, "未登入(請先執行 bear login)", 3)
end
end
defp call_userinfo(issuer, token, opts, api) do
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", 3)
{:error, :network, reason} ->
fail(opts, "無法連線到 #{issuer}:#{reason}", 6)
{:error, :server_error, status} ->
fail(opts, "伺服器回應 #{status}", 6)
end
end
defp token_from_credentials(opts) do
case Credentials.load() do
{:ok, creds} ->
if opts[:refresh] do
# 規格 §3.3:PAT 模式下 --refresh 為無效選項,報用法錯誤。
fail_token("PAT 模式無 refresh token,--refresh 為無效選項", 2)
else
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
end
:error ->
fail_token("未登入(請先執行 bear login)", 3)
end
end
defp status_from_credentials(opts) 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} ->
# 規格 §3.1:PAT 無效或已撤銷 → 退出碼 3。
fail(opts, "PAT 無效或已撤銷(#{desc}),不寫入任何資料", 3)
{: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 顯示登入狀態
apps <動詞> App 管理(admin;bear apps --help 查看)
jwks <動詞> 簽章金鑰管理(admin)
accounts <動詞> 帳號管理(admin)
audit-logs list 稽核日誌(admin)
profile <動詞> 個人資料(show/set)
password change 變更密碼(互動輸入,不進 shell history)
email change --new E 變更 email(雙向驗證碼流程)
sessions <動詞> 管理網頁 session(list/revoke/revoke-others)
tokens <動詞> 管理 PAT(list/create/revoke)
mfa <動詞> TOTP 兩因子(status/setup/disable/recovery-codes)
apps 指令群(需 admin 權限的 PAT):
apps list [--visibility public|internal] [--status active|inactive]
[--page N] [--per-page N]
apps show <client-id>
apps create --client-id ID --url URL --title TITLE
[--method client_secret|PKCE] [--visibility public|internal]
[--redirect-url URL]... [--post-logout-redirect-uri URI]...
[--scope SCOPE]... [--sub FIELD] [--jwk-id ID] [--secret SECRET]
apps update <client-id> [同 create 的選項;只更新有給的欄位]
apps rotate-secret <client-id>
apps toggle <client-id>
個人自助指令群(任何有效 PAT 皆可):
profile show
profile set [--name NAME] [--given-name NAME] [--family-name NAME]
[--nickname NAME] [--preferred-username NAME]
[--profile URL] [--picture URL] [--website URL]
[--gender male|female|other] [--birthdate YYYY-MM-DD]
[--zoneinfo TZ] [--locale LOC] [--phone-number E164]
[--phone-verified] [--address-formatted TEXT]
[--address-street-address TEXT] [--address-locality TEXT]
[--address-region TEXT] [--address-postal-code TEXT]
[--address-country TEXT]
password change
email change --new EMAIL
sessions list
sessions revoke <id>
sessions revoke-others
tokens list
tokens create --name NAME [--expires-in DAYS|never]
tokens revoke <id>
mfa status
mfa setup
mfa disable
mfa recovery-codes
vault 指令群(任何有效 PAT 皆可;詳見 bear vault --help 與 docs/commands.md §3.11):
vault status 初始化狀態、KDF 參數、解鎖狀態
vault unlock 主密碼解鎖 → 匯出 BEAR_VAULT_SESSION
vault lock 丟棄 session(unset 環境變數)
vault list [--folder UUID] 列出項目(解鎖後顯示明文名稱)
vault get <uuid> 顯示單一項目(解鎖後解密欄位)
vault create --type login|secure_note|card|identity [--folder UUID]
vault edit <uuid> 互動編輯(Enter 保留現值)
vault delete <uuid> 丟入回收桶
vault restore <uuid> 從回收桶還原
vault purge <uuid> 永久刪除(需確認)
vault folders list|create|rename <uuid>|delete <uuid>
vault sync 完整同步(伺服器為準)
vault password change 變更 vault 主密碼
vault rescue 助記詞救援(重設主密碼)
全域選項:
--issuer URL Bear(OIDC Provider)位址(預設 https://alterminal.com)
--config PATH 設定檔路徑(預設 ~/.config/bear/config.json)
--json 機器可讀輸出(JSON)
-v, --verbose 顯示詳細日誌
-h, --help 顯示說明
--version 顯示版本
環境變數:
BEAR_TOKEN 一次性/CI 用的 PAT;提供後 whoami/token/status 優先
使用,不寫入憑證檔
""")
end
end