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, "缺少 參數", 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