- 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 更新
447 lines
13 KiB
Elixir
447 lines
13 KiB
Elixir
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
|