feat: 實作 bear CLI 骨架與 PAT 模式共用基礎(issue #6)

- 專案骨架:Elixir + Mix escript(單檔可執行 bear),HTTP 一律 Req
- 設定/憑證檔讀寫:0600、原子寫入(rename)、O_CREAT|O_EXCL 防 symlink
- PAT Bearer 呼叫 /userinfo;--json 輸出;規格化退出碼
- 補齊 main 規格承諾的 BEAR_TOKEN 環境變數(優先於憑證檔、不寫入)
- login 無效 PAT 退出碼 3(依 docs/commands.md §3.1);token --refresh 用法錯誤 2(§3.3)
- 35 個單元測試;mix precommit(compile --warnings-as-errors + format + test)全綠
- 沿用已關閉 PR #3 的實作(feat/pat-mode-mvp 分支),文件改依 main 現行版本更新

apps 管理指令待 #5 規格與 alterminal/bear#28 API 就緒後實作。
This commit is contained in:
2026-09-07 22:59:05 +08:00
parent c26420303e
commit 93176c0eb1
15 changed files with 1279 additions and 3 deletions
+87
View File
@@ -0,0 +1,87 @@
defmodule BearCli.Credentials do
@moduledoc """
本機憑證檔的讀寫(0600,原子寫入、防 symlink 攻擊)。
憑證檔結構(JSON):
{"issuer": "https://alterminal.com", "access_token": "<PAT>", "email": "..."}
PAT 模式沒有 refresh token,`access_token` 即個人存取權杖本身。
"""
alias BearCli.Config
@doc """
載入憑證,回傳 `{:ok, map}` 或 `:error`(不存在/JSON 損毀)。
"""
def load(path \\ Config.credentials_path()) do
case File.read(path) do
{:ok, content} ->
case Jason.decode(content) do
{:ok, map} when is_map(map) -> {:ok, map}
_ -> :error
end
{:error, _} ->
:error
end
end
@doc """
寫入憑證(原子、0600)。建立父目錄(0700)。回傳 `:ok` 或 `{:error, reason}`。
"""
def save(map, path \\ Config.credentials_path()) do
json = Jason.encode!(map)
with :ok <- ensure_parent_dir(path),
{:ok, tmp} <- write_temp(path, json) do
# rename(2) 會原子地取代目標(包含取代 symlink 本身,而非其指向的檔案)。
File.rename(tmp, path)
else
{:error, _} = error -> error
end
end
@doc """
刪除憑證檔;檔案不存在也算成功。
"""
def delete(path \\ Config.credentials_path()) do
case File.rm(path) do
:ok -> :ok
{:error, :enoent} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp ensure_parent_dir(path) do
dir = Path.dirname(path)
File.mkdir_p(dir)
# 目錄權限 0700 為 best-effort(例如憑證路徑落在 /tmp 等共享目錄時無法 chmod,
# 忽略即可;真正的保護是憑證檔本身的 0600)。
_ = File.chmod(dir, 0o700)
:ok
end
defp write_temp(path, json) do
dir = Path.dirname(path)
tmp = Path.join(dir, ".credentials.#{System.unique_integer([:positive])}.tmp")
result =
with {:ok, io} <- File.open(tmp, [:write, :exclusive, :binary]),
:ok <- IO.binwrite(io, json),
:ok <- File.close(io),
:ok <- File.chmod(tmp, 0o600) do
{:ok, tmp}
end
case result do
{:ok, _} = ok ->
ok
{:error, _} = error ->
_ = File.rm(tmp)
error
end
end
end