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
+446
View File
@@ -0,0 +1,446 @@
defmodule BearCli.Apps do
@moduledoc """
`bear apps` 指令群(App 管理;docs/commands.md §3.6、issue #10)。
對應伺服器端 App 管理 JSON API(alterminal/bear#28,`/api/v1/apps`、
PAT Bearer、admin 限定)。認證沿用既有 PAT 憑證(`BEAR_TOKEN` 或憑證檔)。
API 路徑參數為資料庫 UUID,CLI 對外介面一律使用 `client_id`;show/
update/rotate-secret/toggle 先以 list 比對解析(§7 開放問題 6 的結論)。
`client_secret` 僅於 create/rotate-secret 成功當下一次性輸出,不寫入
憑證檔/log/`--verbose`。
退出碼:0 成功;1 404/422;2 用法錯誤;3 未登入或 401;6 網路/伺服器
錯誤;8 權限不足(403,僅限 admin)。
"""
alias BearCli.{Api, Config, Credentials}
@list_per_page_fetch 100
@max_fetch_pages 50
# -- 入口 --
@doc """
執行 `bear apps <動詞>`,回傳退出碼。
`opts[:apps_api]` 可注入假 API 模組供測試(需實作 `apps_list/3`、
`apps_get/3`、`apps_create/3`、`apps_update/4`、`apps_rotate_secret/3`、
`apps_toggle/3`)。
"""
def run(verb, opts) do
api = opts[:apps_api] || Api
with {:ok, ctx} <- context(opts) do
execute(verb, ctx, opts, api)
else
{:error, message, code} -> fail(opts, message, code)
end
end
# -- 共用背景(token/issuer)--
defp context(opts) do
if token = Config.env_token() do
# BEAR_TOKEN 優先於憑證檔(不讀檔、不寫檔)。
{:ok, %{token: token, issuer: resolve_issuer(opts, nil)}}
else
case Credentials.load() do
{:ok, creds} ->
token = creds["access_token"]
if blank?(token) do
{:error, "憑證檔缺少 access_token,請重新 bear login", 7}
else
{:ok, %{token: token, issuer: resolve_issuer(opts, creds["issuer"])}}
end
:error ->
{:error, "未登入(請先執行 bear login)", 3}
end
end
end
defp resolve_issuer(opts, stored_issuer) do
opts[:issuer] || stored_issuer || Config.resolve_issuer(nil, opts[:config])
end
# -- 各動詞 --
defp execute(:list, ctx, opts, api) do
filters =
opts
|> Map.take([:visibility, :status])
|> Enum.reject(fn {_k, v} -> blank?(v) end)
|> Map.new()
if filters == %{} do
params = [page: opts[:page] || 1, per_page: opts[:per_page] || 20]
case api.apps_list(ctx.issuer, ctx.token, params) do
{:ok, body} ->
apps = body["data"] || []
page = body["page"] || params[:page]
per_page = body["per_page"] || params[:per_page]
total = body["total"] || length(apps)
render_list(opts, sort_apps(apps), page, per_page, total)
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
# 伺服器 list 端點目前僅支援分頁(bear#28),無 visibility/status
# 過濾參數;有過濾時以全量列舉後在本地過濾、排序與分頁。
with {:ok, all} <- fetch_all(ctx, api) do
filtered =
all
|> Enum.filter(&matches_filters?(&1, filters))
|> sort_apps()
page = opts[:page] || 1
per_page = opts[:per_page] || 20
page_apps = filtered |> Enum.drop((page - 1) * per_page) |> Enum.take(per_page)
render_list(opts, page_apps, page, per_page, length(filtered))
0
else
{:error, message, code} -> fail(opts, message, code)
end
end
end
defp execute(:show, ctx, opts, api) do
with {:ok, app} <- resolve_app(ctx, opts[:client_id_arg], api) do
case api.apps_get(ctx.issuer, ctx.token, app["id"]) do
{:ok, body} ->
show_app(body["data"] || %{}, opts)
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:create, ctx, opts, api) do
attrs = build_attrs(opts, :create)
case api.apps_create(ctx.issuer, ctx.token, attrs) do
{:ok, body} ->
app = body["data"] || %{}
secret = body["client_secret"]
if opts[:json] do
output = %{ok: true, app: app} |> maybe_put(:client_secret, secret)
IO.puts(Jason.encode!(output))
else
IO.puts("App 已建立:#{app["client_id"]}(#{app["visibility"]}/#{app["status"]})")
print_secret_block(secret, rotated?: false)
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
end
defp execute(:update, ctx, opts, api) do
attrs = build_attrs(opts, :update)
with {:ok, app} <- resolve_app(ctx, opts[:client_id_arg], api) do
case api.apps_update(ctx.issuer, ctx.token, app["id"], attrs) do
{:ok, body} ->
show_app(body["data"] || %{}, opts, summary?: true)
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:"rotate-secret", ctx, opts, api) do
with {:ok, app} <- resolve_app(ctx, opts[:client_id_arg], api) do
case api.apps_rotate_secret(ctx.issuer, ctx.token, app["id"]) do
{:ok, body} ->
secret = body["client_secret"]
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, client_secret: secret}))
else
print_secret_block(secret, rotated?: true)
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:toggle, ctx, opts, api) do
with {:ok, app} <- resolve_app(ctx, opts[:client_id_arg], api) do
case api.apps_toggle(ctx.issuer, ctx.token, app["id"]) do
{:ok, body} ->
updated = body["data"] || %{}
if opts[:json] do
IO.puts(
Jason.encode!(%{
ok: true,
app: %{client_id: updated["client_id"], status: updated["status"]}
})
)
else
IO.puts("#{app["client_id"]}:#{app["status"]} → #{updated["status"]}")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
# -- client_id → UUID 解析 --
defp resolve_app(_ctx, nil, _api), do: {:error, "缺少 <client-id> 參數", 2}
defp resolve_app(ctx, client_id, api) do
with {:ok, all} <- fetch_all(ctx, api) do
case Enum.find(all, &(&1["client_id"] == client_id)) do
nil -> {:error, "找不到 client_id 為 #{client_id} 的 App", 1}
app -> {:ok, app}
end
end
end
# 全量列舉(分頁逐步拉取直到取完;設頁數上限避免無限迴圈)。
defp fetch_all(ctx, api) do
do_fetch_all(ctx, api, 1, [])
end
defp do_fetch_all(_ctx, _api, page, acc) when page > @max_fetch_pages, do: {:ok, acc}
defp do_fetch_all(ctx, api, page, acc) do
case api.apps_list(ctx.issuer, ctx.token, page: page, per_page: @list_per_page_fetch) do
{:ok, body} ->
entries = body["data"] || []
acc = acc ++ entries
total = body["total"]
if entries == [] or length(entries) < @list_per_page_fetch or
(is_integer(total) and length(acc) >= total) do
{:ok, acc}
else
do_fetch_all(ctx, api, page + 1, acc)
end
{:error, _kind, _detail} = error ->
{message, code} = format_api_error(ctx, error)
{:error, message, code}
end
end
# -- 請求屬性組裝 --
# create:必填欄位已在解析層驗證(缺 → 用法錯誤 2),這裡只組裝。
defp build_attrs(opts, :create) do
%{}
|> put_value("client_id", opts[:client_id])
|> put_value("url", opts[:url])
|> put_value("title", opts[:title])
|> put_value("method", opts[:method])
|> put_value("visibility", opts[:visibility])
|> put_list("redirect_urls", opts[:redirect_url])
|> put_list("post_logout_redirect_uris", opts[:post_logout_redirect_uri])
|> put_list("scopes", opts[:scope])
|> put_value("sub", opts[:sub])
|> put_value("jwk_id", opts[:jwk_id])
|> put_value("client_secret", opts[:secret])
end
# update:只送有給的欄位(部分更新);清單類整組覆寫。
defp build_attrs(opts, :update) do
%{}
|> put_value("url", opts[:url])
|> put_value("title", opts[:title])
|> put_value("method", opts[:method])
|> put_value("visibility", opts[:visibility])
|> put_list("redirect_urls", opts[:redirect_url])
|> put_list("post_logout_redirect_uris", opts[:post_logout_redirect_uri])
|> put_list("scopes", opts[:scope])
|> put_value("sub", opts[:sub])
|> put_value("jwk_id", opts[:jwk_id])
end
defp put_value(map, _key, nil), do: map
defp put_value(map, _key, ""), do: map
defp put_value(map, key, value), do: Map.put(map, key, value)
defp put_list(map, _key, nil), do: map
defp put_list(map, _key, []), do: map
defp put_list(map, key, values), do: Map.put(map, key, values)
# -- 過濾與排序 --
defp matches_filters?(app, filters) do
Enum.all?(filters, fn {k, v} -> app[to_string(k)] == v end)
end
defp sort_apps(apps), do: Enum.sort_by(apps, &{&1["client_id"] || "", &1["id"] || ""})
# -- 輸出 --
defp render_list(opts, apps, page, per_page, total) do
if opts[:json] do
IO.puts(
Jason.encode!(%{ok: true, page: page, per_page: per_page, total: total, apps: apps})
)
else
print_table(apps)
end
end
@table_headers ["CLIENT ID", "TITLE", "VISIBILITY", "STATUS", "METHOD", "SCOPES"]
defp print_table(apps) do
rows =
Enum.map(apps, fn app ->
[
app["client_id"] || "",
app["title"] || "",
app["visibility"] || "",
app["status"] || "",
app["method"] || "",
Enum.join(app["scopes"] || [], ", ")
]
end)
widths =
Enum.with_index(@table_headers, fn _header, i ->
Enum.max([
String.length(Enum.at(@table_headers, i))
| Enum.map(rows, &String.length(Enum.at(&1, i)))
])
end)
render_row = fn cells ->
cells
|> Enum.with_index()
|> Enum.map(fn {cell, i} -> String.pad_trailing(cell, Enum.at(widths, i)) end)
|> Enum.join(" ")
|> String.trim_trailing()
end
IO.puts(render_row.(@table_headers))
Enum.each(rows, fn cells -> IO.puts(render_row.(cells)) end)
end
@app_fields ~w(client_id title url method visibility status scopes redirect_urls post_logout_redirect_uris sub jwk_id created_at updated_at)
defp show_app(app, opts, summary? \\ false)
defp show_app(app, opts, _summary?) do
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, app: app}))
else
Enum.each(@app_fields, fn key ->
IO.puts("#{String.pad_trailing(key, 26)}: #{format_app_value(app[key])}")
end)
end
end
defp format_app_value(nil), do: "(無)"
defp format_app_value([]), do: "(無)"
defp format_app_value(values) when is_list(values), do: Enum.join(values, ", ")
defp format_app_value(value) when is_binary(value), do: value
defp format_app_value(value), do: to_string(value)
# secret 只在成功當下一次性輸出(不寫入憑證檔/log/--verbose)。
defp print_secret_block(nil, _opts), do: :ok
defp print_secret_block(secret, rotated?: rotated?) do
lead = if rotated?, do: "client_secret 已輪轉", else: "client_secret"
IO.puts("#{lead}(只顯示這一次,請立即保存):")
IO.puts(" #{secret}")
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
# -- 錯誤處理 --
# API 錯誤 → 依 §3.6 群組共通退出碼輸出,回傳退出碼。
defp fail_api(opts, ctx, error) do
{message, code} = format_api_error(ctx, error)
fail(opts, message, code)
end
defp format_api_error(_ctx, {:error, :unauthorized, desc}) do
{"token 無效或已過期(#{desc}),請重新 bear login", 3}
end
defp format_api_error(_ctx, {:error, :forbidden, _desc}) do
{"此操作需 admin 權限(HTTP 403)", 8}
end
defp format_api_error(_ctx, {:error, :not_found, _desc}) do
{"找不到目標 App(HTTP 404)", 1}
end
defp format_api_error(_ctx, {:error, :unprocessable_entity, %{"error" => "pkce_app"}}) do
{"此 App 為 PKCE,無 client secret 可輪轉", 1}
end
defp format_api_error(_ctx, {:error, :unprocessable_entity, body}) do
details =
body
|> Map.get("errors", %{})
|> Enum.map(fn {field, messages} ->
"#{field}: #{Enum.join(List.wrap(messages), "、")}"
end)
|> Enum.join(";")
if details == "" do
{"驗證失敗(HTTP 422)", 1}
else
{"驗證失敗(HTTP 422):#{details}", 1}
end
end
defp format_api_error(_ctx, {:error, :server_error, status}) do
{"伺服器回應 #{status}", 6}
end
defp format_api_error(ctx, {:error, :network, reason}) do
{"無法連線到 #{ctx.issuer}:#{reason}", 6}
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
defp blank?(nil), do: true
defp blank?(""), do: true
defp blank?(_), do: false
end