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:
+203
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user