feat: 實作 bear apps 指令群 list/show/create/update/rotate-secret/toggle(issue #10)

- Api:新增 /api/v1/apps 端點(apps_list/get/create/update/rotate_secret/toggle,
  401/403/404/422/5xx/網路 分流)
- Apps:指令群實作——client_id → UUID 以 list 解析;一次性 client_secret
  僅於 create/rotate-secret 成功當下輸出;--json 輸出符合 docs/commands.md §3.6;
  visibility/status 過濾由 CLI 本地套用
- CLI:apps 子指令解析(重複選項收集為清單)、本地用法驗證(缺參數/列舉值/
  PKCE 未綁 --jwk-id → 退出碼 2)、help 更新
- 測試:注入 fake API 的單元測試 29 例(解析、輸出、退出碼、UUID 解析、
  401/403/404/422 分流、PKCE rotate)
- 文件:README.md 實作狀態、docs/commands.md §3.6/§9 更新
This commit is contained in:
2026-09-08 20:58:54 +08:00
parent 77c65ec299
commit 2192d067ce
6 changed files with 1255 additions and 5 deletions
+203 -1
View File
@@ -8,7 +8,7 @@ defmodule BearCli.CLI do
優先使用該值,不讀寫本機憑證檔。
"""
alias BearCli.{Api, Config, Credentials}
alias BearCli.{Api, Apps, Config, Credentials}
@global_switches [
issuer: :string,
@@ -23,6 +23,48 @@ defmodule BearCli.CLI do
@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)
# -- escript 入口 --
@doc "escript 主函式。執行後以 `System.halt/1` 設定退出碼。"
@@ -114,6 +156,27 @@ defmodule BearCli.CLI do
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(unknown, _args, _global) do
{:error, "未知指令:#{unknown}(可用 bear --help 查看說明)", 2}
end
@@ -131,10 +194,131 @@ defmodule BearCli.CLI do
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
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 """
@@ -156,6 +340,11 @@ defmodule BearCli.CLI do
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, command, cmd_opts} ->
cond do
cmd_opts[:version] ->
@@ -463,6 +652,19 @@ defmodule BearCli.CLI do
token [--refresh] 印出 access token(PAT 模式下 --refresh 為用法錯誤)
logout 登出(清除本機憑證)
status 顯示登入狀態
apps <動詞> App 管理(admin;bear apps --help 查看)
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>
全域選項:
--issuer URL Bear(OIDC Provider)位址(預設 https://alterminal.com)