Files
alex 2192d067ce 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 更新
2026-09-08 20:58:54 +08:00

447 lines
13 KiB
Elixir
Raw Permalink 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.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