Gleam CLI client for Tangled (clone of aly.codes/tg)
0

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit: Gleam Tangled CLI (gtg)

Clone of aly.codes/tg in Gleam: Bobbin reads, OAuth/DPoP and app-password
auth, repo/issue/PR/string/ssh-key commands, Nix flake for development.

author
nandi
date (Jul 25, 2026, 6:47 PM -0700) commit 10c50bf2
+6737
+1
.envrc
··· 1 + use flake
+23
.github/workflows/test.yml
··· 1 + name: test 2 + 3 + on: 4 + push: 5 + branches: 6 + - master 7 + - main 8 + pull_request: 9 + 10 + jobs: 11 + test: 12 + runs-on: ubuntu-latest 13 + steps: 14 + - uses: actions/checkout@v6 15 + - uses: erlef/setup-beam@v1 16 + with: 17 + otp-version: "29" 18 + gleam-version: "1.17.0" 19 + rebar3-version: "3" 20 + # elixir-version: "1" 21 + - run: gleam deps download 22 + - run: gleam test 23 + - run: gleam format --check src test
+8
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + /result 5 + /result-* 6 + .direnv 7 + .devenv 8 + erl_crash.dump
+108
README.md
··· 1 + # gtg 2 + 3 + A [Gleam](https://gleam.run) CLI client for [Tangled](https://tangled.org), the git forge built on AT Protocol. 4 + 5 + `gtg` is a Gleam reimplementation of [`aly.codes/tg`](https://tangled.org/aly.codes/tg) (the Go `tg` tool — an analogue of GitHub’s `gh`). 6 + 7 + ## Features 8 + 9 + - **Auth** — OAuth (browser + localhost callback + DPoP) or app-password login; multi-account store 10 + - **Repos** — list, view, clone, create, delete, edit, set-default-branch 11 + - **Issues** — list, view, create, comment, close, reopen 12 + - **Pull requests** — list, view, create (patch upload), comment, diff, checkout, close, reopen, merge 13 + - **Strings** — list, view, create, delete 14 + - **SSH keys** — list, add, delete 15 + - **API** — call authenticated XRPC endpoints 16 + - **Browse** — open a repo on tangled.org 17 + 18 + Reads go through the Bobbin appview (default `https://bobbin.klbr.net`). Writes use your PDS session and knot service-auth JWTs. 19 + 20 + ## Build / develop 21 + 22 + ### With Nix (recommended) 23 + 24 + ```bash 25 + nix develop # or: direnv allow (uses .envrc → use flake) 26 + gleam run -- --help 27 + gleam test 28 + ``` 29 + 30 + One-shot without entering the shell: 31 + 32 + ```bash 33 + nix run . -- --help 34 + nix run . -- repo list aly.codes 35 + ``` 36 + 37 + ### Without Nix 38 + 39 + Requires Erlang/OTP and Gleam on `PATH`: 40 + 41 + ```bash 42 + gleam build 43 + gleam run -- --help 44 + gleam run -- auth status 45 + ``` 46 + 47 + ## Quick start 48 + 49 + ```bash 50 + # Log in with OAuth (opens browser; callback on 127.0.0.1:8095) 51 + gtg auth login alice.example.com 52 + 53 + # Or headless with an atproto app password 54 + gtg auth login alice.example.com xxxx-xxxx-xxxx-xxxx 55 + 56 + # Clone a repository (SSH) 57 + gtg repo clone aly.codes/tg 58 + 59 + # List issues / PRs (detects origin remote in a Tangled clone) 60 + gtg issue list 61 + gtg pr list 62 + 63 + # Create an issue 64 + gtg issue create --body "Details" "Bug report" -R handle/repo 65 + ``` 66 + 67 + ## Configuration 68 + 69 + Precedence (highest first): flags → `TG_*` env → config file → defaults. 70 + 71 + | Source | Keys | 72 + |--------|------| 73 + | Flags | `--appview`, `--account`, `--config`, `--json` | 74 + | Env | `TG_APPVIEW`, `TG_ACCOUNT` | 75 + | File | `$XDG_CONFIG_HOME/gtg/config.toml` (also reads `tg/config.toml`) | 76 + 77 + ```toml 78 + # ~/.config/gtg/config.toml 79 + appview = "https://bobbin.klbr.net" 80 + ``` 81 + 82 + Sessions are stored under `$XDG_DATA_HOME/gtg/` (not the OS keyring — unlike Go `tg`). 83 + 84 + ## Architecture 85 + 86 + ``` 87 + src/gtg.gleam entrypoint 88 + src/gtg/cli.gleam command tree 89 + src/gtg/config.gleam flags / env / toml 90 + src/gtg/app/ domain service (repos, issues, PRs, …) 91 + src/gtg/atproto/ resolve, XRPC, OAuth+DPoP, password auth, PDS, sessions 92 + src/gtg/tangled/ Bobbin appview client 93 + src/gtg/knot/ knot RPCs 94 + src/gtg/gitutil.gleam git clone / remotes / patches 95 + ``` 96 + 97 + ## Differences from Go `tg` 98 + 99 + | | `tg` (Go) | `gtg` (Gleam) | 100 + |--|-----------|---------------| 101 + | Auth | OAuth + app password, OS keyring | OAuth (DPoP) + app password, file store | 102 + | Binary name | `tg` | `gtg` | 103 + | Config dir | `~/.config/tg` | `~/.config/gtg` (falls back to reading `tg`) | 104 + | Completions / man | generated | not yet | 105 + 106 + ## License 107 + 108 + MIT (this port). Upstream `tg` has its own license — see [aly.codes/tg](https://tangled.org/aly.codes/tg).
+27
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1784796856, 6 + "narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", 7 + "owner": "NixOS", 8 + "repo": "nixpkgs", 9 + "rev": "e2587caef70cea85dd97d7daab492899902dbf5d", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "owner": "NixOS", 14 + "ref": "nixos-unstable", 15 + "repo": "nixpkgs", 16 + "type": "github" 17 + } 18 + }, 19 + "root": { 20 + "inputs": { 21 + "nixpkgs": "nixpkgs" 22 + } 23 + } 24 + }, 25 + "root": "root", 26 + "version": 7 27 + }
+96
flake.nix
··· 1 + { 2 + description = "gtg — Gleam CLI client for Tangled (git forge on atproto)"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 + }; 7 + 8 + outputs = 9 + { self, nixpkgs }: 10 + let 11 + systems = [ 12 + "x86_64-linux" 13 + "aarch64-linux" 14 + "x86_64-darwin" 15 + "aarch64-darwin" 16 + ]; 17 + forAllSystems = nixpkgs.lib.genAttrs systems; 18 + in 19 + { 20 + # nix develop 21 + devShells = forAllSystems ( 22 + system: 23 + let 24 + pkgs = nixpkgs.legacyPackages.${system}; 25 + in 26 + { 27 + default = pkgs.mkShell { 28 + name = "gtg"; 29 + 30 + packages = [ 31 + pkgs.gleam 32 + pkgs.beamPackages.erlang 33 + pkgs.rebar3 34 + pkgs.beamPackages.hex 35 + pkgs.git 36 + ]; 37 + 38 + shellHook = '' 39 + echo "gtg dev shell" 40 + echo " gleam $(gleam --version 2>/dev/null || echo '?')" 41 + echo " erl OTP $(erl -eval 'io:format("~s", [erlang:system_info(otp_release)]), halt().' -noshell 2>/dev/null || echo '?')" 42 + echo "" 43 + echo " gleam run -- --help" 44 + echo " gleam run -- auth login <handle>" 45 + echo " gleam test" 46 + ''; 47 + }; 48 + } 49 + ); 50 + 51 + # nix run . -- --help 52 + # Copies sources to a writable temp dir (store paths are read-only; gleam 53 + # needs to write build/ and download hex packages). 54 + apps = forAllSystems ( 55 + system: 56 + let 57 + pkgs = nixpkgs.legacyPackages.${system}; 58 + gtg = pkgs.writeShellApplication { 59 + name = "gtg"; 60 + runtimeInputs = [ 61 + pkgs.gleam 62 + pkgs.beamPackages.erlang 63 + pkgs.rebar3 64 + pkgs.beamPackages.hex 65 + pkgs.rsync 66 + pkgs.coreutils 67 + ]; 68 + text = '' 69 + root="${self}" 70 + if [[ ! -f "$root/gleam.toml" ]]; then 71 + echo "gtg: flake source not found at $root" >&2 72 + exit 1 73 + fi 74 + work=$(mktemp -d) 75 + trap 'rm -rf "$work"' EXIT 76 + # Nix store files are mode 444; gleam must write build/ here. 77 + rsync -a --chmod=u+w --exclude build --exclude result \ 78 + --exclude .direnv --exclude .git \ 79 + "$root"/ "$work"/ 80 + chmod -R u+w "$work" 81 + cd "$work" 82 + exec gleam run --no-print-progress -- "$@" 83 + ''; 84 + }; 85 + in 86 + { 87 + default = { 88 + type = "app"; 89 + program = "${gtg}/bin/gtg"; 90 + }; 91 + } 92 + ); 93 + 94 + formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.nixfmt-rfc-style); 95 + }; 96 + }
+21
gleam.toml
··· 1 + name = "gtg" 2 + version = "0.1.0" 3 + description = "A Gleam CLI client for Tangled (git forge on atproto), clone of aly.codes/tg" 4 + licences = ["MIT"] 5 + 6 + [dependencies] 7 + gleam_stdlib = ">= 1.0.0 and < 2.0.0" 8 + gleam_erlang = ">= 1.0.0 and < 2.0.0" 9 + gleam_http = ">= 4.0.0 and < 5.0.0" 10 + gleam_httpc = ">= 5.0.0 and < 6.0.0" 11 + gleam_json = ">= 3.0.0 and < 4.0.0" 12 + gleam_crypto = ">= 1.5.0 and < 2.0.0" 13 + argv = ">= 1.1.0 and < 2.0.0" 14 + envoy = ">= 1.0.0 and < 2.0.0" 15 + simplifile = ">= 2.0.0 and < 3.0.0" 16 + filepath = ">= 1.0.0 and < 2.0.0" 17 + shellout = ">= 1.7.0 and < 2.0.0" 18 + tom = ">= 2.0.0 and < 3.0.0" 19 + 20 + [dev_dependencies] 21 + gleeunit = ">= 1.0.0 and < 2.0.0"
+39
manifest.toml
··· 1 + # Do not manually edit this file, it is managed by Gleam. 2 + # 3 + # This file locks the dependency versions used, to make your build 4 + # deterministic and to prevent unexpected versions from being included 5 + # in your application. 6 + # 7 + # You should check this file into your source control repository. 8 + 9 + packages = [ 10 + { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, 11 + { name = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, 12 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 13 + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, 14 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 15 + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 16 + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, 17 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 18 + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, 19 + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, 20 + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, 21 + { name = "shellout", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "C416356D45151F298108C9DB9CD1EDE0313F620B5EDBB5766CD7237659D87841" }, 22 + { name = "simplifile", version = "2.6.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A33C345F0A4FFB91DCCD4220114534A58C387964A5F17B3E472CEBD1ADA9FFB4" }, 23 + { name = "tom", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "DCF04CB7AB35D58CFC598C66EA2E1816D160759802C89B2BA6238780D59BC256" }, 24 + ] 25 + 26 + [requirements] 27 + argv = { version = ">= 1.1.0 and < 2.0.0" } 28 + envoy = { version = ">= 1.0.0 and < 2.0.0" } 29 + filepath = { version = ">= 1.0.0 and < 2.0.0" } 30 + gleam_crypto = { version = ">= 1.5.0 and < 2.0.0" } 31 + gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } 32 + gleam_http = { version = ">= 4.0.0 and < 5.0.0" } 33 + gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } 34 + gleam_json = { version = ">= 3.0.0 and < 4.0.0" } 35 + gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } 36 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 37 + shellout = { version = ">= 1.7.0 and < 2.0.0" } 38 + simplifile = { version = ">= 2.0.0 and < 3.0.0" } 39 + tom = { version = ">= 2.0.0 and < 3.0.0" }
+9
src/gtg.gleam
··· 1 + //// gtg — Gleam CLI client for Tangled (clone of aly.codes/tg). 2 + 3 + import argv 4 + import gtg/cli 5 + import gtg/sys 6 + 7 + pub fn main() -> Nil { 8 + sys.halt(cli.run(argv.load().arguments)) 9 + }
+1200
src/gtg/app/service.gleam
··· 1 + //// Application service: Tangled operations over atproto / Bobbin / knots. 2 + 3 + import gleam/bit_array 4 + import gleam/dynamic.{type Dynamic} 5 + import gleam/dynamic/decode 6 + import gleam/json 7 + import gleam/list 8 + import gleam/option.{type Option, None, Some} 9 + import gleam/result 10 + import gleam/string 11 + import gtg/app/types.{ 12 + type AuthAccount, type AuthLogout, type AuthStatus, type Author, 13 + type CreatedRecord, type DeletedRecord, type Item, type PRCheckoutResult, 14 + type PRCreateResult, type RepoCloneResult, type RepoCreateResult, 15 + type RepoDefaultBranchResult, type RepoDeleteResult, type RepoEditResult, 16 + type RepoItem, type SSHKeyAddResult, type SSHKeyItem, type StateResult, 17 + type StringItem, type StringView, type Target, type ViewResult, AuthAccount, 18 + AuthLogout, AuthStatus, Author, CreatedRecord, DeletedRecord, Item, 19 + PRCheckoutResult, PRCreateResult, RepoCloneResult, RepoCreateResult, 20 + RepoDefaultBranchResult, RepoDeleteResult, RepoEditResult, RepoItem, 21 + SSHKeyAddResult, SSHKeyItem, StateResult, StringItem, StringView, Target, 22 + ViewResult, 23 + } 24 + import gtg/atproto/auth 25 + import gtg/atproto/client 26 + import gtg/atproto/pds 27 + import gtg/atproto/resolve 28 + import gtg/atproto/session 29 + import gtg/atproto/tid 30 + import gtg/atproto/xrpc 31 + import gtg/gitutil 32 + import gtg/knot/client as knot 33 + import gtg/sys 34 + import gtg/tangled/client as tangled 35 + 36 + pub const issue_collection = "sh.tangled.repo.issue" 37 + 38 + pub const pull_collection = "sh.tangled.repo.pull" 39 + 40 + pub const string_collection = "sh.tangled.string" 41 + 42 + pub const ssh_key_collection = "sh.tangled.publicKey" 43 + 44 + pub const repo_collection = "sh.tangled.repo" 45 + 46 + pub type Service { 47 + Service(appview: String, account: Option(String)) 48 + } 49 + 50 + pub fn new(appview: String, account: Option(String)) -> Service { 51 + Service(appview:, account:) 52 + } 53 + 54 + pub type AppError { 55 + NotAuthenticated 56 + Failed(String) 57 + } 58 + 59 + pub fn error_message(err: AppError) -> String { 60 + case err { 61 + NotAuthenticated -> "not logged in; run \"gtg auth login\" first" 62 + Failed(m) -> m 63 + } 64 + } 65 + 66 + fn require_session(svc: Service) -> Result(client.Client, AppError) { 67 + auth.active_client(svc.account) 68 + |> result.map_error(fn(e) { 69 + case e { 70 + auth.NotAuthenticated -> NotAuthenticated 71 + other -> Failed(auth.error_message(other)) 72 + } 73 + }) 74 + } 75 + 76 + fn map_resolve(err: resolve.ResolveError) -> AppError { 77 + case err { 78 + resolve.ResolveFailed(m) -> Failed(m) 79 + } 80 + } 81 + 82 + // --- Target --- 83 + 84 + pub fn parse_target(arg: String) -> Result(Target, AppError) { 85 + case string.split_once(arg, "/") { 86 + Ok(#(handle, repo)) -> 87 + case handle != "" && repo != "" && !string.contains(repo, "/") { 88 + True -> Ok(Target(handle:, repo:)) 89 + False -> Error(Failed("expected handle/repo, got \"" <> arg <> "\"")) 90 + } 91 + Error(_) -> Error(Failed("expected handle/repo, got \"" <> arg <> "\"")) 92 + } 93 + } 94 + 95 + pub fn target_from_cwd() -> Result(Target, AppError) { 96 + gitutil.detect_repo_from_cwd() 97 + |> result.map(fn(rc) { Target(handle: rc.handle, repo: rc.repo) }) 98 + |> result.map_error(fn(e) { Failed(gitutil.error_message(e)) }) 99 + } 100 + 101 + pub fn resolve_target(args: List(String)) -> Result(Target, AppError) { 102 + case args { 103 + [arg] -> parse_target(arg) 104 + [] -> target_from_cwd() 105 + _ -> Error(Failed("expected at most one handle/repo argument")) 106 + } 107 + } 108 + 109 + pub fn resolve_target_flag(repo_flag: String) -> Result(Target, AppError) { 110 + case repo_flag { 111 + "" -> target_from_cwd() 112 + r -> parse_target(r) 113 + } 114 + } 115 + 116 + // --- Auth --- 117 + 118 + pub fn login_with_password( 119 + identifier: String, 120 + password: String, 121 + ) -> Result(String, AppError) { 122 + auth.login_with_password(identifier, password) 123 + |> result.map_error(fn(e) { Failed(auth.error_message(e)) }) 124 + } 125 + 126 + pub fn login_with_oauth(identifier: String) -> Result(String, AppError) { 127 + auth.login_with_oauth(identifier) 128 + |> result.map_error(fn(e) { Failed(auth.error_message(e)) }) 129 + } 130 + 131 + pub fn auth_status(svc: Service) -> Result(AuthStatus, AppError) { 132 + auth.probe_status(svc.account) 133 + |> result.map(fn(t) { 134 + let #(authenticated, status, did, handle) = t 135 + AuthStatus(authenticated:, status:, did:, handle:) 136 + }) 137 + |> result.map_error(fn(e) { Failed(auth.error_message(e)) }) 138 + } 139 + 140 + pub fn auth_accounts(svc: Service) -> Result(List(AuthAccount), AppError) { 141 + let _ = svc 142 + session.list_accounts() 143 + |> result.map(fn(pair) { 144 + let #(accounts, active) = pair 145 + list.map(accounts, fn(a) { 146 + let is_active = case active { 147 + Some(d) -> d == a.did 148 + None -> False 149 + } 150 + AuthAccount( 151 + active: is_active, 152 + did: a.did, 153 + handle: a.handle, 154 + method: a.method, 155 + ) 156 + }) 157 + }) 158 + |> result.map_error(fn(e) { Failed(session.store_error_message(e)) }) 159 + } 160 + 161 + pub fn switch_account(selector: String) -> Result(AuthAccount, AppError) { 162 + auth.switch_account(selector) 163 + |> result.map(fn(a) { 164 + AuthAccount(active: True, did: a.did, handle: a.handle, method: a.method) 165 + }) 166 + |> result.map_error(fn(e) { Failed(auth.error_message(e)) }) 167 + } 168 + 169 + pub fn logout(svc: Service, all: Bool) -> Result(AuthLogout, AppError) { 170 + auth.logout(all, svc.account) 171 + |> result.map(fn(was) { AuthLogout(was_logged_in: was) }) 172 + |> result.map_error(fn(e) { Failed(auth.error_message(e)) }) 173 + } 174 + 175 + pub fn access_token(svc: Service) -> Result(String, AppError) { 176 + auth.access_token(svc.account) 177 + |> result.map_error(fn(e) { 178 + case e { 179 + auth.NotAuthenticated -> NotAuthenticated 180 + other -> Failed(auth.error_message(other)) 181 + } 182 + }) 183 + } 184 + 185 + pub fn handle_or_self(svc: Service, handle: String) -> Result(String, AppError) { 186 + case handle { 187 + "" -> { 188 + use sess <- result.try(require_session(svc)) 189 + case sess.handle { 190 + "" -> { 191 + resolve.resolve_did(sess.did) 192 + |> result.map(fn(i) { i.handle }) 193 + |> result.map_error(map_resolve) 194 + } 195 + h -> Ok(h) 196 + } 197 + // sess is client.Client 198 + } 199 + h -> Ok(h) 200 + } 201 + } 202 + 203 + // --- Repos --- 204 + 205 + fn resolve_repo(svc: Service, t: Target) -> Result(tangled.Repo, AppError) { 206 + use ident <- result.try( 207 + resolve.resolve(t.handle) |> result.map_error(map_resolve), 208 + ) 209 + let record_uri = "at://" <> ident.did <> "/sh.tangled.repo/" <> t.repo 210 + case tangled.get_repo(svc.appview, record_uri) { 211 + Ok(repo) -> { 212 + let repo = case repo.uri { 213 + "" -> tangled.Repo(..repo, uri: record_uri) 214 + _ -> repo 215 + } 216 + case tangled.is_canonical(repo) || repo.name == "" { 217 + True -> Ok(repo) 218 + False -> resolve_canonical(svc, ident.did, t, Some(repo)) 219 + } 220 + } 221 + Error(_) -> resolve_canonical(svc, ident.did, t, None) 222 + } 223 + } 224 + 225 + fn resolve_canonical( 226 + svc: Service, 227 + owner_did: String, 228 + t: Target, 229 + direct: Option(tangled.Repo), 230 + ) -> Result(tangled.Repo, AppError) { 231 + use repos <- result.try( 232 + tangled.list_repos(svc.appview, owner_did) 233 + |> result.map_error(fn(e) { Failed(tangled.error_message(e)) }), 234 + ) 235 + case direct { 236 + Some(alias) -> Ok(canonical_for_alias(repos, alias)) 237 + None -> { 238 + case 239 + list.find(repos, fn(r) { 240 + r.name == t.repo || tangled.extract_rkey(r.uri) == t.repo 241 + }) 242 + { 243 + Ok(r) -> Ok(canonical_for_alias(repos, r)) 244 + Error(_) -> 245 + Error(Failed( 246 + "repo \"" <> t.repo <> "\" not found for handle \"" <> t.handle <> "\"", 247 + )) 248 + } 249 + } 250 + } 251 + } 252 + 253 + fn canonical_for_alias(items: List(tangled.Repo), alias: tangled.Repo) -> tangled.Repo { 254 + case alias.name == "" || alias.repo_did == "" { 255 + True -> alias 256 + False -> 257 + case 258 + list.find(items, fn(c) { 259 + c.repo_did == alias.repo_did 260 + && tangled.is_canonical(c) 261 + && c.name == alias.name 262 + }) 263 + { 264 + Ok(c) -> c 265 + Error(_) -> alias 266 + } 267 + } 268 + } 269 + 270 + fn repo_did(svc: Service, t: Target) -> Result(String, AppError) { 271 + use repo <- result.try(resolve_repo(svc, t)) 272 + Ok(repo.repo_did) 273 + } 274 + 275 + pub fn view_repo(svc: Service, t: Target) -> Result(RepoItem, AppError) { 276 + use repo <- result.try(resolve_repo(svc, t)) 277 + let name = case repo.name { 278 + "" -> t.repo 279 + n -> n 280 + } 281 + Ok(RepoItem( 282 + name:, 283 + uri: repo.uri, 284 + author: t.handle, 285 + knot: repo.knot, 286 + description: repo.description, 287 + created_at: repo.created_at, 288 + repo_did: repo.repo_did, 289 + )) 290 + } 291 + 292 + pub fn list_repos(svc: Service, handle: String) -> Result(List(RepoItem), AppError) { 293 + use ident <- result.try( 294 + resolve.resolve(handle) |> result.map_error(map_resolve), 295 + ) 296 + use repos <- result.try( 297 + tangled.list_repos(svc.appview, ident.did) 298 + |> result.map_error(fn(e) { Failed(tangled.error_message(e)) }), 299 + ) 300 + let canonical = canonical_repo_list(repos) 301 + Ok( 302 + list.map(canonical, fn(r) { 303 + let name = case r.name { 304 + "" -> tangled.extract_rkey(r.uri) 305 + n -> n 306 + } 307 + RepoItem( 308 + name:, 309 + uri: r.uri, 310 + author: handle, 311 + knot: r.knot, 312 + description: r.description, 313 + created_at: r.created_at, 314 + repo_did: r.repo_did, 315 + ) 316 + }), 317 + ) 318 + } 319 + 320 + fn canonical_repo_list(items: List(tangled.Repo)) -> List(tangled.Repo) { 321 + list.fold(items, [], fn(acc, repo) { 322 + case repo.repo_did { 323 + "" -> list.append(acc, [repo]) 324 + did -> { 325 + case list.split_while(acc, fn(r) { r.repo_did != did }) { 326 + #(before, [existing, ..after]) -> { 327 + let keep = case 328 + tangled.is_canonical(repo) && !tangled.is_canonical(existing) 329 + { 330 + True -> repo 331 + False -> existing 332 + } 333 + list.append(before, [keep, ..after]) 334 + } 335 + #(before, []) -> list.append(before, [repo]) 336 + } 337 + } 338 + } 339 + }) 340 + } 341 + 342 + pub fn clone_repo( 343 + svc: Service, 344 + handle: String, 345 + repo: String, 346 + destination: String, 347 + ) -> Result(RepoCloneResult, AppError) { 348 + let _ = svc 349 + gitutil.clone_repo(handle, repo, destination) 350 + |> result.map(fn(_) { 351 + RepoCloneResult(handle:, repo:, destination:) 352 + }) 353 + |> result.map_error(fn(e) { Failed(gitutil.error_message(e)) }) 354 + } 355 + 356 + pub fn create_repo( 357 + svc: Service, 358 + name: String, 359 + description: String, 360 + knot_host: String, 361 + do_clone: Bool, 362 + push_path: String, 363 + remote_name: String, 364 + ) -> Result(RepoCreateResult, AppError) { 365 + use sess <- result.try(require_session(svc)) 366 + use token <- result.try( 367 + pds.get_service_auth(sess, "did:web:" <> knot_host, "sh.tangled.repo.create") 368 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 369 + ) 370 + use repo_did <- result.try( 371 + knot.create_repo(knot_host, token, name, name) 372 + |> result.map_error(fn(e) { Failed(knot.error_message(e)) }), 373 + ) 374 + let record = repo_record_json(knot_host, description, repo_did) 375 + use #(uri, _) <- result.try( 376 + pds.put_record(sess, repo_collection, name, record) 377 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 378 + ) 379 + let handle = case sess.handle { 380 + "" -> sess.did 381 + h -> h 382 + } 383 + let result = 384 + RepoCreateResult( 385 + handle:, 386 + name:, 387 + uri:, 388 + knot: knot_host, 389 + cloned: False, 390 + pushed: False, 391 + default_branch: "", 392 + warnings: [], 393 + ) 394 + use result <- result.try(case do_clone { 395 + True -> { 396 + use _ <- result.try(clone_repo(svc, handle, name, name)) 397 + Ok(RepoCreateResult(..result, cloned: True)) 398 + } 399 + False -> Ok(result) 400 + }) 401 + case push_path { 402 + "" -> Ok(result) 403 + dir -> { 404 + case gitutil.push_new_repo(dir, handle, name, remote_name) { 405 + Error(e) -> Error(Failed(gitutil.error_message(e))) 406 + Ok(_) -> { 407 + let branch = case gitutil.current_branch(dir) { 408 + Ok(b) -> b 409 + Error(_) -> "" 410 + } 411 + let warnings = case branch { 412 + "" -> result.warnings 413 + b -> { 414 + case 415 + set_knot_default_branch(sess, knot_host, uri, b) 416 + { 417 + Ok(_) -> result.warnings 418 + Error(e) -> 419 + list.append(result.warnings, [ 420 + "could not set default branch: " <> error_message(e), 421 + ]) 422 + } 423 + } 424 + } 425 + Ok( 426 + RepoCreateResult( 427 + ..result, 428 + pushed: True, 429 + default_branch: branch, 430 + warnings:, 431 + ), 432 + ) 433 + } 434 + } 435 + } 436 + } 437 + } 438 + 439 + fn repo_record_json( 440 + knot_host: String, 441 + description: String, 442 + repo_did: String, 443 + ) -> json.Json { 444 + let fields = [ 445 + #("$type", json.string(repo_collection)), 446 + #("knot", json.string(knot_host)), 447 + #("createdAt", json.string(sys.rfc3339_now())), 448 + #("repoDid", json.string(repo_did)), 449 + ] 450 + let fields = case description { 451 + "" -> fields 452 + d -> list.append(fields, [#("description", json.string(d))]) 453 + } 454 + json.object(fields) 455 + } 456 + 457 + fn set_knot_default_branch( 458 + sess: client.Client, 459 + knot_host: String, 460 + repo_uri: String, 461 + branch: String, 462 + ) -> Result(Nil, AppError) { 463 + use token <- result.try( 464 + pds.get_service_auth( 465 + sess, 466 + "did:web:" <> knot_host, 467 + "sh.tangled.repo.setDefaultBranch", 468 + ) 469 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 470 + ) 471 + knot.set_default_branch(knot_host, token, repo_uri, branch) 472 + |> result.map_error(fn(e) { Failed(knot.error_message(e)) }) 473 + } 474 + 475 + pub fn delete_repo(svc: Service, t: Target) -> Result(RepoDeleteResult, AppError) { 476 + use sess <- result.try(require_session(svc)) 477 + use repo <- result.try(require_owned_repo(svc, t, sess.did)) 478 + case repo.knot { 479 + "" -> Error(Failed("repo has no knot")) 480 + knot_host -> { 481 + let rkey = tangled.extract_rkey(repo.uri) 482 + use token <- result.try( 483 + pds.get_service_auth( 484 + sess, 485 + "did:web:" <> knot_host, 486 + "sh.tangled.repo.delete", 487 + ) 488 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 489 + ) 490 + let _ = pds.delete_record(sess, repo_collection, rkey) 491 + use _ <- result.try( 492 + knot.delete_repo(knot_host, token, sess.did, t.repo, rkey) 493 + |> result.map_error(fn(e) { Failed(knot.error_message(e)) }), 494 + ) 495 + Ok(RepoDeleteResult(uri: repo.uri)) 496 + } 497 + } 498 + } 499 + 500 + fn require_owned_repo( 501 + svc: Service, 502 + t: Target, 503 + did: String, 504 + ) -> Result(tangled.Repo, AppError) { 505 + use repo <- result.try(resolve_repo(svc, t)) 506 + case tangled.extract_did(repo.uri) == did { 507 + True -> Ok(repo) 508 + False -> 509 + Error(Failed( 510 + "repo \"" <> types.target_string(t) <> "\" is not owned by the authenticated user", 511 + )) 512 + } 513 + } 514 + 515 + pub fn set_default_branch( 516 + svc: Service, 517 + t: Target, 518 + branch: String, 519 + ) -> Result(RepoDefaultBranchResult, AppError) { 520 + use sess <- result.try(require_session(svc)) 521 + use repo <- result.try(require_owned_repo(svc, t, sess.did)) 522 + case repo.knot { 523 + "" -> Error(Failed("repo has no knot")) 524 + knot_host -> { 525 + use _ <- result.try(set_knot_default_branch(sess, knot_host, repo.uri, branch)) 526 + Ok(RepoDefaultBranchResult(uri: repo.uri, branch:)) 527 + } 528 + } 529 + } 530 + 531 + pub fn edit_repo( 532 + svc: Service, 533 + t: Target, 534 + description: Option(String), 535 + ) -> Result(RepoEditResult, AppError) { 536 + use sess <- result.try(require_session(svc)) 537 + use repo <- result.try(require_owned_repo(svc, t, sess.did)) 538 + let rkey = tangled.extract_rkey(repo.uri) 539 + use existing <- result.try( 540 + pds.get_record_authed(sess, sess.did, repo_collection, rkey) 541 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 542 + ) 543 + // Rebuild record from known fields + optional description override 544 + let desc = case description { 545 + Some(d) -> d 546 + None -> repo.description 547 + } 548 + let record = 549 + json.object([ 550 + #("$type", json.string(repo_collection)), 551 + #("knot", json.string(repo.knot)), 552 + #("createdAt", json.string(repo.created_at)), 553 + #("repoDid", json.string(repo.repo_did)), 554 + #("name", json.string(repo.name)), 555 + #("description", json.string(desc)), 556 + ]) 557 + let _ = existing 558 + use #(uri, _) <- result.try( 559 + pds.put_record(sess, repo_collection, rkey, record) 560 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 561 + ) 562 + Ok(RepoEditResult(uri:, description: desc)) 563 + } 564 + 565 + // --- Issues --- 566 + 567 + pub fn list_issues(svc: Service, t: Target) -> Result(List(Item), AppError) { 568 + use did <- result.try(repo_did(svc, t)) 569 + use items <- result.try( 570 + tangled.list_issues(svc.appview, did) 571 + |> result.map_error(fn(e) { Failed(tangled.error_message(e)) }), 572 + ) 573 + Ok(build_items(items, decode_issue)) 574 + } 575 + 576 + pub fn view_issue( 577 + svc: Service, 578 + t: Target, 579 + rkey: String, 580 + ) -> Result(ViewResult, AppError) { 581 + use did <- result.try(repo_did(svc, t)) 582 + use items <- result.try( 583 + tangled.list_issues(svc.appview, did) 584 + |> result.map_error(fn(e) { Failed(tangled.error_message(e)) }), 585 + ) 586 + use found <- result.try(find_by_rkey(items, rkey, "issue")) 587 + use view <- result.try(decode_issue(found.value)) 588 + Ok(ViewResult( 589 + rkey:, 590 + title: view.0, 591 + body: view.1, 592 + author: resolve_author(tangled.extract_did(found.uri)), 593 + created_at: view.2, 594 + source_branch: "", 595 + target_branch: "", 596 + )) 597 + } 598 + 599 + pub fn create_issue( 600 + svc: Service, 601 + t: Target, 602 + title: String, 603 + body: String, 604 + ) -> Result(CreatedRecord, AppError) { 605 + use sess <- result.try(require_session(svc)) 606 + use did <- result.try(repo_did(svc, t)) 607 + let rkey = tid.new_now() 608 + let fields = [ 609 + #("$type", json.string(issue_collection)), 610 + #("repo", json.string(did)), 611 + #("title", json.string(title)), 612 + #("createdAt", json.string(sys.rfc3339_now())), 613 + ] 614 + let fields = case body { 615 + "" -> fields 616 + b -> list.append(fields, [#("body", json.string(b))]) 617 + } 618 + use #(uri, _) <- result.try( 619 + pds.put_record(sess, issue_collection, rkey, json.object(fields)) 620 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 621 + ) 622 + Ok(CreatedRecord(rkey:, uri:)) 623 + } 624 + 625 + pub fn comment_issue( 626 + svc: Service, 627 + t: Target, 628 + rkey: String, 629 + body: String, 630 + ) -> Result(CreatedRecord, AppError) { 631 + use sess <- result.try(require_session(svc)) 632 + use did <- result.try(repo_did(svc, t)) 633 + use items <- result.try( 634 + tangled.list_issues(svc.appview, did) 635 + |> result.map_error(fn(e) { Failed(tangled.error_message(e)) }), 636 + ) 637 + use found <- result.try(find_by_rkey(items, rkey, "issue")) 638 + let crkey = tid.new_now() 639 + let record = 640 + json.object([ 641 + #("$type", json.string(issue_collection <> ".comment")), 642 + #("issue", json.string(found.uri)), 643 + #("body", json.string(body)), 644 + #("createdAt", json.string(sys.rfc3339_now())), 645 + ]) 646 + use #(uri, _) <- result.try( 647 + pds.put_record(sess, issue_collection <> ".comment", crkey, record) 648 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 649 + ) 650 + Ok(CreatedRecord(rkey: crkey, uri:)) 651 + } 652 + 653 + pub fn set_issue_state( 654 + svc: Service, 655 + t: Target, 656 + rkey: String, 657 + state: String, 658 + ) -> Result(StateResult, AppError) { 659 + use sess <- result.try(require_session(svc)) 660 + use did <- result.try(repo_did(svc, t)) 661 + use items <- result.try( 662 + tangled.list_issues(svc.appview, did) 663 + |> result.map_error(fn(e) { Failed(tangled.error_message(e)) }), 664 + ) 665 + use found <- result.try(find_by_rkey(items, rkey, "issue")) 666 + let full_state = issue_collection <> ".state." <> state 667 + let record = 668 + json.object([ 669 + #("$type", json.string(issue_collection <> ".state")), 670 + #("issue", json.string(found.uri)), 671 + #("state", json.string(full_state)), 672 + #("createdAt", json.string(sys.rfc3339_now())), 673 + ]) 674 + use _ <- result.try( 675 + pds.put_record(sess, issue_collection <> ".state", rkey, record) 676 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 677 + ) 678 + Ok(StateResult(rkey:, state:)) 679 + } 680 + 681 + // --- Pulls --- 682 + 683 + pub fn list_pulls(svc: Service, t: Target) -> Result(List(Item), AppError) { 684 + use did <- result.try(repo_did(svc, t)) 685 + use items <- result.try( 686 + tangled.list_pulls(svc.appview, did) 687 + |> result.map_error(fn(e) { Failed(tangled.error_message(e)) }), 688 + ) 689 + Ok(build_items(items, decode_pull)) 690 + } 691 + 692 + pub fn view_pull( 693 + svc: Service, 694 + t: Target, 695 + rkey: String, 696 + ) -> Result(ViewResult, AppError) { 697 + use did <- result.try(repo_did(svc, t)) 698 + use items <- result.try( 699 + tangled.list_pulls(svc.appview, did) 700 + |> result.map_error(fn(e) { Failed(tangled.error_message(e)) }), 701 + ) 702 + use found <- result.try(find_by_rkey(items, rkey, "pull request")) 703 + use view <- result.try(decode_pull(found.value)) 704 + Ok(ViewResult( 705 + rkey:, 706 + title: view.0, 707 + body: view.1, 708 + author: resolve_author(tangled.extract_did(found.uri)), 709 + created_at: view.2, 710 + source_branch: view.3, 711 + target_branch: view.4, 712 + )) 713 + } 714 + 715 + pub fn create_pull( 716 + svc: Service, 717 + t: Target, 718 + title: String, 719 + body: String, 720 + base: String, 721 + head: String, 722 + repo_dir: String, 723 + source: Option(Target), 724 + ) -> Result(PRCreateResult, AppError) { 725 + use sess <- result.try(require_session(svc)) 726 + use head <- result.try(case head { 727 + "" -> 728 + gitutil.current_branch(repo_dir) 729 + |> result.map_error(fn(e) { Failed(gitutil.error_message(e)) }) 730 + h -> Ok(h) 731 + }) 732 + use base <- result.try(case base { 733 + "" -> 734 + gitutil.default_branch(repo_dir) 735 + |> result.map_error(fn(e) { Failed(gitutil.error_message(e)) }) 736 + b -> Ok(b) 737 + }) 738 + use target_repo <- result.try(resolve_repo(svc, t)) 739 + use source_repo <- result.try(case source { 740 + None -> Ok(target_repo) 741 + Some(st) -> resolve_repo(svc, st) 742 + }) 743 + use patch_bits <- result.try( 744 + gitutil.generate_patch(repo_dir, base, head) 745 + |> result.map_error(fn(e) { Failed(gitutil.error_message(e)) }), 746 + ) 747 + let gzipped = sys.gzip(patch_bits) 748 + use blob <- result.try( 749 + pds.upload_blob(sess, gzipped, "application/gzip") 750 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 751 + ) 752 + let now = sys.rfc3339_now() 753 + let rkey = tid.new_now() 754 + let fields = [ 755 + #("$type", json.string(pull_collection)), 756 + #("title", json.string(title)), 757 + #("createdAt", json.string(now)), 758 + #( 759 + "target", 760 + json.object([ 761 + #("repo", json.string(target_repo.repo_did)), 762 + #("branch", json.string(base)), 763 + ]), 764 + ), 765 + #( 766 + "source", 767 + json.object([ 768 + #("repo", json.string(source_repo.repo_did)), 769 + #("branch", json.string(head)), 770 + ]), 771 + ), 772 + #( 773 + "rounds", 774 + json.preprocessed_array([ 775 + json.object([ 776 + #("createdAt", json.string(now)), 777 + #("patchBlob", pds.embed_blob(blob)), 778 + ]), 779 + ]), 780 + ), 781 + ] 782 + let fields = case body { 783 + "" -> fields 784 + b -> list.append(fields, [#("body", json.string(b))]) 785 + } 786 + use #(uri, _) <- result.try( 787 + pds.put_record(sess, pull_collection, rkey, json.object(fields)) 788 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 789 + ) 790 + Ok(PRCreateResult(uri:, title:, base:, head:)) 791 + } 792 + 793 + pub fn comment_pull( 794 + svc: Service, 795 + t: Target, 796 + rkey: String, 797 + body: String, 798 + ) -> Result(CreatedRecord, AppError) { 799 + use sess <- result.try(require_session(svc)) 800 + use did <- result.try(repo_did(svc, t)) 801 + use items <- result.try( 802 + tangled.list_pulls(svc.appview, did) 803 + |> result.map_error(fn(e) { Failed(tangled.error_message(e)) }), 804 + ) 805 + use found <- result.try(find_by_rkey(items, rkey, "pull request")) 806 + let crkey = tid.new_now() 807 + let record = 808 + json.object([ 809 + #("$type", json.string(pull_collection <> ".comment")), 810 + #("pull", json.string(found.uri)), 811 + #("body", json.string(body)), 812 + #("createdAt", json.string(sys.rfc3339_now())), 813 + ]) 814 + use #(uri, _) <- result.try( 815 + pds.put_record(sess, pull_collection <> ".comment", crkey, record) 816 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 817 + ) 818 + Ok(CreatedRecord(rkey: crkey, uri:)) 819 + } 820 + 821 + pub fn set_pull_state( 822 + svc: Service, 823 + t: Target, 824 + rkey: String, 825 + state: String, 826 + ) -> Result(StateResult, AppError) { 827 + use sess <- result.try(require_session(svc)) 828 + use did <- result.try(repo_did(svc, t)) 829 + use items <- result.try( 830 + tangled.list_pulls(svc.appview, did) 831 + |> result.map_error(fn(e) { Failed(tangled.error_message(e)) }), 832 + ) 833 + use found <- result.try(find_by_rkey(items, rkey, "pull request")) 834 + let full = pull_collection <> ".status." <> state 835 + let record = 836 + json.object([ 837 + #("$type", json.string(pull_collection <> ".status")), 838 + #("pull", json.string(found.uri)), 839 + #("status", json.string(full)), 840 + #("createdAt", json.string(sys.rfc3339_now())), 841 + ]) 842 + use _ <- result.try( 843 + pds.put_record(sess, pull_collection <> ".status", rkey, record) 844 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 845 + ) 846 + Ok(StateResult(rkey:, state:)) 847 + } 848 + 849 + pub fn merge_pull( 850 + svc: Service, 851 + t: Target, 852 + rkey: String, 853 + ) -> Result(StateResult, AppError) { 854 + use sess <- result.try(require_session(svc)) 855 + use did <- result.try(repo_did(svc, t)) 856 + use items <- result.try( 857 + tangled.list_pulls(svc.appview, did) 858 + |> result.map_error(fn(e) { Failed(tangled.error_message(e)) }), 859 + ) 860 + use found <- result.try(find_by_rkey(items, rkey, "pull request")) 861 + use repo <- result.try(resolve_repo(svc, t)) 862 + case repo.knot { 863 + "" -> Error(Failed("repository has no knot")) 864 + knot_host -> { 865 + use token <- result.try( 866 + pds.get_service_auth(sess, "did:web:" <> knot_host, "sh.tangled.repo.merge") 867 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 868 + ) 869 + use _ <- result.try( 870 + knot.merge(knot_host, token, repo.uri, found.uri) 871 + |> result.map_error(fn(e) { Failed(knot.error_message(e)) }), 872 + ) 873 + set_pull_state(svc, t, rkey, "merged") 874 + } 875 + } 876 + } 877 + 878 + pub fn pull_diff( 879 + svc: Service, 880 + t: Target, 881 + rkey: String, 882 + ) -> Result(String, AppError) { 883 + use did <- result.try(repo_did(svc, t)) 884 + use items <- result.try( 885 + tangled.list_pulls(svc.appview, did) 886 + |> result.map_error(fn(e) { Failed(tangled.error_message(e)) }), 887 + ) 888 + use found <- result.try(find_by_rkey(items, rkey, "pull request")) 889 + use cid <- result.try(latest_patch_cid(found.value)) 890 + let author = tangled.extract_did(found.uri) 891 + use pds_host <- result.try( 892 + resolve.resolve_pds(author) |> result.map_error(map_resolve), 893 + ) 894 + use compressed <- result.try( 895 + pds.download_blob(pds_host, author, cid) 896 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 897 + ) 898 + use patch <- result.try( 899 + sys.gunzip(compressed) 900 + |> result.map_error(fn(e) { Failed(e) }), 901 + ) 902 + case bit_array.to_string(patch) { 903 + Ok(s) -> Ok(s) 904 + Error(_) -> Error(Failed("patch is not UTF-8")) 905 + } 906 + } 907 + 908 + pub fn checkout_pull( 909 + svc: Service, 910 + t: Target, 911 + rkey: String, 912 + ) -> Result(PRCheckoutResult, AppError) { 913 + use patch <- result.try(pull_diff(svc, t, rkey)) 914 + use dir <- result.try( 915 + sys.get_cwd() |> result.map_error(fn(e) { Failed(e) }), 916 + ) 917 + let branch = "pr-" <> rkey 918 + use _ <- result.try( 919 + gitutil.apply_patch(dir, branch, patch) 920 + |> result.map_error(fn(e) { Failed(gitutil.error_message(e)) }), 921 + ) 922 + Ok(PRCheckoutResult(rkey:, branch:)) 923 + } 924 + 925 + // --- Strings --- 926 + 927 + pub fn list_strings(_svc: Service, handle: String) -> Result(List(StringItem), AppError) { 928 + use ident <- result.try( 929 + resolve.resolve(handle) |> result.map_error(map_resolve), 930 + ) 931 + use records <- result.try( 932 + pds.list_all_records(ident.pds, xrpc.NoAuth, ident.did, string_collection) 933 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 934 + ) 935 + Ok( 936 + list.filter_map(records, fn(rec) { 937 + let filename = pds.dynamic_optional_string(rec.value, "filename") 938 + case filename { 939 + "" -> Error(Nil) 940 + f -> 941 + Ok(StringItem( 942 + rkey: tangled.extract_rkey(rec.uri), 943 + uri: rec.uri, 944 + filename: f, 945 + description: pds.dynamic_optional_string(rec.value, "description"), 946 + created_at: pds.dynamic_optional_string(rec.value, "createdAt"), 947 + )) 948 + } 949 + }), 950 + ) 951 + } 952 + 953 + pub fn view_string( 954 + svc: Service, 955 + handle: String, 956 + rkey: String, 957 + ) -> Result(StringView, AppError) { 958 + let _ = svc 959 + use ident <- result.try( 960 + resolve.resolve(handle) |> result.map_error(map_resolve), 961 + ) 962 + use rec <- result.try( 963 + pds.get_record(ident.pds, xrpc.NoAuth, ident.did, string_collection, rkey) 964 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 965 + ) 966 + Ok(StringView( 967 + rkey:, 968 + uri: rec.uri, 969 + filename: pds.dynamic_optional_string(rec.value, "filename"), 970 + author: Author(did: ident.did, handle:), 971 + description: pds.dynamic_optional_string(rec.value, "description"), 972 + contents: pds.dynamic_optional_string(rec.value, "contents"), 973 + created_at: pds.dynamic_optional_string(rec.value, "createdAt"), 974 + )) 975 + } 976 + 977 + pub fn create_string( 978 + svc: Service, 979 + filename: String, 980 + description: String, 981 + contents: String, 982 + ) -> Result(CreatedRecord, AppError) { 983 + use sess <- result.try(require_session(svc)) 984 + let rkey = tid.new_now() 985 + let record = 986 + json.object([ 987 + #("$type", json.string(string_collection)), 988 + #("filename", json.string(filename)), 989 + #("description", json.string(description)), 990 + #("contents", json.string(contents)), 991 + #("createdAt", json.string(sys.rfc3339_now())), 992 + ]) 993 + use #(uri, _) <- result.try( 994 + pds.put_record(sess, string_collection, rkey, record) 995 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 996 + ) 997 + Ok(CreatedRecord(rkey:, uri:)) 998 + } 999 + 1000 + pub fn delete_string(svc: Service, rkey: String) -> Result(DeletedRecord, AppError) { 1001 + use sess <- result.try(require_session(svc)) 1002 + use _ <- result.try( 1003 + pds.delete_record(sess, string_collection, rkey) 1004 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 1005 + ) 1006 + Ok(DeletedRecord(rkey:)) 1007 + } 1008 + 1009 + // --- SSH keys --- 1010 + 1011 + pub fn list_ssh_keys(svc: Service, handle: String) -> Result(List(SSHKeyItem), AppError) { 1012 + let _ = svc 1013 + use ident <- result.try( 1014 + resolve.resolve(handle) |> result.map_error(map_resolve), 1015 + ) 1016 + use records <- result.try( 1017 + pds.list_all_records(ident.pds, xrpc.NoAuth, ident.did, ssh_key_collection) 1018 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 1019 + ) 1020 + Ok( 1021 + list.map(records, fn(rec) { 1022 + SSHKeyItem( 1023 + name: pds.dynamic_optional_string(rec.value, "name"), 1024 + key: pds.dynamic_optional_string(rec.value, "key"), 1025 + created_at: pds.dynamic_optional_string(rec.value, "createdAt"), 1026 + uri: rec.uri, 1027 + ) 1028 + }), 1029 + ) 1030 + } 1031 + 1032 + pub fn add_ssh_key( 1033 + svc: Service, 1034 + name: String, 1035 + key: String, 1036 + ) -> Result(SSHKeyAddResult, AppError) { 1037 + use sess <- result.try(require_session(svc)) 1038 + let rkey = tid.new_now() 1039 + let record = 1040 + json.object([ 1041 + #("$type", json.string(ssh_key_collection)), 1042 + #("name", json.string(name)), 1043 + #("key", json.string(key)), 1044 + #("createdAt", json.string(sys.rfc3339_now())), 1045 + ]) 1046 + use #(uri, _) <- result.try( 1047 + pds.put_record(sess, ssh_key_collection, rkey, record) 1048 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 1049 + ) 1050 + Ok(SSHKeyAddResult(name:, uri:)) 1051 + } 1052 + 1053 + pub fn delete_ssh_key(svc: Service, rkey: String) -> Result(DeletedRecord, AppError) { 1054 + use sess <- result.try(require_session(svc)) 1055 + use _ <- result.try( 1056 + pds.delete_record(sess, ssh_key_collection, rkey) 1057 + |> result.map_error(fn(e) { Failed(pds.error_message(e)) }), 1058 + ) 1059 + Ok(DeletedRecord(rkey:)) 1060 + } 1061 + 1062 + // --- API --- 1063 + 1064 + pub fn call_api( 1065 + svc: Service, 1066 + nsid: String, 1067 + method: String, 1068 + fields: List(#(String, String)), 1069 + ) -> Result(#(Int, String), AppError) { 1070 + use sess <- result.try(require_session(svc)) 1071 + // Prefer authenticated client for OAuth/DPoP when possible. 1072 + case string.uppercase(method) { 1073 + "GET" -> 1074 + client.get(sess, nsid, fields) 1075 + |> result.map(fn(body) { #(200, body) }) 1076 + |> result.map_error(fn(e) { Failed(xrpc.error_message(e)) }) 1077 + _ -> { 1078 + // Build a JSON object from string fields 1079 + let body = 1080 + fields 1081 + |> list.map(fn(p) { #(p.0, json.string(p.1)) }) 1082 + |> json.object 1083 + client.post_json(sess, nsid, body) 1084 + |> result.map(fn(b) { #(200, b) }) 1085 + |> result.map_error(fn(e) { Failed(xrpc.error_message(e)) }) 1086 + } 1087 + } 1088 + } 1089 + 1090 + // --- helpers --- 1091 + 1092 + fn resolve_author(did: String) -> Author { 1093 + case resolve.resolve_did(did) { 1094 + Ok(ident) -> Author(did:, handle: ident.handle) 1095 + Error(_) -> Author(did:, handle: did) 1096 + } 1097 + } 1098 + 1099 + fn find_by_rkey( 1100 + items: List(tangled.ListItem), 1101 + rkey: String, 1102 + what: String, 1103 + ) -> Result(tangled.ListItem, AppError) { 1104 + case list.find(items, fn(i) { string.ends_with(i.uri, "/" <> rkey) }) { 1105 + Ok(i) -> Ok(i) 1106 + Error(_) -> Error(Failed(what <> " \"" <> rkey <> "\" not found")) 1107 + } 1108 + } 1109 + 1110 + // decode returns (title, body, created_at, source_branch, target_branch) 1111 + fn decode_issue(value: Dynamic) -> Result(#(String, String, String, String, String), AppError) { 1112 + Ok(#( 1113 + pds.dynamic_optional_string(value, "title"), 1114 + pds.dynamic_optional_string(value, "body"), 1115 + pds.dynamic_optional_string(value, "createdAt"), 1116 + "", 1117 + "", 1118 + )) 1119 + } 1120 + 1121 + fn decode_pull(value: Dynamic) -> Result(#(String, String, String, String, String), AppError) { 1122 + let title = pds.dynamic_optional_string(value, "title") 1123 + let body = pds.dynamic_optional_string(value, "body") 1124 + let created = pds.dynamic_optional_string(value, "createdAt") 1125 + let source = nested_branch(value, "source") 1126 + let target = nested_branch(value, "target") 1127 + Ok(#(title, body, created, source, target)) 1128 + } 1129 + 1130 + fn nested_branch(value: Dynamic, field: String) -> String { 1131 + let decoder = { 1132 + use obj <- decode.field(field, { 1133 + use branch <- decode.optional_field("branch", "", decode.string) 1134 + decode.success(branch) 1135 + }) 1136 + decode.success(obj) 1137 + } 1138 + case decode.run(value, decoder) { 1139 + Ok(b) -> b 1140 + Error(_) -> "" 1141 + } 1142 + } 1143 + 1144 + fn build_items( 1145 + items: List(tangled.ListItem), 1146 + decode: fn(Dynamic) -> Result(#(String, String, String, String, String), AppError), 1147 + ) -> List(Item) { 1148 + list.filter_map(items, fn(list_item) { 1149 + case decode(list_item.value) { 1150 + Error(_) -> Error(Nil) 1151 + Ok(#(title, _body, created, source, target)) -> { 1152 + let title = case title { 1153 + "" -> "(no title)" 1154 + t -> t 1155 + } 1156 + let updated = case list_item.state_updated_at { 1157 + "" -> created 1158 + u -> u 1159 + } 1160 + Ok(Item( 1161 + rkey: tangled.extract_rkey(list_item.uri), 1162 + uri: list_item.uri, 1163 + title:, 1164 + state: list_item.state, 1165 + author: resolve_author(tangled.extract_did(list_item.uri)), 1166 + created_at: created, 1167 + updated_at: updated, 1168 + comment_count: list_item.comment_count, 1169 + source_branch: source, 1170 + target_branch: target, 1171 + )) 1172 + } 1173 + } 1174 + }) 1175 + } 1176 + 1177 + fn latest_patch_cid(value: Dynamic) -> Result(String, AppError) { 1178 + let round_decoder = { 1179 + use blob <- decode.field("patchBlob", { 1180 + use ref <- decode.field("ref", { 1181 + use link <- decode.field("$link", decode.string) 1182 + decode.success(link) 1183 + }) 1184 + decode.success(ref) 1185 + }) 1186 + decode.success(blob) 1187 + } 1188 + let decoder = { 1189 + use rounds <- decode.field("rounds", decode.list(round_decoder)) 1190 + decode.success(rounds) 1191 + } 1192 + case decode.run(value, decoder) { 1193 + Ok(rounds) -> 1194 + case list.last(rounds) { 1195 + Ok(cid) if cid != "" -> Ok(cid) 1196 + _ -> Error(Failed("pull request has no patch blob")) 1197 + } 1198 + Error(_) -> Error(Failed("decode pull rounds")) 1199 + } 1200 + }
+302
src/gtg/app/types.gleam
··· 1 + //// Domain types returned by the app layer (JSON-friendly). 2 + 3 + import gleam/json 4 + import gleam/list 5 + 6 + pub type Author { 7 + Author(did: String, handle: String) 8 + } 9 + 10 + pub type Item { 11 + Item( 12 + rkey: String, 13 + uri: String, 14 + title: String, 15 + state: String, 16 + author: Author, 17 + created_at: String, 18 + updated_at: String, 19 + comment_count: Int, 20 + source_branch: String, 21 + target_branch: String, 22 + ) 23 + } 24 + 25 + pub type RepoItem { 26 + RepoItem( 27 + name: String, 28 + uri: String, 29 + author: String, 30 + knot: String, 31 + description: String, 32 + created_at: String, 33 + repo_did: String, 34 + ) 35 + } 36 + 37 + pub type SSHKeyItem { 38 + SSHKeyItem(name: String, key: String, created_at: String, uri: String) 39 + } 40 + 41 + pub type StringItem { 42 + StringItem( 43 + rkey: String, 44 + uri: String, 45 + filename: String, 46 + description: String, 47 + created_at: String, 48 + ) 49 + } 50 + 51 + pub type StringView { 52 + StringView( 53 + rkey: String, 54 + uri: String, 55 + filename: String, 56 + author: Author, 57 + description: String, 58 + contents: String, 59 + created_at: String, 60 + ) 61 + } 62 + 63 + pub type ViewResult { 64 + ViewResult( 65 + rkey: String, 66 + title: String, 67 + body: String, 68 + author: Author, 69 + created_at: String, 70 + source_branch: String, 71 + target_branch: String, 72 + ) 73 + } 74 + 75 + pub type CreatedRecord { 76 + CreatedRecord(rkey: String, uri: String) 77 + } 78 + 79 + pub type DeletedRecord { 80 + DeletedRecord(rkey: String) 81 + } 82 + 83 + pub type StateResult { 84 + StateResult(rkey: String, state: String) 85 + } 86 + 87 + pub type RepoCreateResult { 88 + RepoCreateResult( 89 + handle: String, 90 + name: String, 91 + uri: String, 92 + knot: String, 93 + cloned: Bool, 94 + pushed: Bool, 95 + default_branch: String, 96 + warnings: List(String), 97 + ) 98 + } 99 + 100 + pub type RepoCloneResult { 101 + RepoCloneResult(handle: String, repo: String, destination: String) 102 + } 103 + 104 + pub type RepoEditResult { 105 + RepoEditResult(uri: String, description: String) 106 + } 107 + 108 + pub type RepoDeleteResult { 109 + RepoDeleteResult(uri: String) 110 + } 111 + 112 + pub type RepoDefaultBranchResult { 113 + RepoDefaultBranchResult(uri: String, branch: String) 114 + } 115 + 116 + pub type RepoForkResult { 117 + RepoForkResult(handle: String, name: String, uri: String, knot: String) 118 + } 119 + 120 + pub type PRCreateResult { 121 + PRCreateResult(uri: String, title: String, base: String, head: String) 122 + } 123 + 124 + pub type PRCheckoutResult { 125 + PRCheckoutResult(rkey: String, branch: String) 126 + } 127 + 128 + pub type SSHKeyAddResult { 129 + SSHKeyAddResult(name: String, uri: String) 130 + } 131 + 132 + pub type AuthStatus { 133 + AuthStatus( 134 + authenticated: Bool, 135 + status: String, 136 + did: String, 137 + handle: String, 138 + ) 139 + } 140 + 141 + pub type AuthLogout { 142 + AuthLogout(was_logged_in: Bool) 143 + } 144 + 145 + pub type AuthAccount { 146 + AuthAccount(active: Bool, did: String, handle: String, method: String) 147 + } 148 + 149 + pub type Target { 150 + Target(handle: String, repo: String) 151 + } 152 + 153 + pub fn target_string(t: Target) -> String { 154 + t.handle <> "/" <> t.repo 155 + } 156 + 157 + // --- JSON encoders --- 158 + 159 + pub fn author_json(a: Author) -> json.Json { 160 + json.object([ 161 + #("did", json.string(a.did)), 162 + #("handle", json.string(a.handle)), 163 + ]) 164 + } 165 + 166 + pub fn item_json(i: Item) -> json.Json { 167 + let base = [ 168 + #("rkey", json.string(i.rkey)), 169 + #("uri", json.string(i.uri)), 170 + #("title", json.string(i.title)), 171 + #("state", json.string(i.state)), 172 + #("author", author_json(i.author)), 173 + #("createdAt", json.string(i.created_at)), 174 + #("commentCount", json.int(i.comment_count)), 175 + ] 176 + let base = case i.updated_at { 177 + "" -> base 178 + u -> list.append(base, [#("updatedAt", json.string(u))]) 179 + } 180 + let base = case i.source_branch { 181 + "" -> base 182 + s -> list.append(base, [#("sourceBranch", json.string(s))]) 183 + } 184 + let base = case i.target_branch { 185 + "" -> base 186 + t -> list.append(base, [#("targetBranch", json.string(t))]) 187 + } 188 + json.object(base) 189 + } 190 + 191 + pub fn repo_item_json(r: RepoItem) -> json.Json { 192 + json.object([ 193 + #("name", json.string(r.name)), 194 + #("uri", json.string(r.uri)), 195 + #("author", json.string(r.author)), 196 + #("knot", json.string(r.knot)), 197 + #("description", json.string(r.description)), 198 + #("createdAt", json.string(r.created_at)), 199 + #("repoDid", json.string(r.repo_did)), 200 + ]) 201 + } 202 + 203 + pub fn auth_status_json(a: AuthStatus) -> json.Json { 204 + json.object([ 205 + #("authenticated", json.bool(a.authenticated)), 206 + #("status", json.string(a.status)), 207 + #("did", json.string(a.did)), 208 + #("handle", json.string(a.handle)), 209 + ]) 210 + } 211 + 212 + pub fn auth_account_json(a: AuthAccount) -> json.Json { 213 + json.object([ 214 + #("active", json.bool(a.active)), 215 + #("did", json.string(a.did)), 216 + #("handle", json.string(a.handle)), 217 + #("method", json.string(a.method)), 218 + ]) 219 + } 220 + 221 + pub fn created_json(c: CreatedRecord) -> json.Json { 222 + json.object([ 223 + #("rkey", json.string(c.rkey)), 224 + #("uri", json.string(c.uri)), 225 + ]) 226 + } 227 + 228 + pub fn view_json(v: ViewResult) -> json.Json { 229 + json.object([ 230 + #("rkey", json.string(v.rkey)), 231 + #("title", json.string(v.title)), 232 + #("body", json.string(v.body)), 233 + #("author", author_json(v.author)), 234 + #("createdAt", json.string(v.created_at)), 235 + #("sourceBranch", json.string(v.source_branch)), 236 + #("targetBranch", json.string(v.target_branch)), 237 + ]) 238 + } 239 + 240 + pub fn string_item_json(s: StringItem) -> json.Json { 241 + json.object([ 242 + #("rkey", json.string(s.rkey)), 243 + #("uri", json.string(s.uri)), 244 + #("filename", json.string(s.filename)), 245 + #("description", json.string(s.description)), 246 + #("createdAt", json.string(s.created_at)), 247 + ]) 248 + } 249 + 250 + pub fn string_view_json(s: StringView) -> json.Json { 251 + json.object([ 252 + #("rkey", json.string(s.rkey)), 253 + #("uri", json.string(s.uri)), 254 + #("filename", json.string(s.filename)), 255 + #("author", author_json(s.author)), 256 + #("description", json.string(s.description)), 257 + #("contents", json.string(s.contents)), 258 + #("createdAt", json.string(s.created_at)), 259 + ]) 260 + } 261 + 262 + pub fn ssh_key_json(k: SSHKeyItem) -> json.Json { 263 + json.object([ 264 + #("name", json.string(k.name)), 265 + #("key", json.string(k.key)), 266 + #("createdAt", json.string(k.created_at)), 267 + #("uri", json.string(k.uri)), 268 + ]) 269 + } 270 + 271 + pub fn repo_create_json(r: RepoCreateResult) -> json.Json { 272 + json.object([ 273 + #("handle", json.string(r.handle)), 274 + #("name", json.string(r.name)), 275 + #("uri", json.string(r.uri)), 276 + #("knot", json.string(r.knot)), 277 + #("cloned", json.bool(r.cloned)), 278 + #("pushed", json.bool(r.pushed)), 279 + #("defaultBranch", json.string(r.default_branch)), 280 + #("warnings", json.array(r.warnings, json.string)), 281 + ]) 282 + } 283 + 284 + pub fn pr_create_json(p: PRCreateResult) -> json.Json { 285 + json.object([ 286 + #("uri", json.string(p.uri)), 287 + #("title", json.string(p.title)), 288 + #("base", json.string(p.base)), 289 + #("head", json.string(p.head)), 290 + ]) 291 + } 292 + 293 + pub fn state_json(s: StateResult) -> json.Json { 294 + json.object([ 295 + #("rkey", json.string(s.rkey)), 296 + #("state", json.string(s.state)), 297 + ]) 298 + } 299 + 300 + pub fn deleted_json(d: DeletedRecord) -> json.Json { 301 + json.object([#("rkey", json.string(d.rkey))]) 302 + }
+328
src/gtg/atproto/auth.gleam
··· 1 + //// Authentication: app passwords + OAuth (DPoP), multi-account sessions. 2 + 3 + import gleam/dynamic/decode 4 + import gleam/json 5 + import gleam/option.{type Option} 6 + import gleam/result 7 + import gtg/atproto/client.{type Client} 8 + import gtg/atproto/dpop 9 + import gtg/atproto/oauth 10 + import gtg/atproto/resolve 11 + import gtg/atproto/session 12 + import gtg/atproto/xrpc 13 + import shellout 14 + 15 + pub type AuthError { 16 + NotAuthenticated 17 + LoginFailed(String) 18 + SessionError(String) 19 + } 20 + 21 + pub fn error_message(err: AuthError) -> String { 22 + case err { 23 + NotAuthenticated -> "not authenticated" 24 + LoginFailed(m) -> m 25 + SessionError(m) -> m 26 + } 27 + } 28 + 29 + /// Log in with handle/DID + app password and persist the session. 30 + pub fn login_with_password( 31 + identifier: String, 32 + password: String, 33 + ) -> Result(String, AuthError) { 34 + use ident <- result.try( 35 + resolve.resolve(identifier) 36 + |> result.map_error(fn(e) { 37 + case e { 38 + resolve.ResolveFailed(m) -> LoginFailed(m) 39 + } 40 + }), 41 + ) 42 + let body = 43 + json.object([ 44 + #("identifier", json.string(identifier)), 45 + #("password", json.string(password)), 46 + ]) 47 + case 48 + xrpc.post_json( 49 + ident.pds, 50 + "com.atproto.server.createSession", 51 + body, 52 + xrpc.NoAuth, 53 + ) 54 + { 55 + Error(e) -> Error(LoginFailed(xrpc.error_message(e))) 56 + Ok(resp) -> { 57 + let decoder = { 58 + use access_jwt <- decode.field("accessJwt", decode.string) 59 + use refresh_jwt <- decode.field("refreshJwt", decode.string) 60 + use did <- decode.field("did", decode.string) 61 + use handle <- decode.optional_field("handle", identifier, decode.string) 62 + decode.success(#(access_jwt, refresh_jwt, did, handle)) 63 + } 64 + case json.parse(resp, decoder) { 65 + Error(_) -> Error(LoginFailed("decode createSession response")) 66 + Ok(#(access_jwt, refresh_jwt, did, handle)) -> { 67 + let sess = 68 + session.PasswordSession( 69 + did:, 70 + handle:, 71 + access_jwt:, 72 + refresh_jwt:, 73 + pds: ident.pds, 74 + ) 75 + case session.save_password_session(sess) { 76 + Ok(_) -> Ok(did) 77 + Error(e) -> Error(SessionError(session.store_error_message(e))) 78 + } 79 + } 80 + } 81 + } 82 + } 83 + } 84 + 85 + /// Interactive OAuth login: opens browser, waits for localhost callback. 86 + pub fn login_with_oauth(identifier: String) -> Result(String, AuthError) { 87 + use prepared <- result.try( 88 + oauth.prepare(identifier) 89 + |> result.map_error(LoginFailed), 90 + ) 91 + // Print URL so headless users can open it; try xdg-open as well. 92 + let _ = 93 + print_login_url( 94 + "Opening browser to complete login…\n" 95 + <> "If it does not open, visit:\n" 96 + <> prepared.auth_url 97 + <> "\nWaiting for callback on " 98 + <> oauth.callback_url 99 + <> " …", 100 + ) 101 + let _ = open_browser(prepared.auth_url) 102 + // Wait up to 5 minutes for the callback on 127.0.0.1:8095 103 + use qs <- result.try( 104 + oauth.wait_callback(300_000) 105 + |> result.map_error(LoginFailed), 106 + ) 107 + use #(code, state) <- result.try( 108 + oauth.parse_callback_query(qs) 109 + |> result.map_error(LoginFailed), 110 + ) 111 + use sess <- result.try( 112 + oauth.complete(prepared, code, state) 113 + |> result.map_error(LoginFailed), 114 + ) 115 + use _ <- result.try(persist_oauth(sess)) 116 + Ok(sess.did) 117 + } 118 + 119 + fn persist_oauth(sess: oauth.OAuthSession) -> Result(Nil, AuthError) { 120 + session.save_oauth_session( 121 + sess.did, 122 + sess.handle, 123 + sess.access_token, 124 + sess.pds_url, 125 + dpop.serialize(sess.dpop_key), 126 + sess.dpop_nonce, 127 + sess.refresh_token, 128 + sess.token_endpoint, 129 + sess.client_id, 130 + ) 131 + |> result.map_error(fn(e) { SessionError(session.store_error_message(e)) }) 132 + } 133 + 134 + fn open_browser(url: String) -> Nil { 135 + let _ = shellout.command(run: "xdg-open", with: [url], in: ".", opt: []) 136 + Nil 137 + } 138 + 139 + fn print_login_url(url: String) -> Nil { 140 + // Use Erlang io so we don't depend on gleam/io in this module cycle. 141 + print_line("Open this URL to authenticate:\n" <> url <> "\n") 142 + } 143 + 144 + @external(erlang, "io", "format") 145 + fn erl_format(fmt: String, args: List(String)) -> Nil 146 + 147 + fn print_line(s: String) -> Nil { 148 + // io:format("~s~n", [Bin]) 149 + erl_format("~s~n", [s]) 150 + } 151 + 152 + /// Load the active session as a unified Client (password or OAuth). 153 + pub fn active_client( 154 + account_selector: Option(String), 155 + ) -> Result(Client, AuthError) { 156 + use account <- result.try( 157 + session.active_account(account_selector) 158 + |> result.map_error(fn(e) { 159 + case e { 160 + session.NotFound -> NotAuthenticated 161 + other -> SessionError(session.store_error_message(other)) 162 + } 163 + }), 164 + ) 165 + case account.method { 166 + m if m == session.auth_method_oauth -> load_oauth_client(account.did) 167 + _ -> load_password_client(account.did) 168 + } 169 + } 170 + 171 + fn load_password_client(did: String) -> Result(Client, AuthError) { 172 + use sess <- result.try( 173 + session.get_password_session(did) 174 + |> result.map_error(fn(e) { 175 + case e { 176 + session.NotFound -> NotAuthenticated 177 + other -> SessionError(session.store_error_message(other)) 178 + } 179 + }), 180 + ) 181 + // Best-effort refresh 182 + let sess = case refresh_password(sess) { 183 + Ok(fresh) -> fresh 184 + Error(_) -> sess 185 + } 186 + Ok(client.from_password(sess)) 187 + } 188 + 189 + fn load_oauth_client(did: String) -> Result(Client, AuthError) { 190 + use stored <- result.try( 191 + session.get_oauth_session(did) 192 + |> result.map_error(fn(e) { 193 + case e { 194 + session.NotFound -> NotAuthenticated 195 + other -> SessionError(session.store_error_message(other)) 196 + } 197 + }), 198 + ) 199 + use key <- result.try( 200 + dpop.deserialize(stored.dpop_key) 201 + |> result.map_error(SessionError), 202 + ) 203 + let sess = 204 + oauth.OAuthSession( 205 + did: stored.did, 206 + handle: stored.handle, 207 + access_token: stored.access_token, 208 + pds_url: stored.pds, 209 + dpop_key: key, 210 + dpop_nonce: stored.dpop_nonce, 211 + refresh_token: stored.refresh_token, 212 + token_endpoint: stored.token_endpoint, 213 + client_id: stored.client_id, 214 + ) 215 + let sess = case oauth.refresh(sess) { 216 + Ok(fresh) -> { 217 + let _ = persist_oauth(fresh) 218 + fresh 219 + } 220 + Error(_) -> sess 221 + } 222 + Ok(client.from_oauth(sess)) 223 + } 224 + 225 + fn refresh_password( 226 + sess: session.PasswordSession, 227 + ) -> Result(session.PasswordSession, AuthError) { 228 + case 229 + xrpc.post_json( 230 + sess.pds, 231 + "com.atproto.server.refreshSession", 232 + json.object([]), 233 + xrpc.Bearer(sess.refresh_jwt), 234 + ) 235 + { 236 + Error(e) -> Error(LoginFailed(xrpc.error_message(e))) 237 + Ok(resp) -> { 238 + let decoder = { 239 + use access_jwt <- decode.field("accessJwt", decode.string) 240 + use refresh_jwt <- decode.field("refreshJwt", decode.string) 241 + use did <- decode.field("did", decode.string) 242 + use handle <- decode.optional_field( 243 + "handle", 244 + sess.handle, 245 + decode.string, 246 + ) 247 + decode.success(#(access_jwt, refresh_jwt, did, handle)) 248 + } 249 + case json.parse(resp, decoder) { 250 + Error(_) -> Error(LoginFailed("decode refreshSession")) 251 + Ok(#(access_jwt, refresh_jwt, did, handle)) -> { 252 + let fresh = 253 + session.PasswordSession( 254 + did:, 255 + handle:, 256 + access_jwt:, 257 + refresh_jwt:, 258 + pds: sess.pds, 259 + ) 260 + case session.save_password_session(fresh) { 261 + Ok(_) -> Ok(fresh) 262 + Error(e) -> Error(SessionError(session.store_error_message(e))) 263 + } 264 + } 265 + } 266 + } 267 + } 268 + } 269 + 270 + pub fn logout( 271 + all: Bool, 272 + account_selector: Option(String), 273 + ) -> Result(Bool, AuthError) { 274 + case all { 275 + True -> { 276 + case session.delete_all() { 277 + Ok(_) -> Ok(True) 278 + Error(session.NotFound) -> Ok(False) 279 + Error(e) -> Error(SessionError(session.store_error_message(e))) 280 + } 281 + } 282 + False -> { 283 + case session.active_account(account_selector) { 284 + Error(session.NotFound) -> Ok(False) 285 + Error(e) -> Error(SessionError(session.store_error_message(e))) 286 + Ok(account) -> 287 + case session.delete_account(account.did) { 288 + Ok(_) -> Ok(True) 289 + Error(e) -> Error(SessionError(session.store_error_message(e))) 290 + } 291 + } 292 + } 293 + } 294 + } 295 + 296 + pub fn probe_status( 297 + account_selector: Option(String), 298 + ) -> Result(#(Bool, String, String, String), AuthError) { 299 + case active_client(account_selector) { 300 + Error(NotAuthenticated) -> Ok(#(False, "", "", "")) 301 + Error(e) -> Error(e) 302 + Ok(c) -> { 303 + case client.get(c, "com.atproto.server.getSession", []) { 304 + Ok(_) -> Ok(#(True, "active", c.did, c.handle)) 305 + Error(xrpc.StatusError(status, _)) if status == 401 || status == 403 -> 306 + Ok(#(True, "expired", c.did, c.handle)) 307 + Error(_) -> Ok(#(True, "unknown", c.did, c.handle)) 308 + } 309 + } 310 + } 311 + } 312 + 313 + pub fn access_token( 314 + account_selector: Option(String), 315 + ) -> Result(String, AuthError) { 316 + use c <- result.try(active_client(account_selector)) 317 + Ok(c.access_token) 318 + } 319 + 320 + pub fn switch_account(selector: String) -> Result(session.Account, AuthError) { 321 + session.select_account(selector) 322 + |> result.map_error(fn(e) { 323 + case e { 324 + session.NotFound -> LoginFailed("account not found: " <> selector) 325 + other -> SessionError(session.store_error_message(other)) 326 + } 327 + }) 328 + }
+273
src/gtg/atproto/client.gleam
··· 1 + //// Unified authenticated ATProto client (app-password Bearer or OAuth DPoP). 2 + 3 + import gleam/bit_array 4 + import gleam/http 5 + import gleam/http/request 6 + import gleam/httpc 7 + import gleam/json 8 + import gleam/list 9 + import gleam/option.{type Option, None, Some} 10 + import gleam/result 11 + import gleam/string 12 + import gleam/uri 13 + import gtg/atproto/dpop.{type DpopKey} 14 + import gtg/atproto/oauth 15 + import gtg/atproto/session 16 + import gtg/atproto/xrpc 17 + 18 + pub type AuthKind { 19 + Password 20 + OAuth( 21 + dpop_key: DpopKey, 22 + dpop_nonce: Option(String), 23 + refresh_token: Option(String), 24 + token_endpoint: Option(String), 25 + client_id: Option(String), 26 + ) 27 + } 28 + 29 + /// Authenticated PDS client for XRPC calls. 30 + pub type Client { 31 + Client( 32 + did: String, 33 + handle: String, 34 + pds: String, 35 + access_token: String, 36 + kind: AuthKind, 37 + ) 38 + } 39 + 40 + pub fn from_password(s: session.PasswordSession) -> Client { 41 + Client( 42 + did: s.did, 43 + handle: s.handle, 44 + pds: s.pds, 45 + access_token: s.access_jwt, 46 + kind: Password, 47 + ) 48 + } 49 + 50 + pub fn from_oauth(s: oauth.OAuthSession) -> Client { 51 + Client( 52 + did: s.did, 53 + handle: s.handle, 54 + pds: s.pds_url, 55 + access_token: s.access_token, 56 + kind: OAuth( 57 + dpop_key: s.dpop_key, 58 + dpop_nonce: s.dpop_nonce, 59 + refresh_token: s.refresh_token, 60 + token_endpoint: s.token_endpoint, 61 + client_id: s.client_id, 62 + ), 63 + ) 64 + } 65 + 66 + pub fn to_oauth_session(c: Client) -> Option(oauth.OAuthSession) { 67 + case c.kind { 68 + Password -> None 69 + OAuth(key, nonce, rt, te, cid) -> 70 + Some(oauth.OAuthSession( 71 + did: c.did, 72 + handle: c.handle, 73 + access_token: c.access_token, 74 + pds_url: c.pds, 75 + dpop_key: key, 76 + dpop_nonce: nonce, 77 + refresh_token: rt, 78 + token_endpoint: te, 79 + client_id: cid, 80 + )) 81 + } 82 + } 83 + 84 + /// GET XRPC with Bearer or DPoP auth. 85 + pub fn get( 86 + client: Client, 87 + nsid: String, 88 + params: List(#(String, String)), 89 + ) -> Result(String, xrpc.XrpcError) { 90 + request(client, http.Get, nsid, params, None) 91 + } 92 + 93 + /// POST JSON XRPC with Bearer or DPoP auth. 94 + pub fn post_json( 95 + client: Client, 96 + nsid: String, 97 + body: json.Json, 98 + ) -> Result(String, xrpc.XrpcError) { 99 + let payload = json.to_string(body) 100 + request( 101 + client, 102 + http.Post, 103 + nsid, 104 + [], 105 + Some(#("application/json", bit_array.from_string(payload))), 106 + ) 107 + } 108 + 109 + /// POST raw bytes (blob upload). 110 + pub fn post_bytes( 111 + client: Client, 112 + nsid: String, 113 + content_type: String, 114 + body: BitArray, 115 + ) -> Result(String, xrpc.XrpcError) { 116 + request(client, http.Post, nsid, [], Some(#(content_type, body))) 117 + } 118 + 119 + fn request( 120 + client: Client, 121 + method: http.Method, 122 + nsid: String, 123 + params: List(#(String, String)), 124 + body: Option(#(String, BitArray)), 125 + ) -> Result(String, xrpc.XrpcError) { 126 + let url = xrpc_url(client.pds, nsid, params) 127 + let htu = xrpc_url(client.pds, nsid, []) 128 + case client.kind { 129 + Password -> do_send(url, method, body, xrpc.Bearer(client.access_token)) 130 + OAuth(key, nonce, _, _, _) -> { 131 + let method_s = case method { 132 + http.Get -> "GET" 133 + http.Post -> "POST" 134 + _ -> "GET" 135 + } 136 + let proof = 137 + dpop.proof(key, method_s, htu, nonce, Some(client.access_token)) 138 + case do_send(url, method, body, xrpc.Dpop(client.access_token, proof)) { 139 + Ok(s) -> Ok(s) 140 + Error(xrpc.StatusError(status, _)) if status == 401 || status == 400 -> { 141 + // Retry once with nonce from a probe (if available) 142 + do_send_with_nonce_retry( 143 + url, 144 + method, 145 + body, 146 + client, 147 + key, 148 + htu, 149 + method_s, 150 + ) 151 + } 152 + Error(e) -> Error(e) 153 + } 154 + } 155 + } 156 + } 157 + 158 + fn do_send_with_nonce_retry( 159 + url: String, 160 + method: http.Method, 161 + body: Option(#(String, BitArray)), 162 + client: Client, 163 + key: DpopKey, 164 + htu: String, 165 + method_s: String, 166 + ) -> Result(String, xrpc.XrpcError) { 167 + // Probe getSession to obtain a DPoP-Nonce header. 168 + let probe_url = trim_slash(client.pds) <> "/xrpc/com.atproto.server.getSession" 169 + let probe_proof = 170 + dpop.proof(key, "GET", probe_url, None, Some(client.access_token)) 171 + let nonce = case raw_get(probe_url, client.access_token, probe_proof) { 172 + Ok(headers) -> oauth.header_value(headers, "dpop-nonce") 173 + Error(_) -> None 174 + } 175 + let proof = dpop.proof(key, method_s, htu, nonce, Some(client.access_token)) 176 + do_send(url, method, body, xrpc.Dpop(client.access_token, proof)) 177 + } 178 + 179 + fn raw_get( 180 + url: String, 181 + token: String, 182 + dpop_proof: String, 183 + ) -> Result(List(#(String, String)), Nil) { 184 + case request.to(url) { 185 + Error(_) -> Error(Nil) 186 + Ok(req) -> { 187 + let req = 188 + req 189 + |> request.set_method(http.Get) 190 + |> request.set_header("authorization", "DPoP " <> token) 191 + |> request.set_header("dpop", dpop_proof) 192 + |> request.set_header("user-agent", "gtg") 193 + |> request.set_body(<<>>) 194 + case httpc.send_bits(req) { 195 + Ok(resp) -> Ok(resp.headers) 196 + Error(_) -> Error(Nil) 197 + } 198 + } 199 + } 200 + } 201 + 202 + fn do_send( 203 + url: String, 204 + method: http.Method, 205 + body: Option(#(String, BitArray)), 206 + auth: xrpc.Auth, 207 + ) -> Result(String, xrpc.XrpcError) { 208 + case request.to(url) { 209 + Error(_) -> Error(xrpc.HttpError("invalid URL: " <> url)) 210 + Ok(req) -> { 211 + let req = 212 + req 213 + |> request.set_method(method) 214 + |> request.set_header("user-agent", "gtg") 215 + |> request.set_header("accept", "application/json") 216 + let req = case auth { 217 + xrpc.Bearer(token) -> 218 + request.set_header(req, "authorization", "Bearer " <> token) 219 + xrpc.Dpop(token, proof) -> 220 + req 221 + |> request.set_header("authorization", "DPoP " <> token) 222 + |> request.set_header("dpop", proof) 223 + xrpc.NoAuth -> req 224 + } 225 + let req = case body { 226 + Some(#(ct, bytes)) -> 227 + req 228 + |> request.set_header("content-type", ct) 229 + |> request.set_body(bytes) 230 + None -> request.set_body(req, <<>>) 231 + } 232 + case httpc.send_bits(req) { 233 + Error(e) -> Error(xrpc.HttpError(string.inspect(e))) 234 + Ok(resp) -> 235 + case resp.status >= 200 && resp.status < 300 { 236 + True -> 237 + case bit_array.to_string(resp.body) { 238 + Ok(s) -> Ok(s) 239 + Error(_) -> Error(xrpc.DecodeError("response body is not UTF-8")) 240 + } 241 + False -> 242 + Error(xrpc.StatusError( 243 + status: resp.status, 244 + body: bit_array.to_string(resp.body) |> result.unwrap(""), 245 + )) 246 + } 247 + } 248 + } 249 + } 250 + } 251 + 252 + fn xrpc_url(host: String, nsid: String, params: List(#(String, String))) -> String { 253 + let base = trim_slash(host) <> "/xrpc/" <> nsid 254 + case params { 255 + [] -> base 256 + _ -> { 257 + let qs = 258 + params 259 + |> list.map(fn(p) { 260 + uri.percent_encode(p.0) <> "=" <> uri.percent_encode(p.1) 261 + }) 262 + |> string.join("&") 263 + base <> "?" <> qs 264 + } 265 + } 266 + } 267 + 268 + fn trim_slash(host: String) -> String { 269 + case string.ends_with(host, "/") { 270 + True -> string.drop_end(host, 1) 271 + False -> host 272 + } 273 + }
+99
src/gtg/atproto/dpop.gleam
··· 1 + //// Ed25519 DPoP keypair and proof JWTs (AT Protocol OAuth). 2 + 3 + import gleam/bit_array 4 + import gleam/crypto 5 + import gleam/json 6 + import gleam/option.{type Option, Some} 7 + import gleam/string 8 + import gtg/sys 9 + 10 + pub type DpopKey { 11 + DpopKey(private_key: BitArray, public_key: BitArray) 12 + } 13 + 14 + pub fn generate() -> DpopKey { 15 + let #(public_key, private_key) = ed25519_generate() 16 + DpopKey(private_key:, public_key:) 17 + } 18 + 19 + pub fn deserialize(str: String) -> Result(DpopKey, String) { 20 + case bit_array.base64_url_decode(str) { 21 + Ok(bytes) -> 22 + case bit_array.byte_size(bytes) { 23 + 32 -> Ok(DpopKey(private_key: bytes, public_key: ed25519_public(bytes))) 24 + _ -> Error("invalid dpop_key length") 25 + } 26 + Error(_) -> Error("invalid dpop_key encoding") 27 + } 28 + } 29 + 30 + pub fn serialize(key: DpopKey) -> String { 31 + bit_array.base64_url_encode(key.private_key, False) 32 + } 33 + 34 + pub fn jwk(key: DpopKey) -> json.Json { 35 + json.object([ 36 + #("kty", json.string("OKP")), 37 + #("crv", json.string("Ed25519")), 38 + #("x", json.string(bit_array.base64_url_encode(key.public_key, False))), 39 + ]) 40 + } 41 + 42 + /// Build a DPoP proof JWT for `method` + `url`. 43 + pub fn proof( 44 + key: DpopKey, 45 + method: String, 46 + url: String, 47 + nonce: Option(String), 48 + access_token: Option(String), 49 + ) -> String { 50 + let method = string.uppercase(method) 51 + let header = 52 + json.object([ 53 + #("typ", json.string("dpop+jwt")), 54 + #("alg", json.string("EdDSA")), 55 + #("jwk", jwk(key)), 56 + ]) 57 + let jti = bit_array.base64_url_encode(crypto.strong_random_bytes(16), False) 58 + let iat = sys.unix_seconds() 59 + let base = [ 60 + #("htm", json.string(method)), 61 + #("htu", json.string(url)), 62 + #("iat", json.int(iat)), 63 + #("jti", json.string(jti)), 64 + ] 65 + let base = case nonce { 66 + Some(n) if n != "" -> [#("nonce", json.string(n)), ..base] 67 + _ -> base 68 + } 69 + let base = case access_token { 70 + Some(tok) if tok != "" -> { 71 + let ath = 72 + crypto.hash(crypto.Sha256, <<tok:utf8>>) 73 + |> bit_array.base64_url_encode(False) 74 + [#("ath", json.string(ath)), ..base] 75 + } 76 + _ -> base 77 + } 78 + let header_b64 = b64url_json(header) 79 + let payload_b64 = b64url_json(json.object(base)) 80 + let signing_input = header_b64 <> "." <> payload_b64 81 + let signature = ed25519_sign(<<signing_input:utf8>>, key.private_key) 82 + signing_input <> "." <> bit_array.base64_url_encode(signature, False) 83 + } 84 + 85 + fn b64url_json(value: json.Json) -> String { 86 + value 87 + |> json.to_string 88 + |> bit_array.from_string 89 + |> bit_array.base64_url_encode(False) 90 + } 91 + 92 + @external(erlang, "gtg_ffi", "ed25519_generate") 93 + fn ed25519_generate() -> #(BitArray, BitArray) 94 + 95 + @external(erlang, "gtg_ffi", "ed25519_public") 96 + fn ed25519_public(seed: BitArray) -> BitArray 97 + 98 + @external(erlang, "gtg_ffi", "ed25519_sign") 99 + fn ed25519_sign(msg: BitArray, seed: BitArray) -> BitArray
+690
src/gtg/atproto/oauth.gleam
··· 1 + //// AT Protocol OAuth: PAR + PKCE + DPoP for localhost CLI clients. 2 + 3 + import gleam/bit_array 4 + import gleam/crypto 5 + import gleam/dynamic/decode 6 + import gleam/http 7 + import gleam/http/request 8 + import gleam/http/response 9 + import gleam/httpc 10 + import gleam/int 11 + import gleam/json 12 + import gleam/list 13 + import gleam/option.{type Option, None, Some} 14 + import gleam/result 15 + import gleam/string 16 + import gleam/uri 17 + import gtg/atproto/dpop.{type DpopKey} 18 + import gtg/atproto/resolve 19 + 20 + /// Scopes for a Tangled CLI session (mirrors Go tg; includes transition:generic). 21 + pub const default_scope = "atproto transition:generic repo:sh.tangled.repo repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status repo:sh.tangled.string repo:sh.tangled.publicKey blob:application/gzip rpc:sh.tangled.repo.create?aud=* rpc:sh.tangled.repo.delete?aud=* rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.setDefaultBranch?aud=*" 22 + 23 + pub const callback_addr = "127.0.0.1:8095" 24 + 25 + pub const callback_url = "http://127.0.0.1:8095/callback" 26 + 27 + pub const public_url = "http://127.0.0.1:8095" 28 + 29 + /// In-flight OAuth login state. 30 + pub type PreparedLogin { 31 + PreparedLogin( 32 + auth_url: String, 33 + state: String, 34 + redirect_uri: String, 35 + client_id: String, 36 + code_verifier: String, 37 + token_endpoint: String, 38 + pds_url: String, 39 + dpop_key: DpopKey, 40 + did: String, 41 + handle: String, 42 + ) 43 + } 44 + 45 + pub type OAuthSession { 46 + OAuthSession( 47 + did: String, 48 + handle: String, 49 + access_token: String, 50 + pds_url: String, 51 + dpop_key: DpopKey, 52 + dpop_nonce: Option(String), 53 + refresh_token: Option(String), 54 + token_endpoint: Option(String), 55 + client_id: Option(String), 56 + ) 57 + } 58 + 59 + /// Start OAuth for a handle: resolve identity, PAR, return browser URL + state. 60 + pub fn prepare(handle: String) -> Result(PreparedLogin, String) { 61 + let handle = drop_at(string.trim(handle)) 62 + use ident <- result.try( 63 + resolve.resolve(handle) 64 + |> result.map_error(fn(e) { 65 + case e { 66 + resolve.ResolveFailed(m) -> m 67 + } 68 + }), 69 + ) 70 + use auth_meta <- result.try(discover_auth_server(ident.pds)) 71 + let redirect_uri = callback_url 72 + let client_id = localhost_client_id(redirect_uri) 73 + let #(code_verifier, code_challenge) = generate_pkce() 74 + let dpop_key = dpop.generate() 75 + let state = random_b64url(16) 76 + use par_endpoint <- result.try(map_get( 77 + auth_meta, 78 + "pushed_authorization_request_endpoint", 79 + )) 80 + use authorization_endpoint <- result.try(map_get( 81 + auth_meta, 82 + "authorization_endpoint", 83 + )) 84 + use token_endpoint <- result.try(map_get(auth_meta, "token_endpoint")) 85 + use auth_url <- result.try(push_authorization_request( 86 + par_endpoint, 87 + authorization_endpoint, 88 + client_id, 89 + redirect_uri, 90 + code_challenge, 91 + state, 92 + handle, 93 + dpop_key, 94 + )) 95 + Ok(PreparedLogin( 96 + auth_url:, 97 + state:, 98 + redirect_uri:, 99 + client_id:, 100 + code_verifier:, 101 + token_endpoint:, 102 + pds_url: ident.pds, 103 + dpop_key:, 104 + did: ident.did, 105 + handle: case ident.handle { 106 + "" -> handle 107 + h -> h 108 + }, 109 + )) 110 + } 111 + 112 + /// Complete token exchange after browser callback. 113 + pub fn complete( 114 + prepared: PreparedLogin, 115 + auth_code: String, 116 + returned_state: String, 117 + ) -> Result(OAuthSession, String) { 118 + case returned_state == prepared.state { 119 + False -> Error("OAuth state mismatch (possible CSRF)") 120 + True -> { 121 + use tokens <- result.try(exchange_code( 122 + prepared.token_endpoint, 123 + auth_code, 124 + prepared.code_verifier, 125 + prepared.redirect_uri, 126 + prepared.client_id, 127 + prepared.dpop_key, 128 + )) 129 + case tokens.sub { 130 + Some(sub) if sub != prepared.did -> 131 + Error( 132 + "DID mismatch: resolved " 133 + <> prepared.did 134 + <> " but token is for " 135 + <> sub, 136 + ) 137 + _ -> { 138 + let dpop_nonce = 139 + probe_dpop_nonce( 140 + prepared.pds_url, 141 + tokens.access_token, 142 + prepared.dpop_key, 143 + ) 144 + Ok(OAuthSession( 145 + did: prepared.did, 146 + handle: prepared.handle, 147 + access_token: tokens.access_token, 148 + pds_url: prepared.pds_url, 149 + dpop_key: prepared.dpop_key, 150 + dpop_nonce:, 151 + refresh_token: tokens.refresh_token, 152 + token_endpoint: Some(prepared.token_endpoint), 153 + client_id: Some(prepared.client_id), 154 + )) 155 + } 156 + } 157 + } 158 + } 159 + } 160 + 161 + /// Wait for the localhost OAuth callback; returns query string. 162 + pub fn wait_callback(timeout_ms: Int) -> Result(String, String) { 163 + case wait_http_callback(8095, timeout_ms) { 164 + Ok(qs) -> Ok(qs) 165 + Error(e) -> Error(e) 166 + } 167 + } 168 + 169 + /// Parse `code` and `state` from a raw query string. 170 + pub fn parse_callback_query(qs: String) -> Result(#(String, String), String) { 171 + let params = uri.parse_query(qs) |> result.unwrap([]) 172 + let code = list.key_find(params, "code") |> result.unwrap("") 173 + let state = list.key_find(params, "state") |> result.unwrap("") 174 + let err = list.key_find(params, "error") |> result.unwrap("") 175 + case err { 176 + "" -> 177 + case code { 178 + "" -> Error("callback missing authorization code") 179 + c -> Ok(#(c, state)) 180 + } 181 + e -> { 182 + let desc = list.key_find(params, "error_description") |> result.unwrap("") 183 + Error(case desc { 184 + "" -> "OAuth error: " <> e 185 + d -> "OAuth error: " <> e <> " — " <> d 186 + }) 187 + } 188 + } 189 + } 190 + 191 + /// Refresh a DPoP-bound access token. 192 + pub fn refresh(session: OAuthSession) -> Result(OAuthSession, String) { 193 + let rt = option_str(session.refresh_token) 194 + let te = option_str(session.token_endpoint) 195 + let cid = option_str(session.client_id) 196 + case rt == "" || te == "" || cid == "" { 197 + True -> Error("missing refresh credentials") 198 + False -> { 199 + let params = [ 200 + #("grant_type", "refresh_token"), 201 + #("refresh_token", rt), 202 + #("client_id", cid), 203 + ] 204 + let dpop_proof = dpop.proof(session.dpop_key, "POST", te, None, None) 205 + case post_form(te, params, [#("dpop", dpop_proof)]) { 206 + Ok(resp) if resp.status == 200 -> apply_token_response(session, resp) 207 + Ok(resp) if resp.status == 400 || resp.status == 401 -> 208 + case header_value(resp.headers, "dpop-nonce") { 209 + None -> 210 + Error( 211 + "OAuth refresh failed (" 212 + <> int.to_string(resp.status) 213 + <> "): " 214 + <> trunc(resp.body), 215 + ) 216 + Some(nonce) -> { 217 + let dpop2 = 218 + dpop.proof(session.dpop_key, "POST", te, Some(nonce), None) 219 + case post_form(te, params, [#("dpop", dpop2)]) { 220 + Ok(resp2) if resp2.status == 200 -> 221 + apply_token_response(session, resp2) 222 + Ok(resp2) -> 223 + Error( 224 + "OAuth refresh failed (" 225 + <> int.to_string(resp2.status) 226 + <> "): " 227 + <> trunc(resp2.body), 228 + ) 229 + Error(e) -> Error(e) 230 + } 231 + } 232 + } 233 + Ok(resp) -> 234 + Error( 235 + "OAuth refresh failed (" 236 + <> int.to_string(resp.status) 237 + <> "): " 238 + <> trunc(resp.body), 239 + ) 240 + Error(e) -> Error(e) 241 + } 242 + } 243 + } 244 + } 245 + 246 + fn apply_token_response( 247 + session: OAuthSession, 248 + resp: response.Response(String), 249 + ) -> Result(OAuthSession, String) { 250 + case 251 + json.parse(resp.body, { 252 + use access <- decode.optional_field("access_token", "", decode.string) 253 + use refresh_tok <- decode.optional_field( 254 + "refresh_token", 255 + "", 256 + decode.string, 257 + ) 258 + decode.success(#(access, refresh_tok)) 259 + }) 260 + { 261 + Error(_) -> Error("bad token response") 262 + Ok(#(access, refresh_tok)) -> { 263 + let access = case access { 264 + "" -> session.access_token 265 + a -> a 266 + } 267 + let refresh_opt = case refresh_tok { 268 + "" -> session.refresh_token 269 + r -> Some(r) 270 + } 271 + let dpop_nonce = case header_value(resp.headers, "dpop-nonce") { 272 + Some(n) -> Some(n) 273 + None -> probe_dpop_nonce(session.pds_url, access, session.dpop_key) 274 + } 275 + Ok( 276 + OAuthSession( 277 + ..session, 278 + access_token: access, 279 + refresh_token: refresh_opt, 280 + dpop_nonce:, 281 + ), 282 + ) 283 + } 284 + } 285 + } 286 + 287 + // ── Discovery / PAR / token ──────────────────────────────────────────────── 288 + 289 + fn discover_auth_server( 290 + pds_url: String, 291 + ) -> Result(List(#(String, String)), String) { 292 + let pr_url = trim_slash(pds_url) <> "/.well-known/oauth-protected-resource" 293 + use pr_body <- result.try(get_text(pr_url)) 294 + case auth_servers_from(pr_body) { 295 + [] -> Error("No authorization servers listed for PDS") 296 + [auth_server, ..] -> { 297 + let as_url = 298 + trim_slash(auth_server) <> "/.well-known/oauth-authorization-server" 299 + fetch_json_map(as_url) 300 + } 301 + } 302 + } 303 + 304 + fn auth_servers_from(raw: String) -> List(String) { 305 + case 306 + json.parse(raw, { 307 + use servers <- decode.optional_field( 308 + "authorization_servers", 309 + [], 310 + decode.list(decode.string), 311 + ) 312 + decode.success(servers) 313 + }) 314 + { 315 + Ok(list) -> list 316 + Error(_) -> [] 317 + } 318 + } 319 + 320 + fn generate_pkce() -> #(String, String) { 321 + let verifier = random_b64url(32) 322 + let challenge = 323 + crypto.hash(crypto.Sha256, <<verifier:utf8>>) 324 + |> bit_array.base64_url_encode(False) 325 + #(verifier, challenge) 326 + } 327 + 328 + fn push_authorization_request( 329 + par_endpoint: String, 330 + authorization_endpoint: String, 331 + client_id: String, 332 + redirect_uri: String, 333 + code_challenge: String, 334 + state: String, 335 + login_hint: String, 336 + key: DpopKey, 337 + ) -> Result(String, String) { 338 + let params = [ 339 + #("response_type", "code"), 340 + #("client_id", client_id), 341 + #("redirect_uri", redirect_uri), 342 + #("code_challenge", code_challenge), 343 + #("code_challenge_method", "S256"), 344 + #("scope", default_scope), 345 + #("state", state), 346 + #("login_hint", login_hint), 347 + ] 348 + let dpop_proof = dpop.proof(key, "POST", par_endpoint, None, None) 349 + case post_form(par_endpoint, params, [#("dpop", dpop_proof)]) { 350 + Ok(resp) if resp.status >= 200 && resp.status < 300 -> 351 + parse_par_response(resp.body, authorization_endpoint, client_id) 352 + Ok(resp) if resp.status == 400 || resp.status == 401 -> 353 + case header_value(resp.headers, "dpop-nonce") { 354 + None -> 355 + Error( 356 + "PAR failed (" 357 + <> int.to_string(resp.status) 358 + <> "): " 359 + <> trunc(resp.body), 360 + ) 361 + Some(nonce) -> { 362 + let dpop2 = dpop.proof(key, "POST", par_endpoint, Some(nonce), None) 363 + case post_form(par_endpoint, params, [#("dpop", dpop2)]) { 364 + Ok(resp2) if resp2.status >= 200 && resp2.status < 300 -> 365 + parse_par_response(resp2.body, authorization_endpoint, client_id) 366 + Ok(resp2) -> 367 + Error( 368 + "PAR failed (" 369 + <> int.to_string(resp2.status) 370 + <> "): " 371 + <> trunc(resp2.body), 372 + ) 373 + Error(e) -> Error(e) 374 + } 375 + } 376 + } 377 + Ok(resp) -> 378 + Error( 379 + "PAR failed (" <> int.to_string(resp.status) <> "): " <> trunc(resp.body), 380 + ) 381 + Error(e) -> Error(e) 382 + } 383 + } 384 + 385 + fn parse_par_response( 386 + body: String, 387 + authorization_endpoint: String, 388 + client_id: String, 389 + ) -> Result(String, String) { 390 + case 391 + json.parse(body, { 392 + use request_uri <- decode.optional_field("request_uri", "", decode.string) 393 + decode.success(request_uri) 394 + }) 395 + { 396 + Ok(request_uri) if request_uri != "" -> { 397 + let auth_url = 398 + authorization_endpoint 399 + <> "?client_id=" 400 + <> urlencode(client_id) 401 + <> "&request_uri=" 402 + <> urlencode(request_uri) 403 + Ok(auth_url) 404 + } 405 + _ -> Error("No request_uri in PAR response") 406 + } 407 + } 408 + 409 + type TokenResult { 410 + TokenResult( 411 + access_token: String, 412 + refresh_token: Option(String), 413 + sub: Option(String), 414 + ) 415 + } 416 + 417 + fn exchange_code( 418 + token_endpoint: String, 419 + code: String, 420 + code_verifier: String, 421 + redirect_uri: String, 422 + client_id: String, 423 + key: DpopKey, 424 + ) -> Result(TokenResult, String) { 425 + let params = [ 426 + #("grant_type", "authorization_code"), 427 + #("code", code), 428 + #("redirect_uri", redirect_uri), 429 + #("client_id", client_id), 430 + #("code_verifier", code_verifier), 431 + ] 432 + let dpop_proof = dpop.proof(key, "POST", token_endpoint, None, None) 433 + case post_form(token_endpoint, params, [#("dpop", dpop_proof)]) { 434 + Ok(resp) if resp.status >= 200 && resp.status < 300 -> 435 + parse_token_response(resp.body) 436 + Ok(resp) if resp.status == 400 || resp.status == 401 -> 437 + case header_value(resp.headers, "dpop-nonce") { 438 + None -> 439 + Error( 440 + "Token exchange failed (" 441 + <> int.to_string(resp.status) 442 + <> "): " 443 + <> trunc(resp.body), 444 + ) 445 + Some(nonce) -> { 446 + let dpop2 = 447 + dpop.proof(key, "POST", token_endpoint, Some(nonce), None) 448 + case post_form(token_endpoint, params, [#("dpop", dpop2)]) { 449 + Ok(resp2) if resp2.status >= 200 && resp2.status < 300 -> 450 + parse_token_response(resp2.body) 451 + Ok(resp2) -> 452 + Error( 453 + "Token exchange failed (" 454 + <> int.to_string(resp2.status) 455 + <> "): " 456 + <> trunc(resp2.body), 457 + ) 458 + Error(e) -> Error(e) 459 + } 460 + } 461 + } 462 + Ok(resp) -> 463 + Error( 464 + "Token exchange failed (" 465 + <> int.to_string(resp.status) 466 + <> "): " 467 + <> trunc(resp.body), 468 + ) 469 + Error(e) -> Error(e) 470 + } 471 + } 472 + 473 + fn parse_token_response(body: String) -> Result(TokenResult, String) { 474 + case 475 + json.parse(body, { 476 + use access_token <- decode.field("access_token", decode.string) 477 + use refresh_token <- decode.optional_field( 478 + "refresh_token", 479 + "", 480 + decode.string, 481 + ) 482 + use sub <- decode.optional_field("sub", "", decode.string) 483 + decode.success(#(access_token, refresh_token, sub)) 484 + }) 485 + { 486 + Ok(#(access, refresh, sub)) -> 487 + Ok(TokenResult( 488 + access_token: access, 489 + refresh_token: case refresh { 490 + "" -> None 491 + r -> Some(r) 492 + }, 493 + sub: case sub { 494 + "" -> None 495 + s -> Some(s) 496 + }, 497 + )) 498 + Error(_) -> Error("bad token response") 499 + } 500 + } 501 + 502 + fn probe_dpop_nonce( 503 + pds_url: String, 504 + access_token: String, 505 + key: DpopKey, 506 + ) -> Option(String) { 507 + let url = trim_slash(pds_url) <> "/xrpc/com.atproto.server.getSession" 508 + let proof = dpop.proof(key, "GET", url, None, Some(access_token)) 509 + case 510 + get_with_headers(url, [ 511 + #("authorization", "DPoP " <> access_token), 512 + #("dpop", proof), 513 + ]) 514 + { 515 + Ok(resp) -> header_value(resp.headers, "dpop-nonce") 516 + Error(_) -> None 517 + } 518 + } 519 + 520 + // ── HTTP / util ──────────────────────────────────────────────────────────── 521 + 522 + fn localhost_client_id(redirect_uri: String) -> String { 523 + "http://localhost?redirect_uri=" 524 + <> urlencode(redirect_uri) 525 + <> "&scope=" 526 + <> urlencode(default_scope) 527 + } 528 + 529 + fn drop_at(s: String) -> String { 530 + case string.starts_with(s, "@") { 531 + True -> string.drop_start(s, 1) 532 + False -> s 533 + } 534 + } 535 + 536 + fn map_get(map: List(#(String, String)), key: String) -> Result(String, String) { 537 + case list.key_find(map, key) { 538 + Ok(v) if v != "" -> Ok(v) 539 + _ -> Error("missing " <> key) 540 + } 541 + } 542 + 543 + fn fetch_json_map(url: String) -> Result(List(#(String, String)), String) { 544 + use body <- result.try(get_text(url)) 545 + let keys = [ 546 + "issuer", 547 + "authorization_endpoint", 548 + "token_endpoint", 549 + "pushed_authorization_request_endpoint", 550 + ] 551 + let pairs = 552 + list.filter_map(keys, fn(key) { 553 + case 554 + json.parse(body, { 555 + use v <- decode.optional_field(key, "", decode.string) 556 + decode.success(v) 557 + }) 558 + { 559 + Ok(v) if v != "" -> Ok(#(key, v)) 560 + _ -> Error(Nil) 561 + } 562 + }) 563 + case pairs { 564 + [] -> Error("empty oauth authorization server metadata") 565 + _ -> Ok(pairs) 566 + } 567 + } 568 + 569 + fn get_text(url: String) -> Result(String, String) { 570 + case request.to(url) { 571 + Error(_) -> Error("bad_url: " <> url) 572 + Ok(req) -> { 573 + let req = 574 + req 575 + |> request.set_method(http.Get) 576 + |> request.set_header("accept", "application/json") 577 + |> request.set_header("user-agent", "gtg") 578 + case httpc.send(req) { 579 + Ok(resp) if resp.status >= 200 && resp.status < 300 -> Ok(resp.body) 580 + Ok(resp) -> 581 + Error( 582 + "GET " <> url <> " failed (" <> int.to_string(resp.status) <> ")", 583 + ) 584 + Error(_) -> Error("GET " <> url <> " failed") 585 + } 586 + } 587 + } 588 + } 589 + 590 + fn get_with_headers( 591 + url: String, 592 + headers: List(#(String, String)), 593 + ) -> Result(response.Response(String), String) { 594 + case request.to(url) { 595 + Error(_) -> Error("bad_url") 596 + Ok(req) -> { 597 + let req = 598 + list.fold(headers, request.set_method(req, http.Get), fn(r, h) { 599 + request.set_header(r, h.0, h.1) 600 + }) 601 + |> request.set_header("user-agent", "gtg") 602 + case httpc.send(req) { 603 + Ok(resp) -> Ok(resp) 604 + Error(_) -> Error("request_failed") 605 + } 606 + } 607 + } 608 + } 609 + 610 + fn post_form( 611 + url: String, 612 + params: List(#(String, String)), 613 + headers: List(#(String, String)), 614 + ) -> Result(response.Response(String), String) { 615 + case request.to(url) { 616 + Error(_) -> Error("bad_url") 617 + Ok(req) -> { 618 + let body = 619 + params 620 + |> list.map(fn(p) { urlencode(p.0) <> "=" <> urlencode(p.1) }) 621 + |> string.join("&") 622 + let req = 623 + req 624 + |> request.set_method(http.Post) 625 + |> request.set_header( 626 + "content-type", 627 + "application/x-www-form-urlencoded", 628 + ) 629 + |> request.set_header("user-agent", "gtg") 630 + |> request.set_body(body) 631 + let req = 632 + list.fold(headers, req, fn(r, h) { request.set_header(r, h.0, h.1) }) 633 + case httpc.send(req) { 634 + Ok(resp) -> Ok(resp) 635 + Error(_) -> Error("post_failed") 636 + } 637 + } 638 + } 639 + } 640 + 641 + pub fn header_value( 642 + headers: List(#(String, String)), 643 + name: String, 644 + ) -> Option(String) { 645 + let name_down = string.lowercase(name) 646 + case 647 + list.find_map(headers, fn(h) { 648 + case string.lowercase(h.0) == name_down { 649 + True if h.1 != "" -> Ok(h.1) 650 + _ -> Error(Nil) 651 + } 652 + }) 653 + { 654 + Ok(v) -> Some(v) 655 + Error(_) -> None 656 + } 657 + } 658 + 659 + fn option_str(opt: Option(String)) -> String { 660 + case opt { 661 + Some(s) -> s 662 + None -> "" 663 + } 664 + } 665 + 666 + fn trim_slash(s: String) -> String { 667 + case string.ends_with(s, "/") { 668 + True -> string.drop_end(s, 1) 669 + False -> s 670 + } 671 + } 672 + 673 + fn urlencode(s: String) -> String { 674 + uri.percent_encode(s) 675 + } 676 + 677 + fn trunc(body: String) -> String { 678 + case string.length(body) > 200 { 679 + True -> string.slice(body, 0, 200) 680 + False -> body 681 + } 682 + } 683 + 684 + fn random_b64url(n: Int) -> String { 685 + crypto.strong_random_bytes(n) 686 + |> bit_array.base64_url_encode(False) 687 + } 688 + 689 + @external(erlang, "gtg_ffi", "wait_http_callback") 690 + fn wait_http_callback(port: Int, timeout_ms: Int) -> Result(String, String)
+375
src/gtg/atproto/pds.gleam
··· 1 + //// Authenticated/public PDS repo operations. 2 + 3 + import gleam/dynamic.{type Dynamic} 4 + import gleam/dynamic/decode 5 + import gleam/int 6 + import gleam/json 7 + import gleam/list 8 + import gleam/option.{type Option, None, Some} 9 + import gleam/result 10 + import gleam/string 11 + import gtg/atproto/client.{type Client} 12 + import gtg/atproto/xrpc 13 + 14 + pub type Blob { 15 + Blob(type_: String, ref: json.Json, mime_type: String, size: Int) 16 + } 17 + 18 + pub type RecordItem { 19 + RecordItem(uri: String, cid: String, value: Dynamic) 20 + } 21 + 22 + pub type GetRecord { 23 + GetRecord(uri: String, cid: String, value: Dynamic) 24 + } 25 + 26 + pub type PdsError { 27 + Failed(String) 28 + } 29 + 30 + pub fn error_message(err: PdsError) -> String { 31 + case err { 32 + Failed(m) -> m 33 + } 34 + } 35 + 36 + pub fn put_record( 37 + sess: Client, 38 + collection: String, 39 + rkey: String, 40 + record: json.Json, 41 + ) -> Result(#(String, String), PdsError) { 42 + let body = 43 + json.object([ 44 + #("repo", json.string(sess.did)), 45 + #("collection", json.string(collection)), 46 + #("rkey", json.string(rkey)), 47 + #("record", record), 48 + ]) 49 + case client.post_json(sess, "com.atproto.repo.putRecord", body) { 50 + Error(e) -> Error(Failed(xrpc.error_message(e))) 51 + Ok(resp) -> { 52 + let decoder = { 53 + use uri <- decode.field("uri", decode.string) 54 + use cid <- decode.optional_field("cid", "", decode.string) 55 + decode.success(#(uri, cid)) 56 + } 57 + case json.parse(resp, decoder) { 58 + Ok(pair) -> Ok(pair) 59 + Error(_) -> Error(Failed("decode putRecord response")) 60 + } 61 + } 62 + } 63 + } 64 + 65 + pub fn delete_record( 66 + sess: Client, 67 + collection: String, 68 + rkey: String, 69 + ) -> Result(Nil, PdsError) { 70 + let body = 71 + json.object([ 72 + #("repo", json.string(sess.did)), 73 + #("collection", json.string(collection)), 74 + #("rkey", json.string(rkey)), 75 + ]) 76 + case client.post_json(sess, "com.atproto.repo.deleteRecord", body) { 77 + Error(e) -> Error(Failed(xrpc.error_message(e))) 78 + Ok(_) -> Ok(Nil) 79 + } 80 + } 81 + 82 + pub fn get_record( 83 + pds: String, 84 + auth: xrpc.Auth, 85 + repo: String, 86 + collection: String, 87 + rkey: String, 88 + ) -> Result(GetRecord, PdsError) { 89 + case 90 + xrpc.get( 91 + pds, 92 + "com.atproto.repo.getRecord", 93 + [ 94 + #("repo", repo), 95 + #("collection", collection), 96 + #("rkey", rkey), 97 + ], 98 + auth, 99 + ) 100 + { 101 + Error(e) -> Error(Failed(xrpc.error_message(e))) 102 + Ok(resp) -> decode_get_record(resp) 103 + } 104 + } 105 + 106 + /// Authenticated getRecord (Bearer or DPoP via Client). 107 + pub fn get_record_authed( 108 + sess: Client, 109 + repo: String, 110 + collection: String, 111 + rkey: String, 112 + ) -> Result(GetRecord, PdsError) { 113 + case 114 + client.get(sess, "com.atproto.repo.getRecord", [ 115 + #("repo", repo), 116 + #("collection", collection), 117 + #("rkey", rkey), 118 + ]) 119 + { 120 + Error(e) -> Error(Failed(xrpc.error_message(e))) 121 + Ok(resp) -> decode_get_record(resp) 122 + } 123 + } 124 + 125 + fn decode_get_record(resp: String) -> Result(GetRecord, PdsError) { 126 + let decoder = { 127 + use uri <- decode.field("uri", decode.string) 128 + use cid <- decode.optional_field("cid", "", decode.string) 129 + use value <- decode.field("value", decode.dynamic) 130 + decode.success(GetRecord(uri:, cid:, value:)) 131 + } 132 + case json.parse(resp, decoder) { 133 + Ok(r) -> Ok(r) 134 + Error(_) -> Error(Failed("decode getRecord")) 135 + } 136 + } 137 + 138 + pub fn list_records( 139 + pds: String, 140 + auth: xrpc.Auth, 141 + repo: String, 142 + collection: String, 143 + limit: Int, 144 + cursor: Option(String), 145 + ) -> Result(#(List(RecordItem), Option(String)), PdsError) { 146 + let params = [ 147 + #("repo", repo), 148 + #("collection", collection), 149 + #("limit", int.to_string(limit)), 150 + ] 151 + let params = case cursor { 152 + Some(c) if c != "" -> list.append(params, [#("cursor", c)]) 153 + _ -> params 154 + } 155 + case xrpc.get(pds, "com.atproto.repo.listRecords", params, auth) { 156 + Error(e) -> Error(Failed(xrpc.error_message(e))) 157 + Ok(resp) -> { 158 + let item_decoder = { 159 + use uri <- decode.field("uri", decode.string) 160 + use cid <- decode.optional_field("cid", "", decode.string) 161 + use value <- decode.field("value", decode.dynamic) 162 + decode.success(RecordItem(uri:, cid:, value:)) 163 + } 164 + let decoder = { 165 + use records <- decode.field("records", decode.list(item_decoder)) 166 + use cursor <- decode.optional_field( 167 + "cursor", 168 + None, 169 + decode.optional(decode.string), 170 + ) 171 + decode.success(#(records, cursor)) 172 + } 173 + case json.parse(resp, decoder) { 174 + Ok(page) -> Ok(page) 175 + Error(_) -> Error(Failed("decode listRecords")) 176 + } 177 + } 178 + } 179 + } 180 + 181 + pub fn list_all_records( 182 + pds: String, 183 + auth: xrpc.Auth, 184 + repo: String, 185 + collection: String, 186 + ) -> Result(List(RecordItem), PdsError) { 187 + list_all_loop(pds, auth, repo, collection, None, [], 0) 188 + } 189 + 190 + fn list_all_loop( 191 + pds: String, 192 + auth: xrpc.Auth, 193 + repo: String, 194 + collection: String, 195 + cursor: Option(String), 196 + acc: List(RecordItem), 197 + page: Int, 198 + ) -> Result(List(RecordItem), PdsError) { 199 + case page >= 1000 { 200 + True -> Error(Failed("exceeded max listRecords pages")) 201 + False -> { 202 + use #(items, next) <- result.try(list_records( 203 + pds, 204 + auth, 205 + repo, 206 + collection, 207 + 100, 208 + cursor, 209 + )) 210 + let acc = list.append(acc, items) 211 + case next { 212 + Some(c) if c != "" -> 213 + list_all_loop(pds, auth, repo, collection, Some(c), acc, page + 1) 214 + _ -> Ok(acc) 215 + } 216 + } 217 + } 218 + } 219 + 220 + pub fn upload_blob( 221 + sess: Client, 222 + data: BitArray, 223 + mime_type: String, 224 + ) -> Result(Blob, PdsError) { 225 + case client.post_bytes(sess, "com.atproto.repo.uploadBlob", mime_type, data) { 226 + Error(e) -> Error(Failed(xrpc.error_message(e))) 227 + Ok(resp) -> { 228 + // blob is nested under "blob" 229 + let decoder = { 230 + use blob <- decode.field("blob", { 231 + use type_ <- decode.optional_field("$type", "blob", decode.string) 232 + use ref <- decode.field("ref", decode.dynamic) 233 + use mime_type <- decode.field("mimeType", decode.string) 234 + use size <- decode.field("size", decode.int) 235 + decode.success(#(type_, ref, mime_type, size)) 236 + }) 237 + decode.success(blob) 238 + } 239 + case json.parse(resp, decoder) { 240 + Ok(#(type_, ref_dyn, mime_type, size)) -> { 241 + // Re-encode ref as JSON via string round-trip of whole blob field 242 + case extract_blob_json(resp) { 243 + Ok(blob_json) -> 244 + Ok(Blob(type_:, ref: blob_json, mime_type:, size:)) 245 + Error(_) -> 246 + // Fallback: try to use cid string if present 247 + Ok(Blob( 248 + type_:, 249 + ref: json.object([#("$link", json.string(string.inspect(ref_dyn)))]), 250 + mime_type:, 251 + size:, 252 + )) 253 + } 254 + } 255 + Error(_) -> Error(Failed("decode uploadBlob")) 256 + } 257 + } 258 + } 259 + } 260 + 261 + fn extract_blob_json(resp: String) -> Result(json.Json, Nil) { 262 + // Parse whole response and re-serialize the blob object for embedding 263 + let decoder = { 264 + use blob <- decode.field("blob", decode.dynamic) 265 + decode.success(blob) 266 + } 267 + case json.parse(resp, decoder) { 268 + Ok(_dyn) -> { 269 + // Pull the blob object substring roughly via re-encoding: use full response 270 + // Better approach: decode known fields 271 + let full = { 272 + use type_ <- decode.optional_field( 273 + "$type", 274 + "blob", 275 + decode.string, 276 + ) 277 + use link <- decode.field("ref", { 278 + use link <- decode.field("$link", decode.string) 279 + decode.success(link) 280 + }) 281 + use mime_type <- decode.field("mimeType", decode.string) 282 + use size <- decode.field("size", decode.int) 283 + decode.success(#(type_, link, mime_type, size)) 284 + } 285 + // Nested under blob 286 + let nested = { 287 + use b <- decode.field("blob", full) 288 + decode.success(b) 289 + } 290 + case json.parse(resp, nested) { 291 + Ok(#(type_, link, mime_type, size)) -> 292 + Ok( 293 + json.object([ 294 + #("$type", json.string(type_)), 295 + #("ref", json.object([#("$link", json.string(link))])), 296 + #("mimeType", json.string(mime_type)), 297 + #("size", json.int(size)), 298 + ]), 299 + ) 300 + Error(_) -> Error(Nil) 301 + } 302 + } 303 + Error(_) -> Error(Nil) 304 + } 305 + } 306 + 307 + pub fn get_service_auth( 308 + sess: Client, 309 + audience: String, 310 + lxm: String, 311 + ) -> Result(String, PdsError) { 312 + let exp = gtg_sys_unix() + 60 313 + case 314 + client.get(sess, "com.atproto.server.getServiceAuth", [ 315 + #("aud", audience), 316 + #("exp", int.to_string(exp)), 317 + #("lxm", lxm), 318 + ]) 319 + { 320 + Error(e) -> Error(Failed(xrpc.error_message(e))) 321 + Ok(resp) -> 322 + case xrpc.decode_string_field(resp, "token") { 323 + Ok(t) if t != "" -> Ok(t) 324 + Ok(_) -> Error(Failed("empty service auth token")) 325 + Error(e) -> Error(Failed(xrpc.error_message(e))) 326 + } 327 + } 328 + } 329 + 330 + fn gtg_sys_unix() -> Int { 331 + // Importing sys here would cycle; use external 332 + unix_seconds() 333 + } 334 + 335 + @external(erlang, "gtg_ffi", "unix_seconds") 336 + fn unix_seconds() -> Int 337 + 338 + /// Embed an uploaded blob in a record (full lexicon blob object). 339 + pub fn embed_blob(blob: Blob) -> json.Json { 340 + blob.ref 341 + } 342 + 343 + pub fn download_blob( 344 + pds: String, 345 + did: String, 346 + cid: String, 347 + ) -> Result(BitArray, PdsError) { 348 + case 349 + xrpc.get_bytes( 350 + pds, 351 + "com.atproto.sync.getBlob", 352 + [#("did", did), #("cid", cid)], 353 + xrpc.NoAuth, 354 + ) 355 + { 356 + Error(e) -> Error(Failed(xrpc.error_message(e))) 357 + Ok(bits) -> Ok(bits) 358 + } 359 + } 360 + 361 + /// Convert a Dynamic JSON object to a plain string map of string fields (shallow). 362 + pub fn dynamic_string_field(value: Dynamic, field: String) -> Option(String) { 363 + let decoder = { 364 + use v <- decode.field(field, decode.string) 365 + decode.success(v) 366 + } 367 + case decode.run(value, decoder) { 368 + Ok(s) -> Some(s) 369 + Error(_) -> None 370 + } 371 + } 372 + 373 + pub fn dynamic_optional_string(value: Dynamic, field: String) -> String { 374 + dynamic_string_field(value, field) |> option.unwrap("") 375 + }
+217
src/gtg/atproto/resolve.gleam
··· 1 + //// Handle ↔ DID resolution and PDS discovery. 2 + 3 + import gleam/bit_array 4 + import gleam/dynamic/decode 5 + import gleam/http 6 + import gleam/http/request 7 + import gleam/httpc 8 + import gleam/json 9 + import gleam/list 10 + import gleam/result 11 + import gleam/string 12 + import gtg/atproto/xrpc 13 + 14 + pub type Identity { 15 + Identity(did: String, handle: String, pds: String) 16 + } 17 + 18 + pub type ResolveError { 19 + ResolveFailed(String) 20 + } 21 + 22 + /// Resolve a handle or DID into an Identity with PDS host. 23 + pub fn resolve(identifier: String) -> Result(Identity, ResolveError) { 24 + let id = string.trim(identifier) |> drop_at 25 + case string.starts_with(id, "did:") { 26 + True -> resolve_did(id) 27 + False -> { 28 + use did <- result.try(resolve_handle(id)) 29 + use ident <- result.try(resolve_did(did)) 30 + Ok(Identity(did: ident.did, handle: id, pds: ident.pds)) 31 + } 32 + } 33 + } 34 + 35 + pub fn resolve_handle(handle: String) -> Result(String, ResolveError) { 36 + let handle = string.trim(handle) |> drop_at |> string.lowercase 37 + case resolve_handle_wellknown(handle) { 38 + Ok(did) -> Ok(did) 39 + Error(_) -> resolve_handle_api(handle) 40 + } 41 + } 42 + 43 + fn resolve_handle_wellknown(handle: String) -> Result(String, ResolveError) { 44 + let url = "https://" <> handle <> "/.well-known/atproto-did" 45 + case request.to(url) { 46 + Error(_) -> Error(ResolveFailed("invalid well-known URL")) 47 + Ok(req) -> { 48 + let req = 49 + req 50 + |> request.set_method(http.Get) 51 + |> request.set_header("user-agent", "gtg") 52 + |> request.set_body(<<>>) 53 + case httpc.send_bits(req) { 54 + Ok(resp) if resp.status >= 200 && resp.status < 300 -> { 55 + case bit_array_to_string(resp.body) { 56 + Ok(body) -> { 57 + let did = string.trim(body) 58 + case string.starts_with(did, "did:") { 59 + True -> Ok(did) 60 + False -> Error(ResolveFailed("invalid well-known DID body")) 61 + } 62 + } 63 + Error(_) -> Error(ResolveFailed("well-known body not UTF-8")) 64 + } 65 + } 66 + _ -> Error(ResolveFailed("well-known lookup failed")) 67 + } 68 + } 69 + } 70 + } 71 + 72 + fn resolve_handle_api(handle: String) -> Result(String, ResolveError) { 73 + // Public Bluesky endpoint resolves any PLC handle. 74 + case 75 + xrpc.get( 76 + "https://public.api.bsky.app", 77 + "com.atproto.identity.resolveHandle", 78 + [#("handle", handle)], 79 + xrpc.NoAuth, 80 + ) 81 + { 82 + Ok(body) -> 83 + case xrpc.decode_string_field(body, "did") { 84 + Ok(did) -> Ok(did) 85 + Error(e) -> Error(ResolveFailed(xrpc.error_message(e))) 86 + } 87 + Error(e) -> Error(ResolveFailed(xrpc.error_message(e))) 88 + } 89 + } 90 + 91 + pub fn resolve_did(did: String) -> Result(Identity, ResolveError) { 92 + use doc <- result.try(fetch_did_document(did)) 93 + use pds <- result.try(pds_from_document(doc)) 94 + let handle = handle_from_document(doc) |> result.unwrap(did) 95 + Ok(Identity(did:, handle:, pds:)) 96 + } 97 + 98 + pub fn resolve_pds(did: String) -> Result(String, ResolveError) { 99 + use ident <- result.try(resolve_did(did)) 100 + Ok(ident.pds) 101 + } 102 + 103 + fn fetch_did_document(did: String) -> Result(String, ResolveError) { 104 + case string.starts_with(did, "did:plc:") { 105 + True -> { 106 + let url = "https://plc.directory/" <> did 107 + http_get_text(url) 108 + } 109 + False -> 110 + case string.starts_with(did, "did:web:") { 111 + True -> { 112 + let host = string.drop_start(did, 8) |> string.replace(":", "/") 113 + let url = "https://" <> host <> "/.well-known/did.json" 114 + http_get_text(url) 115 + } 116 + False -> Error(ResolveFailed("unsupported DID method: " <> did)) 117 + } 118 + } 119 + } 120 + 121 + fn http_get_text(url: String) -> Result(String, ResolveError) { 122 + case request.to(url) { 123 + Error(_) -> Error(ResolveFailed("invalid URL: " <> url)) 124 + Ok(req) -> { 125 + let req = 126 + req 127 + |> request.set_method(http.Get) 128 + |> request.set_header("user-agent", "gtg") 129 + |> request.set_header("accept", "application/json") 130 + |> request.set_body(<<>>) 131 + case httpc.send_bits(req) { 132 + Error(e) -> Error(ResolveFailed(string.inspect(e))) 133 + Ok(resp) -> 134 + case resp.status >= 200 && resp.status < 300 { 135 + True -> 136 + case bit_array_to_string(resp.body) { 137 + Ok(s) -> Ok(s) 138 + Error(_) -> Error(ResolveFailed("DID document not UTF-8")) 139 + } 140 + False -> 141 + Error(ResolveFailed( 142 + "DID document HTTP " <> string.inspect(resp.status), 143 + )) 144 + } 145 + } 146 + } 147 + } 148 + } 149 + 150 + type Service { 151 + Service(type_: String, service_endpoint: String) 152 + } 153 + 154 + fn pds_from_document(doc: String) -> Result(String, ResolveError) { 155 + let service_decoder = { 156 + use type_ <- decode.field("type", decode.string) 157 + use service_endpoint <- decode.field("serviceEndpoint", decode.string) 158 + decode.success(Service(type_:, service_endpoint:)) 159 + } 160 + let decoder = { 161 + use services <- decode.field("service", decode.list(service_decoder)) 162 + decode.success(services) 163 + } 164 + case json.parse(doc, decoder) { 165 + Ok(services) -> { 166 + case 167 + list.find(services, fn(s) { s.type_ == "AtprotoPersonalDataServer" }) 168 + { 169 + Ok(s) -> Ok(trim_slash(s.service_endpoint)) 170 + Error(_) -> 171 + Error(ResolveFailed("no AtprotoPersonalDataServer in DID document")) 172 + } 173 + } 174 + Error(_) -> Error(ResolveFailed("decode DID document service list")) 175 + } 176 + } 177 + 178 + fn handle_from_document(doc: String) -> Result(String, Nil) { 179 + let decoder = { 180 + use also <- decode.optional_field( 181 + "alsoKnownAs", 182 + [], 183 + decode.list(decode.string), 184 + ) 185 + decode.success(also) 186 + } 187 + case json.parse(doc, decoder) { 188 + Ok(akas) -> { 189 + case list.find(akas, fn(a) { string.starts_with(a, "at://") }) { 190 + Ok(aka) -> Ok(string.drop_start(aka, 5)) 191 + Error(_) -> Error(Nil) 192 + } 193 + } 194 + Error(_) -> Error(Nil) 195 + } 196 + } 197 + 198 + fn drop_at(handle: String) -> String { 199 + case string.starts_with(handle, "@") { 200 + True -> string.drop_start(handle, 1) 201 + False -> handle 202 + } 203 + } 204 + 205 + fn trim_slash(host: String) -> String { 206 + case string.ends_with(host, "/") { 207 + True -> string.drop_end(host, 1) 208 + False -> host 209 + } 210 + } 211 + 212 + fn bit_array_to_string(bits: BitArray) -> Result(String, Nil) { 213 + case bit_array.to_string(bits) { 214 + Ok(s) -> Ok(s) 215 + Error(_) -> Error(Nil) 216 + } 217 + }
+407
src/gtg/atproto/session.gleam
··· 1 + //// File-backed multi-account session store ($XDG_DATA_HOME/gtg). 2 + 3 + import filepath 4 + import gleam/dynamic/decode 5 + import gleam/json 6 + import gleam/list 7 + import gleam/option.{type Option, None, Some} 8 + import gleam/result 9 + import gleam/string 10 + import gtg/sys 11 + import simplifile 12 + 13 + pub const auth_method_password = "password" 14 + 15 + pub const auth_method_oauth = "oauth" 16 + 17 + pub type Account { 18 + Account(did: String, handle: String, method: String) 19 + } 20 + 21 + pub type PasswordSession { 22 + PasswordSession( 23 + did: String, 24 + handle: String, 25 + access_jwt: String, 26 + refresh_jwt: String, 27 + pds: String, 28 + ) 29 + } 30 + 31 + pub type AccountIndex { 32 + AccountIndex(active_did: Option(String), accounts: List(Account)) 33 + } 34 + 35 + pub type StoreError { 36 + NotFound 37 + IoError(String) 38 + DecodeError(String) 39 + } 40 + 41 + fn index_path() -> String { 42 + filepath.join(sys.data_dir(), "accounts.json") 43 + } 44 + 45 + fn session_path(did: String) -> String { 46 + let safe = string.replace(did, ":", "_") 47 + filepath.join(filepath.join(sys.data_dir(), "sessions"), safe <> ".json") 48 + } 49 + 50 + pub fn load_index() -> Result(AccountIndex, StoreError) { 51 + case simplifile.read(index_path()) { 52 + Error(_) -> Ok(AccountIndex(active_did: None, accounts: [])) 53 + Ok(body) -> decode_index(body) 54 + } 55 + } 56 + 57 + fn decode_index(body: String) -> Result(AccountIndex, StoreError) { 58 + let account_decoder = { 59 + use did <- decode.field("did", decode.string) 60 + use handle <- decode.optional_field("handle", "", decode.string) 61 + use method <- decode.optional_field( 62 + "method", 63 + auth_method_password, 64 + decode.string, 65 + ) 66 + decode.success(Account(did:, handle:, method:)) 67 + } 68 + let decoder = { 69 + use active_did <- decode.optional_field( 70 + "activeDid", 71 + None, 72 + decode.optional(decode.string), 73 + ) 74 + use accounts <- decode.optional_field( 75 + "accounts", 76 + [], 77 + decode.list(account_decoder), 78 + ) 79 + decode.success(AccountIndex(active_did:, accounts:)) 80 + } 81 + case json.parse(body, decoder) { 82 + Ok(idx) -> Ok(idx) 83 + Error(_) -> Error(DecodeError("accounts index")) 84 + } 85 + } 86 + 87 + fn save_index(index: AccountIndex) -> Result(Nil, StoreError) { 88 + use _ <- result.try( 89 + sys.ensure_dir(sys.data_dir()) 90 + |> result.map_error(IoError), 91 + ) 92 + let active = 93 + case index.active_did { 94 + Some(d) -> json.string(d) 95 + None -> json.null() 96 + } 97 + let accounts = 98 + index.accounts 99 + |> list.map(fn(a) { 100 + json.object([ 101 + #("did", json.string(a.did)), 102 + #("handle", json.string(a.handle)), 103 + #("method", json.string(a.method)), 104 + ]) 105 + }) 106 + |> json.preprocessed_array 107 + let body = 108 + json.object([#("activeDid", active), #("accounts", accounts)]) 109 + |> json.to_string 110 + case simplifile.write(to: index_path(), contents: body) { 111 + Ok(_) -> Ok(Nil) 112 + Error(e) -> Error(IoError(string.inspect(e))) 113 + } 114 + } 115 + 116 + pub fn save_password_session(session: PasswordSession) -> Result(Nil, StoreError) { 117 + use _ <- result.try( 118 + sys.ensure_dir(filepath.join(sys.data_dir(), "sessions")) 119 + |> result.map_error(IoError), 120 + ) 121 + let body = 122 + json.object([ 123 + #("did", json.string(session.did)), 124 + #("handle", json.string(session.handle)), 125 + #("accessJwt", json.string(session.access_jwt)), 126 + #("refreshJwt", json.string(session.refresh_jwt)), 127 + #("pds", json.string(session.pds)), 128 + ]) 129 + |> json.to_string 130 + case simplifile.write(to: session_path(session.did), contents: body) { 131 + Ok(_) -> { 132 + use index <- result.try(load_index()) 133 + let account = 134 + Account( 135 + did: session.did, 136 + handle: session.handle, 137 + method: auth_method_password, 138 + ) 139 + let accounts = 140 + index.accounts 141 + |> list.filter(fn(a) { a.did != session.did }) 142 + |> list.prepend(account) 143 + save_index(AccountIndex( 144 + active_did: Some(session.did), 145 + accounts:, 146 + )) 147 + } 148 + Error(e) -> Error(IoError(string.inspect(e))) 149 + } 150 + } 151 + 152 + pub fn get_password_session(did: String) -> Result(PasswordSession, StoreError) { 153 + case simplifile.read(session_path(did)) { 154 + Error(_) -> Error(NotFound) 155 + Ok(body) -> { 156 + // Reject oauth sessions here 157 + case string.contains(body, "\"method\":\"oauth\"") { 158 + True -> Error(NotFound) 159 + False -> { 160 + let decoder = { 161 + use did <- decode.field("did", decode.string) 162 + use handle <- decode.optional_field("handle", "", decode.string) 163 + use access_jwt <- decode.field("accessJwt", decode.string) 164 + use refresh_jwt <- decode.field("refreshJwt", decode.string) 165 + use pds <- decode.field("pds", decode.string) 166 + decode.success(PasswordSession( 167 + did:, 168 + handle:, 169 + access_jwt:, 170 + refresh_jwt:, 171 + pds:, 172 + )) 173 + } 174 + case json.parse(body, decoder) { 175 + Ok(s) -> Ok(s) 176 + Error(_) -> Error(DecodeError("password session")) 177 + } 178 + } 179 + } 180 + } 181 + } 182 + } 183 + 184 + /// Persist an OAuth session (DPoP-bound tokens). 185 + pub fn save_oauth_session( 186 + did: String, 187 + handle: String, 188 + access_token: String, 189 + pds: String, 190 + dpop_key: String, 191 + dpop_nonce: Option(String), 192 + refresh_token: Option(String), 193 + token_endpoint: Option(String), 194 + client_id: Option(String), 195 + ) -> Result(Nil, StoreError) { 196 + use _ <- result.try( 197 + sys.ensure_dir(filepath.join(sys.data_dir(), "sessions")) 198 + |> result.map_error(IoError), 199 + ) 200 + let opt = fn(o: Option(String)) -> json.Json { 201 + case o { 202 + Some(s) -> json.string(s) 203 + None -> json.null() 204 + } 205 + } 206 + let body = 207 + json.object([ 208 + #("method", json.string(auth_method_oauth)), 209 + #("did", json.string(did)), 210 + #("handle", json.string(handle)), 211 + #("accessToken", json.string(access_token)), 212 + #("pds", json.string(pds)), 213 + #("dpopKey", json.string(dpop_key)), 214 + #("dpopNonce", opt(dpop_nonce)), 215 + #("refreshToken", opt(refresh_token)), 216 + #("tokenEndpoint", opt(token_endpoint)), 217 + #("clientId", opt(client_id)), 218 + ]) 219 + |> json.to_string 220 + case simplifile.write(to: session_path(did), contents: body) { 221 + Ok(_) -> { 222 + use index <- result.try(load_index()) 223 + let account = Account(did:, handle:, method: auth_method_oauth) 224 + let accounts = 225 + index.accounts 226 + |> list.filter(fn(a) { a.did != did }) 227 + |> list.prepend(account) 228 + save_index(AccountIndex(active_did: Some(did), accounts:)) 229 + } 230 + Error(e) -> Error(IoError(string.inspect(e))) 231 + } 232 + } 233 + 234 + pub type StoredOAuth { 235 + StoredOAuth( 236 + did: String, 237 + handle: String, 238 + access_token: String, 239 + pds: String, 240 + dpop_key: String, 241 + dpop_nonce: Option(String), 242 + refresh_token: Option(String), 243 + token_endpoint: Option(String), 244 + client_id: Option(String), 245 + ) 246 + } 247 + 248 + pub fn get_oauth_session(did: String) -> Result(StoredOAuth, StoreError) { 249 + case simplifile.read(session_path(did)) { 250 + Error(_) -> Error(NotFound) 251 + Ok(body) -> { 252 + let decoder = { 253 + use method <- decode.optional_field( 254 + "method", 255 + auth_method_password, 256 + decode.string, 257 + ) 258 + use did <- decode.field("did", decode.string) 259 + use handle <- decode.optional_field("handle", "", decode.string) 260 + use access_token <- decode.field("accessToken", decode.string) 261 + use pds <- decode.field("pds", decode.string) 262 + use dpop_key <- decode.field("dpopKey", decode.string) 263 + use dpop_nonce <- decode.optional_field( 264 + "dpopNonce", 265 + None, 266 + decode.optional(decode.string), 267 + ) 268 + use refresh_token <- decode.optional_field( 269 + "refreshToken", 270 + None, 271 + decode.optional(decode.string), 272 + ) 273 + use token_endpoint <- decode.optional_field( 274 + "tokenEndpoint", 275 + None, 276 + decode.optional(decode.string), 277 + ) 278 + use client_id <- decode.optional_field( 279 + "clientId", 280 + None, 281 + decode.optional(decode.string), 282 + ) 283 + decode.success(#( 284 + method, 285 + did, 286 + handle, 287 + access_token, 288 + pds, 289 + dpop_key, 290 + dpop_nonce, 291 + refresh_token, 292 + token_endpoint, 293 + client_id, 294 + )) 295 + } 296 + case json.parse(body, decoder) { 297 + Ok(#(method, did, handle, access_token, pds, dpop_key, dn, rt, te, cid)) -> 298 + case method == auth_method_oauth { 299 + True -> 300 + Ok(StoredOAuth( 301 + did:, 302 + handle:, 303 + access_token:, 304 + pds:, 305 + dpop_key:, 306 + dpop_nonce: flatten(dn), 307 + refresh_token: flatten(rt), 308 + token_endpoint: flatten(te), 309 + client_id: flatten(cid), 310 + )) 311 + False -> Error(NotFound) 312 + } 313 + Error(_) -> Error(DecodeError("oauth session")) 314 + } 315 + } 316 + } 317 + } 318 + 319 + fn flatten(opt: Option(String)) -> Option(String) { 320 + case opt { 321 + Some(s) if s != "" -> Some(s) 322 + _ -> None 323 + } 324 + } 325 + 326 + /// Select account by DID or handle (case-insensitive handle match). 327 + pub fn select_account(selector: String) -> Result(Account, StoreError) { 328 + use index <- result.try(load_index()) 329 + let selector = string.lowercase(string.trim(selector)) 330 + case 331 + list.find(index.accounts, fn(a) { 332 + string.lowercase(a.did) == selector 333 + || string.lowercase(a.handle) == selector 334 + }) 335 + { 336 + Error(_) -> Error(NotFound) 337 + Ok(account) -> { 338 + use _ <- result.try(save_index(AccountIndex( 339 + active_did: Some(account.did), 340 + accounts: index.accounts, 341 + ))) 342 + Ok(account) 343 + } 344 + } 345 + } 346 + 347 + pub fn active_account(selector: Option(String)) -> Result(Account, StoreError) { 348 + use index <- result.try(load_index()) 349 + case selector { 350 + Some(s) if s != "" -> { 351 + let s = string.lowercase(string.trim(s)) 352 + list.find(index.accounts, fn(a) { 353 + string.lowercase(a.did) == s || string.lowercase(a.handle) == s 354 + }) 355 + |> result.replace_error(NotFound) 356 + } 357 + _ -> { 358 + case index.active_did { 359 + Some(did) -> 360 + list.find(index.accounts, fn(a) { a.did == did }) 361 + |> result.replace_error(NotFound) 362 + None -> 363 + case index.accounts { 364 + [first, ..] -> Ok(first) 365 + [] -> Error(NotFound) 366 + } 367 + } 368 + } 369 + } 370 + } 371 + 372 + pub fn list_accounts() -> Result(#(List(Account), Option(String)), StoreError) { 373 + use index <- result.try(load_index()) 374 + Ok(#(index.accounts, index.active_did)) 375 + } 376 + 377 + pub fn delete_account(did: String) -> Result(Nil, StoreError) { 378 + let _ = simplifile.delete(session_path(did)) 379 + use index <- result.try(load_index()) 380 + let accounts = list.filter(index.accounts, fn(a) { a.did != did }) 381 + let active = case index.active_did { 382 + Some(d) if d == did -> 383 + case accounts { 384 + [first, ..] -> Some(first.did) 385 + [] -> None 386 + } 387 + other -> other 388 + } 389 + save_index(AccountIndex(active_did: active, accounts:)) 390 + } 391 + 392 + pub fn delete_all() -> Result(Nil, StoreError) { 393 + use index <- result.try(load_index()) 394 + list.each(index.accounts, fn(a) { 395 + let _ = simplifile.delete(session_path(a.did)) 396 + Nil 397 + }) 398 + save_index(AccountIndex(active_did: None, accounts: [])) 399 + } 400 + 401 + pub fn store_error_message(err: StoreError) -> String { 402 + case err { 403 + NotFound -> "not authenticated" 404 + IoError(m) -> m 405 + DecodeError(m) -> "decode error: " <> m 406 + } 407 + }
+39
src/gtg/atproto/tid.gleam
··· 1 + //// AT Protocol TID (timestamp identifier) generation. 2 + 3 + import gleam/bit_array 4 + import gleam/int 5 + import gleam/string 6 + import gtg/sys 7 + 8 + /// Crockford base32 alphabet used by atproto TIDs (no I, L, O, U). 9 + const alphabet = "234567abcdefghijklmnopqrstuvwxyz" 10 + 11 + /// Generate a new TID from the current time (13 base32 chars). 12 + pub fn new_now() -> String { 13 + // TID is 64-bit: top bit 0, then 53 bits of microseconds since epoch-ish, 14 + // then 10 bits of clock id. We use micros << 10 with a small random clock. 15 + let micros = sys.unix_micros() 16 + let clock_id = int.bitwise_and(sys.unix_seconds(), 0x3FF) 17 + let tid_int = int.bitwise_or(int.bitwise_shift_left(micros, 10), clock_id) 18 + encode_base32(tid_int, 13) 19 + } 20 + 21 + fn encode_base32(value: Int, length: Int) -> String { 22 + encode_loop(value, length, "") 23 + } 24 + 25 + fn encode_loop(value: Int, remaining: Int, acc: String) -> String { 26 + case remaining { 27 + 0 -> acc 28 + n -> { 29 + let idx = int.bitwise_and(value, 0x1F) 30 + let char = string.slice(alphabet, idx, 1) 31 + encode_loop(int.bitwise_shift_right(value, 5), n - 1, char <> acc) 32 + } 33 + } 34 + } 35 + 36 + /// Encode arbitrary bytes as unpadded base64url. 37 + pub fn base64url(data: BitArray) -> String { 38 + bit_array.base64_url_encode(data, False) 39 + }
+257
src/gtg/atproto/xrpc.gleam
··· 1 + //// Low-level HTTP XRPC client for atproto / Bobbin / knots. 2 + 3 + import gleam/bit_array 4 + import gleam/dynamic/decode 5 + import gleam/http 6 + import gleam/http/request 7 + import gleam/httpc 8 + import gleam/int 9 + import gleam/json 10 + import gleam/list 11 + import gleam/option.{type Option, None, Some} 12 + import gleam/result 13 + import gleam/string 14 + import gleam/uri 15 + 16 + pub type XrpcError { 17 + HttpError(String) 18 + StatusError(status: Int, body: String) 19 + DecodeError(String) 20 + } 21 + 22 + pub type Auth { 23 + Bearer(String) 24 + /// DPoP-bound OAuth access token + proof JWT. 25 + Dpop(token: String, proof: String) 26 + NoAuth 27 + } 28 + 29 + /// GET `{host}/xrpc/{nsid}?…` with optional Bearer token. 30 + pub fn get( 31 + host: String, 32 + nsid: String, 33 + params: List(#(String, String)), 34 + auth: Auth, 35 + ) -> Result(String, XrpcError) { 36 + let url = xrpc_url(host, nsid, params) 37 + use req <- result.try(build_request(url, http.Get, auth, None, None)) 38 + send(req) 39 + } 40 + 41 + /// POST JSON body to `{host}/xrpc/{nsid}`. 42 + pub fn post_json( 43 + host: String, 44 + nsid: String, 45 + body: json.Json, 46 + auth: Auth, 47 + ) -> Result(String, XrpcError) { 48 + let url = xrpc_url(host, nsid, []) 49 + let payload = json.to_string(body) 50 + use req <- result.try(build_request( 51 + url, 52 + http.Post, 53 + auth, 54 + Some(#("application/json", bit_array.from_string(payload))), 55 + None, 56 + )) 57 + send(req) 58 + } 59 + 60 + /// POST raw bytes (e.g. blob upload). 61 + pub fn post_bytes( 62 + host: String, 63 + nsid: String, 64 + content_type: String, 65 + body: BitArray, 66 + auth: Auth, 67 + ) -> Result(String, XrpcError) { 68 + let url = xrpc_url(host, nsid, []) 69 + use req <- result.try(build_request( 70 + url, 71 + http.Post, 72 + auth, 73 + Some(#(content_type, body)), 74 + None, 75 + )) 76 + send(req) 77 + } 78 + 79 + /// GET raw bytes (e.g. blob download). 80 + pub fn get_bytes( 81 + host: String, 82 + nsid: String, 83 + params: List(#(String, String)), 84 + auth: Auth, 85 + ) -> Result(BitArray, XrpcError) { 86 + let url = xrpc_url(host, nsid, params) 87 + use req <- result.try(build_request(url, http.Get, auth, None, None)) 88 + send_bytes(req) 89 + } 90 + 91 + /// Arbitrary authenticated request for `gtg api`. 92 + pub fn call( 93 + host: String, 94 + method: String, 95 + nsid: String, 96 + fields: List(#(String, String)), 97 + auth: Auth, 98 + ) -> Result(#(Int, String), XrpcError) { 99 + let method_atom = case string.uppercase(method) { 100 + "POST" -> http.Post 101 + _ -> http.Get 102 + } 103 + case method_atom { 104 + http.Get -> { 105 + let url = xrpc_url(host, nsid, fields) 106 + use req <- result.try(build_request(url, http.Get, auth, None, None)) 107 + send_status(req) 108 + } 109 + http.Post -> { 110 + let body = 111 + fields 112 + |> list.map(fn(pair) { 113 + let #(k, v) = pair 114 + #(k, json.string(v)) 115 + }) 116 + |> json.object 117 + |> json.to_string 118 + let url = xrpc_url(host, nsid, []) 119 + use req <- result.try(build_request( 120 + url, 121 + http.Post, 122 + auth, 123 + Some(#("application/json", bit_array.from_string(body))), 124 + None, 125 + )) 126 + send_status(req) 127 + } 128 + _ -> Error(HttpError("unsupported method")) 129 + } 130 + } 131 + 132 + fn xrpc_url(host: String, nsid: String, params: List(#(String, String))) -> String { 133 + let base = trim_slash(host) <> "/xrpc/" <> nsid 134 + case params { 135 + [] -> base 136 + _ -> { 137 + let qs = 138 + params 139 + |> list.map(fn(p) { 140 + let #(k, v) = p 141 + uri.percent_encode(k) <> "=" <> uri.percent_encode(v) 142 + }) 143 + |> string.join("&") 144 + base <> "?" <> qs 145 + } 146 + } 147 + } 148 + 149 + fn trim_slash(host: String) -> String { 150 + case string.ends_with(host, "/") { 151 + True -> string.drop_end(host, 1) 152 + False -> host 153 + } 154 + } 155 + 156 + fn build_request( 157 + url: String, 158 + method: http.Method, 159 + auth: Auth, 160 + body: Option(#(String, BitArray)), 161 + _extra: Option(Nil), 162 + ) -> Result(request.Request(BitArray), XrpcError) { 163 + case request.to(url) { 164 + Error(_) -> Error(HttpError("invalid URL: " <> url)) 165 + Ok(req) -> { 166 + let req = 167 + req 168 + |> request.set_method(method) 169 + |> request.set_header("user-agent", "gtg") 170 + |> request.set_header("accept", "application/json") 171 + let req = case auth { 172 + Bearer(token) -> 173 + request.set_header(req, "authorization", "Bearer " <> token) 174 + Dpop(token, proof) -> 175 + req 176 + |> request.set_header("authorization", "DPoP " <> token) 177 + |> request.set_header("dpop", proof) 178 + NoAuth -> req 179 + } 180 + let req = case body { 181 + Some(#(ct, bytes)) -> 182 + req 183 + |> request.set_header("content-type", ct) 184 + |> request.set_body(bytes) 185 + None -> request.set_body(req, <<>>) 186 + } 187 + Ok(req) 188 + } 189 + } 190 + } 191 + 192 + fn send(req: request.Request(BitArray)) -> Result(String, XrpcError) { 193 + case httpc.send_bits(req) { 194 + Error(e) -> Error(HttpError(string.inspect(e))) 195 + Ok(resp) -> { 196 + case resp.status >= 200 && resp.status < 300 { 197 + True -> 198 + case bit_array.to_string(resp.body) { 199 + Ok(s) -> Ok(s) 200 + Error(_) -> Error(DecodeError("response body is not UTF-8")) 201 + } 202 + False -> 203 + Error(StatusError( 204 + status: resp.status, 205 + body: bit_array.to_string(resp.body) |> result.unwrap(""), 206 + )) 207 + } 208 + } 209 + } 210 + } 211 + 212 + fn send_bytes(req: request.Request(BitArray)) -> Result(BitArray, XrpcError) { 213 + case httpc.send_bits(req) { 214 + Error(e) -> Error(HttpError(string.inspect(e))) 215 + Ok(resp) -> { 216 + case resp.status >= 200 && resp.status < 300 { 217 + True -> Ok(resp.body) 218 + False -> 219 + Error(StatusError( 220 + status: resp.status, 221 + body: bit_array.to_string(resp.body) |> result.unwrap(""), 222 + )) 223 + } 224 + } 225 + } 226 + } 227 + 228 + fn send_status(req: request.Request(BitArray)) -> Result(#(Int, String), XrpcError) { 229 + case httpc.send_bits(req) { 230 + Error(e) -> Error(HttpError(string.inspect(e))) 231 + Ok(resp) -> { 232 + let body = bit_array.to_string(resp.body) |> result.unwrap("") 233 + Ok(#(resp.status, body)) 234 + } 235 + } 236 + } 237 + 238 + pub fn error_message(err: XrpcError) -> String { 239 + case err { 240 + HttpError(m) -> m 241 + StatusError(status, body) -> 242 + "HTTP " <> int.to_string(status) <> ": " <> body 243 + DecodeError(m) -> m 244 + } 245 + } 246 + 247 + /// Decode a top-level JSON string field. 248 + pub fn decode_string_field(body: String, field: String) -> Result(String, XrpcError) { 249 + let decoder = { 250 + use value <- decode.field(field, decode.string) 251 + decode.success(value) 252 + } 253 + case json.parse(body, decoder) { 254 + Ok(v) -> Ok(v) 255 + Error(_) -> Error(DecodeError("missing field " <> field)) 256 + } 257 + }
+925
src/gtg/cli.gleam
··· 1 + //// CLI command tree for gtg (Tangled client). 2 + 3 + import gleam/io 4 + import gleam/json 5 + import gleam/list 6 + import gleam/option.{type Option, None, Some} 7 + import gleam/result 8 + import gleam/string 9 + import gtg/app/service 10 + import gtg/app/types 11 + import gtg/config 12 + import gtg/knot/client as knot 13 + import gtg/output 14 + import gtg/sys 15 + import shellout 16 + import simplifile 17 + 18 + pub fn run(args: List(String)) -> Int { 19 + let flags = config.parse_flags(args) 20 + let settings = config.load(flags) 21 + let cmd_args = config.strip_global_flags(args) 22 + let svc = service.new(settings.appview, settings.account) 23 + case dispatch(svc, settings, cmd_args) { 24 + Ok(Nil) -> 0 25 + Error(msg) -> { 26 + io.println_error(msg) 27 + 1 28 + } 29 + } 30 + } 31 + 32 + fn dispatch( 33 + svc: service.Service, 34 + settings: config.Settings, 35 + args: List(String), 36 + ) -> Result(Nil, String) { 37 + case args { 38 + [] | ["-h"] | ["--help"] | ["help"] -> { 39 + print_root_help() 40 + Ok(Nil) 41 + } 42 + ["auth", ..rest] -> cmd_auth(svc, settings, rest) 43 + ["repo", ..rest] -> cmd_repo(svc, settings, rest) 44 + ["issue", ..rest] -> cmd_issue(svc, settings, rest) 45 + ["pr", ..rest] -> cmd_pr(svc, settings, rest) 46 + ["string", ..rest] -> cmd_string(svc, settings, rest) 47 + ["ssh-key", ..rest] -> cmd_ssh_key(svc, settings, rest) 48 + ["browse", ..rest] -> cmd_browse(svc, rest) 49 + ["api", ..rest] -> cmd_api(svc, rest) 50 + ["completion", ..] -> { 51 + io.println("# gtg completion not yet implemented") 52 + Ok(Nil) 53 + } 54 + [unknown, ..] -> Error("unknown command: " <> unknown) 55 + } 56 + } 57 + 58 + fn map_err(r: Result(a, service.AppError)) -> Result(a, String) { 59 + result.map_error(r, service.error_message) 60 + } 61 + 62 + // ---------- auth ---------- 63 + 64 + fn cmd_auth( 65 + svc: service.Service, 66 + settings: config.Settings, 67 + args: List(String), 68 + ) -> Result(Nil, String) { 69 + case args { 70 + [] | ["-h"] | ["--help"] -> { 71 + io.println( 72 + "Manage authentication\n\nUsage:\n gtg auth <command>\n\nCommands:\n login Log in via OAuth (browser) or app password\n logout Log out\n status Show auth status\n token Print access token\n list List accounts\n switch Switch active account\n\nLogin:\n gtg auth login <handle> OAuth in browser\n gtg auth login <handle> <app-password> headless app password", 73 + ) 74 + Ok(Nil) 75 + } 76 + ["login", handle] -> do_oauth_login(handle) 77 + ["login", handle, "--password-stdin"] -> { 78 + case sys.env("GTG_PASSWORD") { 79 + Ok(pw) -> do_login(handle, pw) 80 + Error(_) -> 81 + Error( 82 + "pass app password as argument, or set GTG_PASSWORD for --password-stdin in this build", 83 + ) 84 + } 85 + } 86 + ["login", handle, password] -> do_login(handle, password) 87 + ["logout", ..rest] -> { 88 + let all = list.contains(rest, "--all") 89 + use r <- result.try(map_err(service.logout(svc, all))) 90 + case settings.json { 91 + True -> 92 + output.print_json( 93 + json.object([#("wasLoggedIn", json.bool(r.was_logged_in))]), 94 + ) 95 + False -> 96 + case r.was_logged_in { 97 + True -> io.println("Logged out.") 98 + False -> io.println("Not logged in.") 99 + } 100 + } 101 + Ok(Nil) 102 + } 103 + ["status"] -> { 104 + use r <- result.try(map_err(service.auth_status(svc))) 105 + case settings.json { 106 + True -> output.print_json(types.auth_status_json(r)) 107 + False -> 108 + case r.authenticated { 109 + False -> io.println("Not logged in.") 110 + True -> 111 + case r.status { 112 + "active" -> io.println("Logged in as " <> r.handle) 113 + "expired" -> 114 + io.println( 115 + "Session expired. Run \"gtg auth login\" to re-authenticate.", 116 + ) 117 + _ -> io.println("Unable to verify session (network error).") 118 + } 119 + } 120 + } 121 + Ok(Nil) 122 + } 123 + ["token"] -> { 124 + use tok <- result.try(map_err(service.access_token(svc))) 125 + io.println(tok) 126 + Ok(Nil) 127 + } 128 + ["list"] -> { 129 + use items <- result.try(map_err(service.auth_accounts(svc))) 130 + case settings.json { 131 + True -> 132 + output.print_json( 133 + json.preprocessed_array(list.map(items, types.auth_account_json)), 134 + ) 135 + False -> 136 + case items { 137 + [] -> io.println("No accounts.") 138 + _ -> 139 + list.each(items, fn(item) { 140 + let marker = case item.active { 141 + True -> "*" 142 + False -> " " 143 + } 144 + io.println( 145 + marker 146 + <> " " 147 + <> item.handle 148 + <> " " 149 + <> item.did 150 + <> " " 151 + <> item.method, 152 + ) 153 + }) 154 + } 155 + } 156 + Ok(Nil) 157 + } 158 + ["switch", selector] -> { 159 + use r <- result.try(map_err(service.switch_account(selector))) 160 + case settings.json { 161 + True -> output.print_json(types.auth_account_json(r)) 162 + False -> io.println("Switched to " <> r.handle <> " (" <> r.did <> ")") 163 + } 164 + Ok(Nil) 165 + } 166 + _ -> Error("usage: gtg auth <login|logout|status|token|list|switch>") 167 + } 168 + } 169 + 170 + fn do_login(handle: String, password: String) -> Result(Nil, String) { 171 + use did <- result.try(map_err(service.login_with_password(handle, password))) 172 + io.println("Logged in as " <> did) 173 + Ok(Nil) 174 + } 175 + 176 + fn do_oauth_login(handle: String) -> Result(Nil, String) { 177 + use did <- result.try(map_err(service.login_with_oauth(handle))) 178 + io.println("Logged in as " <> did) 179 + Ok(Nil) 180 + } 181 + 182 + // ---------- repo ---------- 183 + 184 + fn cmd_repo( 185 + svc: service.Service, 186 + settings: config.Settings, 187 + args: List(String), 188 + ) -> Result(Nil, String) { 189 + case args { 190 + [] | ["-h"] | ["--help"] -> { 191 + io.println( 192 + "Manage repositories\n\nCommands:\n list [handle]\n view <handle/repo>\n clone <repo|handle/repo> [dir]\n create <name> [--description ...] [--knot ...] [--clone] [--push path]\n delete <handle/repo>\n set-default-branch <branch> [--repo handle/repo]\n edit [--description ...] [--repo handle/repo]", 193 + ) 194 + Ok(Nil) 195 + } 196 + ["list"] -> repo_list(svc, settings, "") 197 + ["list", handle] -> repo_list(svc, settings, handle) 198 + ["view", target] -> { 199 + use t <- result.try(map_err(service.parse_target(target))) 200 + use item <- result.try(map_err(service.view_repo(svc, t))) 201 + case settings.json { 202 + True -> output.print_json(types.repo_item_json(item)) 203 + False -> { 204 + io.println("Name: " <> item.name) 205 + io.println("Description: " <> item.description) 206 + io.println("URI: " <> item.uri) 207 + io.println("Knot: " <> item.knot) 208 + io.println("Created: " <> item.created_at) 209 + case item.repo_did { 210 + "" -> Nil 211 + d -> io.println("Repo DID: " <> d) 212 + } 213 + } 214 + } 215 + Ok(Nil) 216 + } 217 + ["clone", arg] -> repo_clone(svc, settings, arg, None) 218 + ["clone", arg, dest] -> repo_clone(svc, settings, arg, Some(dest)) 219 + ["create", name, ..flags] -> repo_create(svc, settings, name, flags) 220 + ["delete", target] -> { 221 + use t <- result.try(map_err(service.parse_target(target))) 222 + use r <- result.try(map_err(service.delete_repo(svc, t))) 223 + case settings.json { 224 + True -> output.print_json(json.object([#("uri", json.string(r.uri))])) 225 + False -> io.println("Deleted " <> r.uri) 226 + } 227 + Ok(Nil) 228 + } 229 + ["set-default-branch", branch, ..rest] -> { 230 + let repo_flag = flag_value(rest, "--repo", "-R") 231 + use t <- result.try(map_err(service.resolve_target_flag(repo_flag))) 232 + use r <- result.try(map_err(service.set_default_branch(svc, t, branch))) 233 + case settings.json { 234 + True -> 235 + output.print_json( 236 + json.object([ 237 + #("uri", json.string(r.uri)), 238 + #("branch", json.string(r.branch)), 239 + ]), 240 + ) 241 + False -> io.println("Set default branch to " <> r.branch) 242 + } 243 + Ok(Nil) 244 + } 245 + ["edit", ..rest] -> { 246 + let repo_flag = flag_value(rest, "--repo", "-R") 247 + let desc = flag_value(rest, "--description", "") 248 + use t <- result.try(map_err(service.resolve_target_flag(repo_flag))) 249 + let description = case desc { 250 + "" -> None 251 + d -> Some(d) 252 + } 253 + use r <- result.try(map_err(service.edit_repo(svc, t, description))) 254 + case settings.json { 255 + True -> 256 + output.print_json( 257 + json.object([ 258 + #("uri", json.string(r.uri)), 259 + #("description", json.string(r.description)), 260 + ]), 261 + ) 262 + False -> io.println("Updated " <> r.uri) 263 + } 264 + Ok(Nil) 265 + } 266 + _ -> Error("usage: gtg repo <list|view|clone|create|delete|…>") 267 + } 268 + } 269 + 270 + fn repo_list( 271 + svc: service.Service, 272 + settings: config.Settings, 273 + handle: String, 274 + ) -> Result(Nil, String) { 275 + use handle <- result.try(map_err(service.handle_or_self(svc, handle))) 276 + use items <- result.try(map_err(service.list_repos(svc, handle))) 277 + case settings.json { 278 + True -> 279 + output.print_json( 280 + json.preprocessed_array(list.map(items, types.repo_item_json)), 281 + ) 282 + False -> output.print_repos(items) 283 + } 284 + Ok(Nil) 285 + } 286 + 287 + fn repo_clone( 288 + svc: service.Service, 289 + settings: config.Settings, 290 + arg: String, 291 + dest: Option(String), 292 + ) -> Result(Nil, String) { 293 + use t <- result.try(case string.contains(arg, "/") { 294 + True -> map_err(service.parse_target(arg)) 295 + False -> { 296 + use handle <- result.try(map_err(service.handle_or_self(svc, ""))) 297 + Ok(types.Target(handle:, repo: arg)) 298 + } 299 + }) 300 + let destination = case dest { 301 + Some(d) -> d 302 + None -> t.repo 303 + } 304 + use r <- result.try( 305 + map_err(service.clone_repo(svc, t.handle, t.repo, destination)), 306 + ) 307 + case settings.json { 308 + True -> 309 + output.print_json( 310 + json.object([ 311 + #("handle", json.string(r.handle)), 312 + #("repo", json.string(r.repo)), 313 + #("destination", json.string(r.destination)), 314 + ]), 315 + ) 316 + False -> 317 + io.println( 318 + "Cloned " <> r.handle <> "/" <> r.repo <> " into " <> r.destination, 319 + ) 320 + } 321 + Ok(Nil) 322 + } 323 + 324 + fn repo_create( 325 + svc: service.Service, 326 + settings: config.Settings, 327 + name: String, 328 + flags: List(String), 329 + ) -> Result(Nil, String) { 330 + let description = flag_value(flags, "--description", "") 331 + let knot_host = case flag_value(flags, "--knot", "") { 332 + "" -> knot.default_knot 333 + k -> k 334 + } 335 + let do_clone = list.contains(flags, "--clone") 336 + let push_path = flag_value(flags, "--push", "") 337 + let remote = case flag_value(flags, "--remote", "") { 338 + "" -> "origin" 339 + r -> r 340 + } 341 + use r <- result.try( 342 + map_err(service.create_repo( 343 + svc, 344 + name, 345 + description, 346 + knot_host, 347 + do_clone, 348 + push_path, 349 + remote, 350 + )), 351 + ) 352 + case settings.json { 353 + True -> output.print_json(types.repo_create_json(r)) 354 + False -> { 355 + io.println("Created repository " <> r.handle <> "/" <> r.name) 356 + case r.cloned { 357 + True -> io.println("Cloned into " <> r.name) 358 + False -> Nil 359 + } 360 + case r.pushed { 361 + True -> io.println("Pushed to " <> r.name) 362 + False -> Nil 363 + } 364 + list.each(r.warnings, fn(w) { io.println_error("warning: " <> w) }) 365 + } 366 + } 367 + Ok(Nil) 368 + } 369 + 370 + // ---------- issue ---------- 371 + 372 + fn cmd_issue( 373 + svc: service.Service, 374 + settings: config.Settings, 375 + args: List(String), 376 + ) -> Result(Nil, String) { 377 + case args { 378 + [] | ["-h"] | ["--help"] -> { 379 + io.println( 380 + "Manage issues\n\nCommands:\n list [handle/repo]\n view <rkey> [-R handle/repo]\n create <title> [-b body] [-R handle/repo]\n comment <rkey> [-b body]\n close <rkey>\n reopen <rkey>", 381 + ) 382 + Ok(Nil) 383 + } 384 + ["list", ..rest] -> { 385 + let arg = case rest { 386 + [a] -> [a] 387 + _ -> [] 388 + } 389 + use t <- result.try(map_err(service.resolve_target(arg))) 390 + use items <- result.try(map_err(service.list_issues(svc, t))) 391 + case settings.json { 392 + True -> 393 + output.print_json( 394 + json.preprocessed_array(list.map(items, types.item_json)), 395 + ) 396 + False -> output.print_items(items, "No issues found.") 397 + } 398 + Ok(Nil) 399 + } 400 + ["view", rkey, ..rest] -> { 401 + let repo = flag_value(rest, "--repo", "-R") 402 + use t <- result.try(map_err(service.resolve_target_flag(repo))) 403 + use v <- result.try(map_err(service.view_issue(svc, t, rkey))) 404 + case settings.json { 405 + True -> output.print_json(types.view_json(v)) 406 + False -> { 407 + io.println("Title: " <> v.title) 408 + io.println("Author: " <> v.author.handle) 409 + io.println("Created: " <> v.created_at) 410 + case v.body { 411 + "" -> Nil 412 + b -> { 413 + io.println("") 414 + io.println(b) 415 + } 416 + } 417 + } 418 + } 419 + Ok(Nil) 420 + } 421 + ["create", title, ..rest] -> { 422 + let body = flag_value(rest, "--body", "-b") 423 + let body = case flag_value(rest, "--body-file", "-F") { 424 + "" -> body 425 + path -> 426 + case simplifile.read(path) { 427 + Ok(c) -> c 428 + Error(_) -> body 429 + } 430 + } 431 + let repo = flag_value(rest, "--repo", "-R") 432 + use t <- result.try(map_err(service.resolve_target_flag(repo))) 433 + use r <- result.try(map_err(service.create_issue(svc, t, title, body))) 434 + case settings.json { 435 + True -> output.print_json(types.created_json(r)) 436 + False -> io.println("Created issue " <> r.uri) 437 + } 438 + Ok(Nil) 439 + } 440 + ["comment", rkey, ..rest] -> { 441 + let body = flag_value(rest, "--body", "-b") 442 + let repo = flag_value(rest, "--repo", "-R") 443 + use t <- result.try(map_err(service.resolve_target_flag(repo))) 444 + use r <- result.try(map_err(service.comment_issue(svc, t, rkey, body))) 445 + case settings.json { 446 + True -> output.print_json(types.created_json(r)) 447 + False -> io.println("Created comment " <> r.uri) 448 + } 449 + Ok(Nil) 450 + } 451 + ["close", rkey, ..rest] -> issue_state(svc, settings, rkey, rest, "closed") 452 + ["reopen", rkey, ..rest] -> issue_state(svc, settings, rkey, rest, "open") 453 + _ -> Error("usage: gtg issue <list|view|create|comment|close|reopen>") 454 + } 455 + } 456 + 457 + fn issue_state( 458 + svc: service.Service, 459 + settings: config.Settings, 460 + rkey: String, 461 + rest: List(String), 462 + state: String, 463 + ) -> Result(Nil, String) { 464 + let repo = flag_value(rest, "--repo", "-R") 465 + use t <- result.try(map_err(service.resolve_target_flag(repo))) 466 + use r <- result.try(map_err(service.set_issue_state(svc, t, rkey, state))) 467 + case settings.json { 468 + True -> output.print_json(types.state_json(r)) 469 + False -> io.println("Issue " <> r.rkey <> " is now " <> r.state) 470 + } 471 + Ok(Nil) 472 + } 473 + 474 + // ---------- pr ---------- 475 + 476 + fn cmd_pr( 477 + svc: service.Service, 478 + settings: config.Settings, 479 + args: List(String), 480 + ) -> Result(Nil, String) { 481 + case args { 482 + [] | ["-h"] | ["--help"] -> { 483 + io.println( 484 + "Manage pull requests\n\nCommands:\n list [handle/repo]\n view <rkey>\n create --title …\n comment <rkey> -b body\n diff <rkey>\n checkout <rkey>\n close|reopen|merge <rkey>", 485 + ) 486 + Ok(Nil) 487 + } 488 + ["list", ..rest] -> { 489 + let arg = case rest { 490 + [a] -> [a] 491 + _ -> [] 492 + } 493 + use t <- result.try(map_err(service.resolve_target(arg))) 494 + use items <- result.try(map_err(service.list_pulls(svc, t))) 495 + case settings.json { 496 + True -> 497 + output.print_json( 498 + json.preprocessed_array(list.map(items, types.item_json)), 499 + ) 500 + False -> output.print_items(items, "No pull requests found.") 501 + } 502 + Ok(Nil) 503 + } 504 + ["view", rkey, ..rest] -> { 505 + let repo = flag_value(rest, "--repo", "-R") 506 + use t <- result.try(map_err(service.resolve_target_flag(repo))) 507 + use v <- result.try(map_err(service.view_pull(svc, t, rkey))) 508 + case settings.json { 509 + True -> output.print_json(types.view_json(v)) 510 + False -> { 511 + io.println("Title: " <> v.title) 512 + io.println("Author: " <> v.author.handle) 513 + io.println("Created: " <> v.created_at) 514 + io.println( 515 + "Branch: " <> v.source_branch <> " → " <> v.target_branch, 516 + ) 517 + case v.body { 518 + "" -> Nil 519 + b -> { 520 + io.println("") 521 + io.println(b) 522 + } 523 + } 524 + } 525 + } 526 + Ok(Nil) 527 + } 528 + ["create", ..rest] -> { 529 + let title = flag_value(rest, "--title", "-t") 530 + case title { 531 + "" -> Error("--title is required") 532 + title -> { 533 + let body = flag_value(rest, "--body", "-b") 534 + let base = flag_value(rest, "--base", "-B") 535 + let head = flag_value(rest, "--head", "-H") 536 + let repo = flag_value(rest, "--repo", "-R") 537 + let source = flag_value(rest, "--source-repo", "") 538 + use t <- result.try(map_err(service.resolve_target_flag(repo))) 539 + use source_t <- result.try(case source { 540 + "" -> Ok(None) 541 + s -> 542 + map_err(service.parse_target(s)) 543 + |> result.map(Some) 544 + }) 545 + use dir <- result.try( 546 + sys.get_cwd() |> result.map_error(fn(e) { e }), 547 + ) 548 + use r <- result.try( 549 + map_err(service.create_pull( 550 + svc, 551 + t, 552 + title, 553 + body, 554 + base, 555 + head, 556 + dir, 557 + source_t, 558 + )), 559 + ) 560 + case settings.json { 561 + True -> output.print_json(types.pr_create_json(r)) 562 + False -> 563 + io.println( 564 + "Created pull request " 565 + <> r.uri 566 + <> " (" 567 + <> r.head 568 + <> " -> " 569 + <> r.base 570 + <> ")", 571 + ) 572 + } 573 + Ok(Nil) 574 + } 575 + } 576 + } 577 + ["comment", rkey, ..rest] -> { 578 + let body = flag_value(rest, "--body", "-b") 579 + let repo = flag_value(rest, "--repo", "-R") 580 + use t <- result.try(map_err(service.resolve_target_flag(repo))) 581 + use r <- result.try(map_err(service.comment_pull(svc, t, rkey, body))) 582 + case settings.json { 583 + True -> output.print_json(types.created_json(r)) 584 + False -> io.println("Created comment " <> r.uri) 585 + } 586 + Ok(Nil) 587 + } 588 + ["diff", rkey, ..rest] -> { 589 + let repo = flag_value(rest, "--repo", "-R") 590 + use t <- result.try(map_err(service.resolve_target_flag(repo))) 591 + use patch <- result.try(map_err(service.pull_diff(svc, t, rkey))) 592 + io.println(patch) 593 + Ok(Nil) 594 + } 595 + ["checkout", rkey, ..rest] -> { 596 + let repo = flag_value(rest, "--repo", "-R") 597 + use t <- result.try(map_err(service.resolve_target_flag(repo))) 598 + use r <- result.try(map_err(service.checkout_pull(svc, t, rkey))) 599 + case settings.json { 600 + True -> 601 + output.print_json( 602 + json.object([ 603 + #("rkey", json.string(r.rkey)), 604 + #("branch", json.string(r.branch)), 605 + ]), 606 + ) 607 + False -> io.println("Checked out " <> r.branch) 608 + } 609 + Ok(Nil) 610 + } 611 + ["close", rkey, ..rest] -> pr_state(svc, settings, rkey, rest, "closed") 612 + ["reopen", rkey, ..rest] -> pr_state(svc, settings, rkey, rest, "open") 613 + ["merge", rkey, ..rest] -> { 614 + let repo = flag_value(rest, "--repo", "-R") 615 + use t <- result.try(map_err(service.resolve_target_flag(repo))) 616 + use r <- result.try(map_err(service.merge_pull(svc, t, rkey))) 617 + case settings.json { 618 + True -> output.print_json(types.state_json(r)) 619 + False -> io.println("Merged pull request " <> r.rkey) 620 + } 621 + Ok(Nil) 622 + } 623 + _ -> Error("usage: gtg pr <list|view|create|…>") 624 + } 625 + } 626 + 627 + fn pr_state( 628 + svc: service.Service, 629 + settings: config.Settings, 630 + rkey: String, 631 + rest: List(String), 632 + state: String, 633 + ) -> Result(Nil, String) { 634 + let repo = flag_value(rest, "--repo", "-R") 635 + use t <- result.try(map_err(service.resolve_target_flag(repo))) 636 + use r <- result.try(map_err(service.set_pull_state(svc, t, rkey, state))) 637 + case settings.json { 638 + True -> output.print_json(types.state_json(r)) 639 + False -> io.println("Pull request " <> r.rkey <> " is now " <> r.state) 640 + } 641 + Ok(Nil) 642 + } 643 + 644 + // ---------- string ---------- 645 + 646 + fn cmd_string( 647 + svc: service.Service, 648 + settings: config.Settings, 649 + args: List(String), 650 + ) -> Result(Nil, String) { 651 + case args { 652 + [] | ["-h"] | ["--help"] -> { 653 + io.println( 654 + "Manage strings\n\nCommands:\n list [handle]\n view <rkey> [handle]\n create --filename … --contents …\n delete <rkey>", 655 + ) 656 + Ok(Nil) 657 + } 658 + ["list"] -> string_list(svc, settings, "") 659 + ["list", handle] -> string_list(svc, settings, handle) 660 + ["view", rkey] -> string_view(svc, settings, "", rkey) 661 + ["view", rkey, handle] -> string_view(svc, settings, handle, rkey) 662 + ["create", ..rest] -> { 663 + let filename = flag_value(rest, "--filename", "") 664 + let description = flag_value(rest, "--description", "") 665 + let contents = flag_value(rest, "--contents", "") 666 + let contents = case flag_value(rest, "--contents-file", "") { 667 + "" -> contents 668 + path -> 669 + case simplifile.read(path) { 670 + Ok(c) -> c 671 + Error(_) -> contents 672 + } 673 + } 674 + case filename { 675 + "" -> Error("--filename is required") 676 + filename -> { 677 + use r <- result.try( 678 + map_err(service.create_string(svc, filename, description, contents)), 679 + ) 680 + case settings.json { 681 + True -> output.print_json(types.created_json(r)) 682 + False -> io.println("Created string " <> r.uri) 683 + } 684 + Ok(Nil) 685 + } 686 + } 687 + } 688 + ["delete", rkey] -> { 689 + use r <- result.try(map_err(service.delete_string(svc, rkey))) 690 + case settings.json { 691 + True -> output.print_json(types.deleted_json(r)) 692 + False -> io.println("Deleted string " <> r.rkey) 693 + } 694 + Ok(Nil) 695 + } 696 + _ -> Error("usage: gtg string <list|view|create|delete>") 697 + } 698 + } 699 + 700 + fn string_list( 701 + svc: service.Service, 702 + settings: config.Settings, 703 + handle: String, 704 + ) -> Result(Nil, String) { 705 + use handle <- result.try(map_err(service.handle_or_self(svc, handle))) 706 + use items <- result.try(map_err(service.list_strings(svc, handle))) 707 + case settings.json { 708 + True -> 709 + output.print_json( 710 + json.preprocessed_array(list.map(items, types.string_item_json)), 711 + ) 712 + False -> output.print_strings(items) 713 + } 714 + Ok(Nil) 715 + } 716 + 717 + fn string_view( 718 + svc: service.Service, 719 + settings: config.Settings, 720 + handle: String, 721 + rkey: String, 722 + ) -> Result(Nil, String) { 723 + use handle <- result.try(map_err(service.handle_or_self(svc, handle))) 724 + use v <- result.try(map_err(service.view_string(svc, handle, rkey))) 725 + case settings.json { 726 + True -> output.print_json(types.string_view_json(v)) 727 + False -> { 728 + io.println("Filename: " <> v.filename) 729 + io.println("Author: " <> v.author.handle) 730 + io.println("Created: " <> v.created_at) 731 + case v.description { 732 + "" -> Nil 733 + d -> io.println("Description: " <> d) 734 + } 735 + io.println("") 736 + io.println(v.contents) 737 + } 738 + } 739 + Ok(Nil) 740 + } 741 + 742 + // ---------- ssh-key ---------- 743 + 744 + fn cmd_ssh_key( 745 + svc: service.Service, 746 + settings: config.Settings, 747 + args: List(String), 748 + ) -> Result(Nil, String) { 749 + case args { 750 + [] | ["-h"] | ["--help"] -> { 751 + io.println( 752 + "Manage SSH keys\n\nCommands:\n list [handle]\n add --name … --key …\n delete <rkey>", 753 + ) 754 + Ok(Nil) 755 + } 756 + ["list"] -> ssh_list(svc, settings, "") 757 + ["list", handle] -> ssh_list(svc, settings, handle) 758 + ["add", ..rest] -> { 759 + let name = flag_value(rest, "--name", "") 760 + let key = flag_value(rest, "--key", "") 761 + let key = case flag_value(rest, "--key-file", "") { 762 + "" -> key 763 + path -> 764 + case simplifile.read(path) { 765 + Ok(c) -> string.trim(c) 766 + Error(_) -> key 767 + } 768 + } 769 + case name == "" || key == "" { 770 + True -> Error("--name and --key (or --key-file) are required") 771 + False -> { 772 + use r <- result.try(map_err(service.add_ssh_key(svc, name, key))) 773 + case settings.json { 774 + True -> 775 + output.print_json( 776 + json.object([ 777 + #("name", json.string(r.name)), 778 + #("uri", json.string(r.uri)), 779 + ]), 780 + ) 781 + False -> io.println("Added SSH key " <> r.name <> " (" <> r.uri <> ")") 782 + } 783 + Ok(Nil) 784 + } 785 + } 786 + } 787 + ["delete", rkey] -> { 788 + use r <- result.try(map_err(service.delete_ssh_key(svc, rkey))) 789 + case settings.json { 790 + True -> output.print_json(types.deleted_json(r)) 791 + False -> io.println("Deleted SSH key " <> r.rkey) 792 + } 793 + Ok(Nil) 794 + } 795 + _ -> Error("usage: gtg ssh-key <list|add|delete>") 796 + } 797 + } 798 + 799 + fn ssh_list( 800 + svc: service.Service, 801 + settings: config.Settings, 802 + handle: String, 803 + ) -> Result(Nil, String) { 804 + use handle <- result.try(map_err(service.handle_or_self(svc, handle))) 805 + use items <- result.try(map_err(service.list_ssh_keys(svc, handle))) 806 + case settings.json { 807 + True -> 808 + output.print_json( 809 + json.preprocessed_array(list.map(items, types.ssh_key_json)), 810 + ) 811 + False -> output.print_ssh_keys(items) 812 + } 813 + Ok(Nil) 814 + } 815 + 816 + // ---------- browse / api ---------- 817 + 818 + fn cmd_browse(_svc: service.Service, args: List(String)) -> Result(Nil, String) { 819 + use t <- result.try(map_err(service.resolve_target(args))) 820 + let url = "https://tangled.org/" <> t.handle <> "/" <> t.repo 821 + case shellout.command(run: "xdg-open", with: [url], in: ".", opt: []) { 822 + Ok(_) -> Ok(Nil) 823 + Error(_) -> { 824 + io.println(url) 825 + Ok(Nil) 826 + } 827 + } 828 + } 829 + 830 + fn cmd_api(svc: service.Service, args: List(String)) -> Result(Nil, String) { 831 + case args { 832 + [nsid, ..rest] -> { 833 + let method = case flag_value(rest, "--method", "-X") { 834 + "" -> "GET" 835 + m -> m 836 + } 837 + let fields = collect_fields(rest, []) 838 + use #(status, body) <- result.try( 839 + map_err(service.call_api(svc, nsid, method, fields)), 840 + ) 841 + case status >= 200 && status < 300 { 842 + True -> { 843 + io.println(body) 844 + Ok(Nil) 845 + } 846 + False -> Error("HTTP " <> sys.int_to_string(status) <> ": " <> body) 847 + } 848 + } 849 + [] -> Error("usage: gtg api <nsid> [-X GET|POST] [-f key=value]…") 850 + } 851 + } 852 + 853 + fn collect_fields( 854 + args: List(String), 855 + acc: List(#(String, String)), 856 + ) -> List(#(String, String)) { 857 + case args { 858 + [] -> list.reverse(acc) 859 + ["-f", kv, ..rest] | ["--field", kv, ..rest] -> { 860 + case string.split_once(kv, "=") { 861 + Ok(pair) -> collect_fields(rest, [pair, ..acc]) 862 + Error(_) -> collect_fields(rest, acc) 863 + } 864 + } 865 + [_, ..rest] -> collect_fields(rest, acc) 866 + } 867 + } 868 + 869 + // ---------- flag helpers ---------- 870 + 871 + /// Get flag value for long and optional short form. 872 + fn flag_value(args: List(String), long: String, short: String) -> String { 873 + flag_value_loop(args, long, short) 874 + } 875 + 876 + fn flag_value_loop(args: List(String), long: String, short: String) -> String { 877 + case args { 878 + [] -> "" 879 + [arg, ..rest] -> { 880 + case arg == long || { short != "" && arg == short } { 881 + True -> 882 + case rest { 883 + [v, ..] -> v 884 + [] -> "" 885 + } 886 + False -> 887 + case string.starts_with(arg, long <> "=") { 888 + True -> string.drop_start(arg, string.length(long) + 1) 889 + False -> flag_value_loop(rest, long, short) 890 + } 891 + } 892 + } 893 + } 894 + } 895 + 896 + fn print_root_help() -> Nil { 897 + io.println( 898 + string.join( 899 + [ 900 + "A CLI for Tangled (Gleam port of tg)", 901 + "", 902 + "Usage:", 903 + " gtg [command]", 904 + "", 905 + "Available Commands:", 906 + " api Call an authenticated XRPC endpoint", 907 + " auth Manage authentication", 908 + " browse Open a Tangled repository in a browser", 909 + " issue Manage issues on a Tangled repository", 910 + " pr Manage pull requests on a Tangled repository", 911 + " repo Manage repositories on Tangled", 912 + " ssh-key Manage SSH keys on Tangled", 913 + " string Manage strings on Tangled", 914 + "", 915 + "Flags:", 916 + " --appview string Appview host URL (default \"https://bobbin.klbr.net\")", 917 + " --config string Path to config file", 918 + " --account string Account handle or DID", 919 + " --json Output in JSON format", 920 + " -h, --help Help", 921 + ], 922 + "\n", 923 + ), 924 + ) 925 + }
+178
src/gtg/config.gleam
··· 1 + //// Configuration: flags > TG_* env > config.toml > defaults. 2 + 3 + import envoy 4 + import filepath 5 + import gleam/dict 6 + import gleam/list 7 + import gleam/option.{type Option, None, Some} 8 + import gleam/string 9 + import gtg/sys 10 + import simplifile 11 + import tom 12 + 13 + pub const default_appview = "https://bobbin.klbr.net" 14 + 15 + pub type Settings { 16 + Settings(appview: String, account: Option(String), json: Bool) 17 + } 18 + 19 + pub type FlagSettings { 20 + FlagSettings( 21 + config_path: Option(String), 22 + appview: Option(String), 23 + account: Option(String), 24 + json: Bool, 25 + ) 26 + } 27 + 28 + pub fn default_settings() -> Settings { 29 + Settings(appview: default_appview, account: None, json: False) 30 + } 31 + 32 + /// Parse known root flags from argv (does not consume subcommand args). 33 + pub fn parse_flags(args: List(String)) -> FlagSettings { 34 + parse_flags_loop(args, FlagSettings(None, None, None, False)) 35 + } 36 + 37 + fn parse_flags_loop(args: List(String), flags: FlagSettings) -> FlagSettings { 38 + case args { 39 + [] -> flags 40 + ["--", ..] -> flags 41 + ["--json", ..rest] -> 42 + parse_flags_loop(rest, FlagSettings(..flags, json: True)) 43 + ["--config", value, ..rest] -> 44 + parse_flags_loop(rest, FlagSettings(..flags, config_path: Some(value))) 45 + ["--appview", value, ..rest] -> 46 + parse_flags_loop(rest, FlagSettings(..flags, appview: Some(value))) 47 + ["--account", value, ..rest] -> 48 + parse_flags_loop(rest, FlagSettings(..flags, account: Some(value))) 49 + [arg, ..rest] -> { 50 + case string.split_once(arg, "=") { 51 + Ok(#("--config", v)) -> 52 + parse_flags_loop(rest, FlagSettings(..flags, config_path: Some(v))) 53 + Ok(#("--appview", v)) -> 54 + parse_flags_loop(rest, FlagSettings(..flags, appview: Some(v))) 55 + Ok(#("--account", v)) -> 56 + parse_flags_loop(rest, FlagSettings(..flags, account: Some(v))) 57 + _ -> parse_flags_loop(rest, flags) 58 + } 59 + } 60 + } 61 + } 62 + 63 + /// Strip global flags from args, leaving command path + command flags/args. 64 + pub fn strip_global_flags(args: List(String)) -> List(String) { 65 + strip_loop(args, []) 66 + } 67 + 68 + fn strip_loop(args: List(String), acc: List(String)) -> List(String) { 69 + case args { 70 + [] -> list.reverse(acc) 71 + ["--"] -> list.reverse(acc) 72 + ["--json", ..rest] -> strip_loop(rest, acc) 73 + ["--config", _value, ..rest] -> strip_loop(rest, acc) 74 + ["--appview", _value, ..rest] -> strip_loop(rest, acc) 75 + ["--account", _value, ..rest] -> strip_loop(rest, acc) 76 + [arg, ..rest] -> { 77 + case string.starts_with(arg, "--config=") 78 + || string.starts_with(arg, "--appview=") 79 + || string.starts_with(arg, "--account=") 80 + { 81 + True -> strip_loop(rest, acc) 82 + False -> strip_loop(rest, [arg, ..acc]) 83 + } 84 + } 85 + } 86 + } 87 + 88 + pub fn load(flags: FlagSettings) -> Settings { 89 + let file_settings = read_config_file(flags.config_path) 90 + let appview = 91 + coalesce_string([ 92 + flags.appview, 93 + env("TG_APPVIEW"), 94 + file_settings.appview, 95 + Some(default_appview), 96 + ]) 97 + let account = 98 + coalesce_opt([flags.account, env("TG_ACCOUNT"), file_settings.account]) 99 + Settings(appview:, account:, json: flags.json) 100 + } 101 + 102 + fn env(name: String) -> Option(String) { 103 + case envoy.get(name) { 104 + Ok(v) if v != "" -> Some(v) 105 + _ -> None 106 + } 107 + } 108 + 109 + fn coalesce_string(opts: List(Option(String))) -> String { 110 + case opts { 111 + [] -> default_appview 112 + [Some(v), ..] if v != "" -> v 113 + [_, ..rest] -> coalesce_string(rest) 114 + } 115 + } 116 + 117 + fn coalesce_opt(opts: List(Option(String))) -> Option(String) { 118 + case opts { 119 + [] -> None 120 + [Some(v), ..] if v != "" -> Some(v) 121 + [_, ..rest] -> coalesce_opt(rest) 122 + } 123 + } 124 + 125 + type FileSettings { 126 + FileSettings(appview: Option(String), account: Option(String)) 127 + } 128 + 129 + fn read_config_file(path: Option(String)) -> FileSettings { 130 + let candidates = case path { 131 + Some(p) -> [p] 132 + None -> [ 133 + filepath.join(sys.config_dir(), "config.toml"), 134 + // also accept tg config for interoperability 135 + filepath.join( 136 + case envoy.get("XDG_CONFIG_HOME") { 137 + Ok(xdg) if xdg != "" -> filepath.join(xdg, "tg") 138 + _ -> 139 + case envoy.get("HOME") { 140 + Ok(home) -> filepath.join(filepath.join(home, ".config"), "tg") 141 + Error(_) -> ".config/tg" 142 + } 143 + }, 144 + "config.toml", 145 + ), 146 + ] 147 + } 148 + case list.find_map(candidates, read_one_config) { 149 + Ok(s) -> s 150 + Error(_) -> FileSettings(None, None) 151 + } 152 + } 153 + 154 + fn read_one_config(path: String) -> Result(FileSettings, Nil) { 155 + case simplifile.read(path) { 156 + Error(_) -> Error(Nil) 157 + Ok(body) -> { 158 + case tom.parse(body) { 159 + Error(_) -> Error(Nil) 160 + Ok(doc) -> { 161 + let appview = tom_string(doc, "appview") 162 + let account = tom_string(doc, "account") 163 + Ok(FileSettings(appview:, account:)) 164 + } 165 + } 166 + } 167 + } 168 + } 169 + 170 + fn tom_string( 171 + doc: dict.Dict(String, tom.Toml), 172 + key: String, 173 + ) -> Option(String) { 174 + case dict.get(doc, key) { 175 + Ok(tom.String(s)) -> Some(s) 176 + _ -> None 177 + } 178 + }
+295
src/gtg/gitutil.gleam
··· 1 + //// Git subprocess helpers for clone, remotes, patches, branches. 2 + 3 + import gleam/list 4 + import gleam/result 5 + import gleam/string 6 + import shellout 7 + import simplifile 8 + 9 + pub type RepoContext { 10 + RepoContext(handle: String, repo: String) 11 + } 12 + 13 + pub type GitError { 14 + Failed(String) 15 + } 16 + 17 + pub fn error_message(err: GitError) -> String { 18 + case err { 19 + Failed(m) -> m 20 + } 21 + } 22 + 23 + const tangled_host = "tangled.org" 24 + 25 + pub fn tangled_remote_url(handle: String, repo: String) -> String { 26 + "git@" <> tangled_host <> ":" <> handle <> "/" <> repo 27 + } 28 + 29 + pub fn clone_repo(handle: String, repo: String, dest: String) -> Result(Nil, GitError) { 30 + let url = tangled_remote_url(handle, repo) 31 + run_git(["clone", url, dest]) 32 + } 33 + 34 + pub fn detect_repo_from_cwd() -> Result(RepoContext, GitError) { 35 + use remotes <- result.try(git_lines(["remote"])) 36 + let ordered = origin_first(remotes) 37 + detect_loop(ordered) 38 + } 39 + 40 + fn detect_loop(remotes: List(String)) -> Result(RepoContext, GitError) { 41 + case remotes { 42 + [] -> Error(Failed("no Tangled remote found; pass handle/repo")) 43 + [name, ..rest] -> { 44 + case git_lines(["remote", "get-url", "--all", name]) { 45 + Error(_) -> detect_loop(rest) 46 + Ok(urls) -> { 47 + case find_tangled(urls) { 48 + Ok(rc) -> Ok(rc) 49 + Error(_) -> detect_loop(rest) 50 + } 51 + } 52 + } 53 + } 54 + } 55 + } 56 + 57 + fn find_tangled(urls: List(String)) -> Result(RepoContext, Nil) { 58 + case urls { 59 + [] -> Error(Nil) 60 + [url, ..rest] -> 61 + case parse_tangled_url(url) { 62 + Ok(rc) -> Ok(rc) 63 + Error(_) -> find_tangled(rest) 64 + } 65 + } 66 + } 67 + 68 + pub fn parse_tangled_url(raw: String) -> Result(RepoContext, Nil) { 69 + let raw = string.trim(raw) 70 + // SCP: git@tangled.org:handle/repo 71 + case string.split_once(raw, "git@tangled.org:") { 72 + Ok(#("", path)) -> split_handle_repo(path) 73 + _ -> { 74 + // ssh://git@tangled.org/handle/repo 75 + case string.contains(raw, "tangled.org") { 76 + False -> Error(Nil) 77 + True -> { 78 + let path = case string.split(raw, "tangled.org") { 79 + [_, rest] -> string.drop_start(rest, 1) 80 + // drop leading : or / 81 + _ -> raw 82 + } 83 + let path = case string.starts_with(path, "/") { 84 + True -> string.drop_start(path, 1) 85 + False -> 86 + case string.starts_with(path, ":") { 87 + True -> string.drop_start(path, 1) 88 + False -> path 89 + } 90 + } 91 + // strip user@ if present in scp already handled 92 + split_handle_repo(path) 93 + } 94 + } 95 + } 96 + } 97 + } 98 + 99 + fn split_handle_repo(path: String) -> Result(RepoContext, Nil) { 100 + let path = 101 + path 102 + |> string.trim 103 + |> strip_slashes 104 + |> strip_git_suffix 105 + case string.split_once(path, "/") { 106 + Ok(#(handle, repo)) -> 107 + case handle != "" && repo != "" && !string.contains(repo, "/") { 108 + True -> Ok(RepoContext(handle:, repo:)) 109 + False -> Error(Nil) 110 + } 111 + Error(_) -> Error(Nil) 112 + } 113 + } 114 + 115 + fn strip_slashes(path: String) -> String { 116 + case string.starts_with(path, "/") { 117 + True -> strip_slashes(string.drop_start(path, 1)) 118 + False -> 119 + case string.ends_with(path, "/") { 120 + True -> strip_slashes(string.drop_end(path, 1)) 121 + False -> path 122 + } 123 + } 124 + } 125 + 126 + fn strip_git_suffix(path: String) -> String { 127 + case string.ends_with(path, ".git") { 128 + True -> string.drop_end(path, 4) 129 + False -> path 130 + } 131 + } 132 + 133 + fn origin_first(remotes: List(String)) -> List(String) { 134 + case list.split_while(remotes, fn(r) { r != "origin" }) { 135 + #(before, ["origin", ..after]) -> 136 + list.append(["origin"], list.append(before, after)) 137 + _ -> remotes 138 + } 139 + } 140 + 141 + pub fn current_branch(dir: String) -> Result(String, GitError) { 142 + git_lines_in(dir, ["rev-parse", "--abbrev-ref", "HEAD"]) 143 + |> result.try(fn(lines) { 144 + case lines { 145 + [b, ..] -> Ok(b) 146 + [] -> Error(Failed("no current branch")) 147 + } 148 + }) 149 + } 150 + 151 + pub fn default_branch(dir: String) -> Result(String, GitError) { 152 + // origin/HEAD -> origin/main 153 + case git_lines_in(dir, ["symbolic-ref", "refs/remotes/origin/HEAD"]) { 154 + Ok([ref, ..]) -> { 155 + // refs/remotes/origin/main 156 + case string.split(ref, "/") { 157 + parts -> 158 + case list.last(parts) { 159 + Ok(b) -> Ok(b) 160 + Error(_) -> Error(Failed("parse origin HEAD")) 161 + } 162 + } 163 + } 164 + Ok([]) -> Error(Failed("empty origin HEAD")) 165 + Error(_) -> { 166 + // fallback: origin/main or origin/master 167 + case git_lines_in(dir, ["branch", "-r"]) { 168 + Ok(lines) -> { 169 + case 170 + list.find(lines, fn(l) { 171 + string.contains(l, "origin/main") 172 + || string.contains(l, "origin/master") 173 + }) 174 + { 175 + Ok(line) -> { 176 + let line = string.trim(line) 177 + case string.split(line, "/") { 178 + parts -> 179 + case list.last(parts) { 180 + Ok(b) -> Ok(b) 181 + Error(_) -> Error(Failed("detect default branch")) 182 + } 183 + } 184 + } 185 + Error(_) -> Error(Failed("detect default branch; set --base")) 186 + } 187 + } 188 + Error(e) -> Error(e) 189 + } 190 + } 191 + } 192 + } 193 + 194 + pub fn generate_patch( 195 + dir: String, 196 + base: String, 197 + head: String, 198 + ) -> Result(BitArray, GitError) { 199 + // git format-patch base..head --stdout 200 + case 201 + shellout.command( 202 + run: "git", 203 + with: ["format-patch", base <> ".." <> head, "--stdout"], 204 + in: dir, 205 + opt: [], 206 + ) 207 + { 208 + Ok(out) -> Ok(<<out:utf8>>) 209 + Error(#(_status, msg)) -> Error(Failed("git format-patch: " <> msg)) 210 + } 211 + } 212 + 213 + pub fn push_new_repo( 214 + dir: String, 215 + handle: String, 216 + repo: String, 217 + remote_name: String, 218 + ) -> Result(Nil, GitError) { 219 + let url = tangled_remote_url(handle, repo) 220 + use _ <- result.try(run_git_in(dir, ["remote", "remove", remote_name]) |> or_ok) 221 + use _ <- result.try(run_git_in(dir, ["remote", "add", remote_name, url])) 222 + use branch <- result.try(current_branch(dir)) 223 + run_git_in(dir, ["push", "-u", remote_name, branch]) 224 + } 225 + 226 + fn or_ok(r: Result(Nil, GitError)) -> Result(Nil, GitError) { 227 + case r { 228 + Ok(_) -> Ok(Nil) 229 + Error(_) -> Ok(Nil) 230 + } 231 + } 232 + 233 + fn run_git(args: List(String)) -> Result(Nil, GitError) { 234 + case shellout.command(run: "git", with: args, in: ".", opt: []) { 235 + Ok(_) -> Ok(Nil) 236 + Error(#(_status, msg)) -> 237 + Error(Failed("git " <> string.join(args, " ") <> ": " <> msg)) 238 + } 239 + } 240 + 241 + fn run_git_in(dir: String, args: List(String)) -> Result(Nil, GitError) { 242 + case shellout.command(run: "git", with: args, in: dir, opt: []) { 243 + Ok(_) -> Ok(Nil) 244 + Error(#(_status, msg)) -> 245 + Error(Failed("git " <> string.join(args, " ") <> ": " <> msg)) 246 + } 247 + } 248 + 249 + fn git_lines(args: List(String)) -> Result(List(String), GitError) { 250 + git_lines_in(".", args) 251 + } 252 + 253 + fn git_lines_in(dir: String, args: List(String)) -> Result(List(String), GitError) { 254 + case shellout.command(run: "git", with: args, in: dir, opt: []) { 255 + Ok(out) -> 256 + Ok( 257 + out 258 + |> string.split("\n") 259 + |> list.map(string.trim) 260 + |> list.filter(fn(l) { l != "" }), 261 + ) 262 + Error(#(_status, msg)) -> 263 + Error(Failed("git " <> string.join(args, " ") <> ": " <> msg)) 264 + } 265 + } 266 + 267 + pub fn apply_patch(dir: String, branch: String, patch: String) -> Result(Nil, GitError) { 268 + use _ <- result.try(run_git_in(dir, ["checkout", "-B", branch])) 269 + // Write patch via git apply from stdin is hard; use temp via shell 270 + case 271 + shellout.command( 272 + run: "sh", 273 + with: ["-c", "git apply"], 274 + in: dir, 275 + opt: [shellout.LetBeStderr], 276 + ) 277 + { 278 + // Fallback: checkout won't apply without stdin. Use git am with echo. 279 + _ -> { 280 + // Write to /tmp and apply 281 + let path = "/tmp/gtg-pr.patch" 282 + case simplifile_write(path, patch) { 283 + Error(e) -> Error(Failed(e)) 284 + Ok(_) -> run_git_in(dir, ["am", path]) 285 + } 286 + } 287 + } 288 + } 289 + 290 + fn simplifile_write(path: String, contents: String) -> Result(Nil, String) { 291 + case simplifile.write(to: path, contents: contents) { 292 + Ok(_) -> Ok(Nil) 293 + Error(e) -> Error(string.inspect(e)) 294 + } 295 + }
+144
src/gtg/knot/client.gleam
··· 1 + //// Knot server RPCs authenticated with service-auth JWT. 2 + 3 + import gleam/dynamic/decode 4 + import gleam/json 5 + import gleam/option.{None, Some} 6 + import gtg/atproto/xrpc 7 + 8 + pub const default_knot = "knot1.tangled.sh" 9 + 10 + pub type KnotError { 11 + Failed(String) 12 + } 13 + 14 + pub fn error_message(err: KnotError) -> String { 15 + case err { 16 + Failed(m) -> m 17 + } 18 + } 19 + 20 + fn host_url(host: String) -> String { 21 + case string_starts_with_http(host) { 22 + True -> host 23 + False -> "https://" <> host 24 + } 25 + } 26 + 27 + fn string_starts_with_http(host: String) -> Bool { 28 + case host { 29 + "http://" <> _ | "https://" <> _ -> True 30 + _ -> False 31 + } 32 + } 33 + 34 + pub fn create_repo( 35 + knot_host: String, 36 + token: String, 37 + name: String, 38 + rkey: String, 39 + ) -> Result(String, KnotError) { 40 + let body = 41 + json.object([ 42 + #("name", json.string(name)), 43 + #("rkey", json.string(rkey)), 44 + ]) 45 + case 46 + xrpc.post_json( 47 + host_url(knot_host), 48 + "sh.tangled.repo.create", 49 + body, 50 + xrpc.Bearer(token), 51 + ) 52 + { 53 + Error(e) -> Error(Failed(xrpc.error_message(e))) 54 + Ok(resp) -> { 55 + let decoder = { 56 + use repo_did <- decode.optional_field( 57 + "repoDid", 58 + None, 59 + decode.optional(decode.string), 60 + ) 61 + decode.success(repo_did) 62 + } 63 + case json.parse(resp, decoder) { 64 + Ok(Some(did)) if did != "" -> Ok(did) 65 + Ok(_) -> Error(Failed("knot did not return a repoDid")) 66 + Error(_) -> Error(Failed("decode create repo")) 67 + } 68 + } 69 + } 70 + } 71 + 72 + pub fn delete_repo( 73 + knot_host: String, 74 + token: String, 75 + did: String, 76 + name: String, 77 + rkey: String, 78 + ) -> Result(Nil, KnotError) { 79 + let body = 80 + json.object([ 81 + #("did", json.string(did)), 82 + #("name", json.string(name)), 83 + #("rkey", json.string(rkey)), 84 + ]) 85 + case 86 + xrpc.post_json( 87 + host_url(knot_host), 88 + "sh.tangled.repo.delete", 89 + body, 90 + xrpc.Bearer(token), 91 + ) 92 + { 93 + Error(e) -> Error(Failed(xrpc.error_message(e))) 94 + Ok(_) -> Ok(Nil) 95 + } 96 + } 97 + 98 + pub fn set_default_branch( 99 + knot_host: String, 100 + token: String, 101 + repo_uri: String, 102 + branch: String, 103 + ) -> Result(Nil, KnotError) { 104 + let body = 105 + json.object([ 106 + #("repo", json.string(repo_uri)), 107 + #("defaultBranch", json.string(branch)), 108 + ]) 109 + case 110 + xrpc.post_json( 111 + host_url(knot_host), 112 + "sh.tangled.repo.setDefaultBranch", 113 + body, 114 + xrpc.Bearer(token), 115 + ) 116 + { 117 + Error(e) -> Error(Failed(xrpc.error_message(e))) 118 + Ok(_) -> Ok(Nil) 119 + } 120 + } 121 + 122 + pub fn merge( 123 + knot_host: String, 124 + token: String, 125 + repo_uri: String, 126 + pull_uri: String, 127 + ) -> Result(Nil, KnotError) { 128 + let body = 129 + json.object([ 130 + #("repo", json.string(repo_uri)), 131 + #("pull", json.string(pull_uri)), 132 + ]) 133 + case 134 + xrpc.post_json( 135 + host_url(knot_host), 136 + "sh.tangled.repo.merge", 137 + body, 138 + xrpc.Bearer(token), 139 + ) 140 + { 141 + Error(e) -> Error(Failed(xrpc.error_message(e))) 142 + Ok(_) -> Ok(Nil) 143 + } 144 + }
+133
src/gtg/output.gleam
··· 1 + //// Human tables and JSON output helpers. 2 + 3 + import gleam/io 4 + import gleam/json 5 + import gleam/list 6 + import gleam/string 7 + import gtg/app/types 8 + import gtg/sys 9 + 10 + pub fn print_json(value: json.Json) -> Nil { 11 + io.println(json.to_string(value)) 12 + } 13 + 14 + pub fn print_table( 15 + headers: List(String), 16 + rows: List(List(String)), 17 + empty: String, 18 + ) -> Nil { 19 + case rows { 20 + [] -> io.println(empty) 21 + _ -> { 22 + let all = [headers, ..rows] 23 + let widths = column_widths(all) 24 + io.println(format_row(headers, widths)) 25 + list.each(rows, fn(row) { io.println(format_row(row, widths)) }) 26 + } 27 + } 28 + } 29 + 30 + fn column_widths(rows: List(List(String))) -> List(Int) { 31 + case rows { 32 + [] -> [] 33 + [first, ..] -> { 34 + list.index_map(first, fn(_cell, i) { 35 + list.fold(rows, 0, fn(max, row) { 36 + case list_at(row, i) { 37 + Ok(cell) -> 38 + case string.length(cell) > max { 39 + True -> string.length(cell) 40 + False -> max 41 + } 42 + Error(_) -> max 43 + } 44 + }) 45 + }) 46 + } 47 + } 48 + } 49 + 50 + fn list_at(items: List(String), index: Int) -> Result(String, Nil) { 51 + case items, index { 52 + [x, ..], 0 -> Ok(x) 53 + [_, ..rest], n if n > 0 -> list_at(rest, n - 1) 54 + _, _ -> Error(Nil) 55 + } 56 + } 57 + 58 + fn format_row(cells: List(String), widths: List(Int)) -> String { 59 + list.map2(cells, widths, fn(cell, width) { pad_right(cell, width) }) 60 + |> string.join(" ") 61 + } 62 + 63 + fn pad_right(s: String, width: Int) -> String { 64 + let pad = width - string.length(s) 65 + case pad > 0 { 66 + True -> s <> string.repeat(" ", pad) 67 + False -> s 68 + } 69 + } 70 + 71 + pub fn print_items(items: List(types.Item), empty: String) -> Nil { 72 + let rows = 73 + list.map(items, fn(i) { 74 + [ 75 + i.rkey, 76 + i.title, 77 + i.state, 78 + i.author.handle, 79 + sys.short_date(i.updated_at), 80 + ] 81 + }) 82 + print_table(["RKEY", "TITLE", "STATE", "AUTHOR", "UPDATED"], rows, empty) 83 + } 84 + 85 + pub fn print_repos(items: List(types.RepoItem)) -> Nil { 86 + let rows = 87 + list.map(items, fn(r) { 88 + [r.name, r.knot, r.description, sys.short_date(r.created_at)] 89 + }) 90 + print_table(["NAME", "KNOT", "DESCRIPTION", "CREATED"], rows, "No repositories found.") 91 + } 92 + 93 + pub fn print_strings(items: List(types.StringItem)) -> Nil { 94 + let rows = 95 + list.map(items, fn(s) { 96 + [s.rkey, s.filename, s.description, sys.short_date(s.created_at)] 97 + }) 98 + print_table( 99 + ["RKEY", "FILENAME", "DESCRIPTION", "CREATED"], 100 + rows, 101 + "No strings found.", 102 + ) 103 + } 104 + 105 + pub fn print_ssh_keys(items: List(types.SSHKeyItem)) -> Nil { 106 + let rows = 107 + list.map(items, fn(k) { 108 + [ 109 + types_extract_rkey(k.uri), 110 + k.name, 111 + truncate(k.key, 40), 112 + sys.short_date(k.created_at), 113 + ] 114 + }) 115 + print_table(["RKEY", "NAME", "KEY", "CREATED"], rows, "No SSH keys found.") 116 + } 117 + 118 + fn types_extract_rkey(uri: String) -> String { 119 + case string.split(uri, "/") { 120 + parts -> 121 + case list.last(parts) { 122 + Ok(l) -> l 123 + Error(_) -> uri 124 + } 125 + } 126 + } 127 + 128 + fn truncate(s: String, n: Int) -> String { 129 + case string.length(s) > n { 130 + True -> string.slice(s, 0, n) <> "…" 131 + False -> s 132 + } 133 + }
+96
src/gtg/sys.gleam
··· 1 + //// Process helpers: halt, wall-clock time, path utilities. 2 + 3 + import envoy 4 + import filepath 5 + import gleam/erlang/process 6 + import gleam/int 7 + import gleam/result 8 + import gleam/string 9 + import simplifile 10 + 11 + @external(erlang, "gtg_ffi", "halt") 12 + pub fn halt(code: Int) -> Nil 13 + 14 + @external(erlang, "gtg_ffi", "unix_seconds") 15 + pub fn unix_seconds() -> Int 16 + 17 + @external(erlang, "gtg_ffi", "unix_micros") 18 + pub fn unix_micros() -> Int 19 + 20 + @external(erlang, "gtg_ffi", "gzip") 21 + pub fn gzip(data: BitArray) -> BitArray 22 + 23 + @external(erlang, "gtg_ffi", "gunzip") 24 + pub fn gunzip(data: BitArray) -> Result(BitArray, String) 25 + 26 + @external(erlang, "gtg_ffi", "get_cwd") 27 + pub fn get_cwd() -> Result(String, String) 28 + 29 + /// Config directory: `$XDG_CONFIG_HOME/gtg` or `~/.config/gtg`. 30 + pub fn config_dir() -> String { 31 + case envoy.get("XDG_CONFIG_HOME") { 32 + Ok(xdg) if xdg != "" -> filepath.join(xdg, "gtg") 33 + _ -> 34 + case envoy.get("HOME") { 35 + Ok(home) -> filepath.join(filepath.join(home, ".config"), "gtg") 36 + Error(_) -> ".config/gtg" 37 + } 38 + } 39 + } 40 + 41 + /// Data directory: `$XDG_DATA_HOME/gtg` or `~/.local/share/gtg`. 42 + pub fn data_dir() -> String { 43 + case envoy.get("XDG_DATA_HOME") { 44 + Ok(xdg) if xdg != "" -> filepath.join(xdg, "gtg") 45 + _ -> 46 + case envoy.get("HOME") { 47 + Ok(home) -> 48 + filepath.join(filepath.join(filepath.join(home, ".local"), "share"), "gtg") 49 + Error(_) -> ".local/share/gtg" 50 + } 51 + } 52 + } 53 + 54 + pub fn ensure_dir(path: String) -> Result(Nil, String) { 55 + case simplifile.create_directory_all(path) { 56 + Ok(_) -> Ok(Nil) 57 + Error(e) -> Error(string.inspect(e)) 58 + } 59 + } 60 + 61 + /// RFC3339 UTC timestamp from wall clock (second precision). 62 + pub fn rfc3339_now() -> String { 63 + let secs = unix_seconds() 64 + // Approximate ISO without calendar: use unix seconds as epoch string fallback 65 + // Prefer Erlang calendar conversion via FFI. 66 + format_rfc3339(secs) 67 + } 68 + 69 + @external(erlang, "gtg_ffi", "format_rfc3339") 70 + fn format_rfc3339(secs: Int) -> String 71 + 72 + pub fn short_date(timestamp: String) -> String { 73 + case string.length(timestamp) > 10 { 74 + True -> string.slice(timestamp, 0, 10) 75 + False -> timestamp 76 + } 77 + } 78 + 79 + pub fn sleep_ms(ms: Int) -> Nil { 80 + process.sleep(ms) 81 + } 82 + 83 + pub fn env_or(name: String, default: String) -> String { 84 + envoy.get(name) |> result.unwrap(default) 85 + } 86 + 87 + pub fn env(name: String) -> Result(String, Nil) { 88 + case envoy.get(name) { 89 + Ok(v) if v != "" -> Ok(v) 90 + _ -> Error(Nil) 91 + } 92 + } 93 + 94 + pub fn int_to_string(n: Int) -> String { 95 + int.to_string(n) 96 + }
+265
src/gtg/tangled/client.gleam
··· 1 + //// Read-only Bobbin appview XRPC client. 2 + 3 + import gleam/dynamic.{type Dynamic} 4 + import gleam/dynamic/decode 5 + import gleam/json 6 + import gleam/list 7 + import gleam/option.{type Option, None, Some} 8 + import gleam/result 9 + import gleam/string 10 + import gtg/atproto/xrpc 11 + 12 + pub type Repo { 13 + Repo( 14 + uri: String, 15 + cid: String, 16 + name: String, 17 + knot: String, 18 + description: String, 19 + created_at: String, 20 + repo_did: String, 21 + ) 22 + } 23 + 24 + pub type ListItem { 25 + ListItem( 26 + uri: String, 27 + cid: String, 28 + value: Dynamic, 29 + state: String, 30 + state_updated_at: String, 31 + comment_count: Int, 32 + ) 33 + } 34 + 35 + pub type ClientError { 36 + Failed(String) 37 + } 38 + 39 + pub fn error_message(err: ClientError) -> String { 40 + case err { 41 + Failed(m) -> m 42 + } 43 + } 44 + 45 + pub fn get_repo(appview: String, repo_uri: String) -> Result(Repo, ClientError) { 46 + case 47 + xrpc.get( 48 + appview, 49 + "sh.tangled.repo.getRepo", 50 + [#("repo", repo_uri)], 51 + xrpc.NoAuth, 52 + ) 53 + { 54 + Error(e) -> Error(Failed(xrpc.error_message(e))) 55 + Ok(body) -> decode_repo_response(body) 56 + } 57 + } 58 + 59 + fn decode_repo_response(body: String) -> Result(Repo, ClientError) { 60 + let decoder = { 61 + use uri <- decode.field("uri", decode.string) 62 + use cid <- decode.optional_field("cid", "", decode.string) 63 + use value <- decode.field("value", decode.dynamic) 64 + decode.success(#(uri, cid, value)) 65 + } 66 + case json.parse(body, decoder) { 67 + Error(_) -> Error(Failed("decode getRepo")) 68 + Ok(#(uri, cid, value)) -> Ok(repo_from_value(uri, cid, value)) 69 + } 70 + } 71 + 72 + fn repo_from_value(uri: String, cid: String, value: Dynamic) -> Repo { 73 + Repo( 74 + uri:, 75 + cid:, 76 + name: field_string(value, "name"), 77 + knot: field_string(value, "knot"), 78 + description: field_string(value, "description"), 79 + created_at: field_string(value, "createdAt"), 80 + repo_did: field_string(value, "repoDid"), 81 + ) 82 + } 83 + 84 + pub fn list_repos(appview: String, owner_did: String) -> Result(List(Repo), ClientError) { 85 + fetch_all(fn(cursor) { 86 + let params = [ 87 + #("subject", owner_did), 88 + #("limit", "100"), 89 + ] 90 + let params = case cursor { 91 + Some(c) if c != "" -> list.append(params, [#("cursor", c)]) 92 + _ -> params 93 + } 94 + case xrpc.get(appview, "sh.tangled.repo.listRepos", params, xrpc.NoAuth) { 95 + Error(e) -> Error(Failed(xrpc.error_message(e))) 96 + Ok(body) -> decode_repo_list(body) 97 + } 98 + }) 99 + } 100 + 101 + fn decode_repo_list( 102 + body: String, 103 + ) -> Result(#(List(Repo), Option(String)), ClientError) { 104 + let item_decoder = { 105 + use uri <- decode.field("uri", decode.string) 106 + use cid <- decode.optional_field("cid", "", decode.string) 107 + use value <- decode.field("value", decode.dynamic) 108 + decode.success(#(uri, cid, value)) 109 + } 110 + let decoder = { 111 + use items <- decode.field("items", decode.list(item_decoder)) 112 + use cursor <- decode.optional_field( 113 + "cursor", 114 + None, 115 + decode.optional(decode.string), 116 + ) 117 + decode.success(#(items, cursor)) 118 + } 119 + case json.parse(body, decoder) { 120 + Error(_) -> Error(Failed("decode listRepos")) 121 + Ok(#(items, cursor)) -> { 122 + let repos = 123 + list.map(items, fn(it) { 124 + let #(uri, cid, value) = it 125 + repo_from_value(uri, cid, value) 126 + }) 127 + Ok(#(repos, cursor)) 128 + } 129 + } 130 + } 131 + 132 + pub fn list_issues( 133 + appview: String, 134 + repo_did: String, 135 + ) -> Result(List(ListItem), ClientError) { 136 + list_items(appview, "sh.tangled.repo.listIssues", repo_did) 137 + } 138 + 139 + pub fn list_pulls( 140 + appview: String, 141 + repo_did: String, 142 + ) -> Result(List(ListItem), ClientError) { 143 + list_items(appview, "sh.tangled.repo.listPulls", repo_did) 144 + } 145 + 146 + fn list_items( 147 + appview: String, 148 + nsid: String, 149 + subject: String, 150 + ) -> Result(List(ListItem), ClientError) { 151 + fetch_all(fn(cursor) { 152 + let params = [ 153 + #("subject", subject), 154 + #("limit", "100"), 155 + ] 156 + let params = case cursor { 157 + Some(c) if c != "" -> list.append(params, [#("cursor", c)]) 158 + _ -> params 159 + } 160 + case xrpc.get(appview, nsid, params, xrpc.NoAuth) { 161 + Error(e) -> Error(Failed(xrpc.error_message(e))) 162 + Ok(body) -> decode_list_items(body) 163 + } 164 + }) 165 + } 166 + 167 + fn decode_list_items( 168 + body: String, 169 + ) -> Result(#(List(ListItem), Option(String)), ClientError) { 170 + let item_decoder = { 171 + use uri <- decode.field("uri", decode.string) 172 + use cid <- decode.optional_field("cid", "", decode.string) 173 + use value <- decode.field("value", decode.dynamic) 174 + use state <- decode.optional_field("state", "", decode.string) 175 + use state_updated_at <- decode.optional_field( 176 + "stateUpdatedAt", 177 + "", 178 + decode.string, 179 + ) 180 + use comment_count <- decode.optional_field("commentCount", 0, decode.int) 181 + decode.success(ListItem( 182 + uri:, 183 + cid:, 184 + value:, 185 + state:, 186 + state_updated_at:, 187 + comment_count:, 188 + )) 189 + } 190 + let decoder = { 191 + use items <- decode.field("items", decode.list(item_decoder)) 192 + use cursor <- decode.optional_field( 193 + "cursor", 194 + None, 195 + decode.optional(decode.string), 196 + ) 197 + decode.success(#(items, cursor)) 198 + } 199 + case json.parse(body, decoder) { 200 + Ok(page) -> Ok(page) 201 + Error(_) -> Error(Failed("decode list items")) 202 + } 203 + } 204 + 205 + fn fetch_all( 206 + fetch: fn(Option(String)) -> Result(#(List(a), Option(String)), ClientError), 207 + ) -> Result(List(a), ClientError) { 208 + fetch_loop(fetch, None, [], 0) 209 + } 210 + 211 + fn fetch_loop( 212 + fetch: fn(Option(String)) -> Result(#(List(a), Option(String)), ClientError), 213 + cursor: Option(String), 214 + acc: List(a), 215 + page: Int, 216 + ) -> Result(List(a), ClientError) { 217 + case page >= 1000 { 218 + True -> Error(Failed("exceeded max appview pages")) 219 + False -> { 220 + use #(items, next) <- result.try(fetch(cursor)) 221 + let acc = list.append(acc, items) 222 + case next { 223 + Some(c) if c != "" -> fetch_loop(fetch, Some(c), acc, page + 1) 224 + _ -> Ok(acc) 225 + } 226 + } 227 + } 228 + } 229 + 230 + fn field_string(value: Dynamic, field: String) -> String { 231 + let decoder = { 232 + use v <- decode.field(field, decode.string) 233 + decode.success(v) 234 + } 235 + case decode.run(value, decoder) { 236 + Ok(s) -> s 237 + Error(_) -> "" 238 + } 239 + } 240 + 241 + pub fn is_canonical(repo: Repo) -> Bool { 242 + repo.name != "" && extract_rkey(repo.uri) == repo.name 243 + } 244 + 245 + pub fn extract_rkey(uri: String) -> String { 246 + case string.split(uri, "/") { 247 + [] -> uri 248 + parts -> 249 + case list.last(parts) { 250 + Ok(last) -> last 251 + Error(_) -> uri 252 + } 253 + } 254 + } 255 + 256 + pub fn extract_did(uri: String) -> String { 257 + let stripped = case string.starts_with(uri, "at://") { 258 + True -> string.drop_start(uri, 5) 259 + False -> uri 260 + } 261 + case string.split(stripped, "/") { 262 + [did, ..] -> did 263 + [] -> stripped 264 + } 265 + }
+147
src/gtg_ffi.erl
··· 1 + -module(gtg_ffi). 2 + -export([ 3 + halt/1, 4 + unix_seconds/0, 5 + unix_micros/0, 6 + gzip/1, 7 + gunzip/1, 8 + get_cwd/0, 9 + format_rfc3339/1, 10 + ed25519_generate/0, 11 + ed25519_public/1, 12 + ed25519_sign/2, 13 + lookup_txt/1, 14 + wait_http_callback/2 15 + ]). 16 + 17 + halt(Code) -> 18 + erlang:halt(Code). 19 + 20 + unix_seconds() -> 21 + erlang:system_time(second). 22 + 23 + unix_micros() -> 24 + erlang:system_time(microsecond). 25 + 26 + gzip(Data) when is_binary(Data) -> 27 + zlib:gzip(Data). 28 + 29 + gunzip(Data) when is_binary(Data) -> 30 + try 31 + {ok, zlib:gunzip(Data)} 32 + catch 33 + _:_ -> {error, <<"gunzip failed">>} 34 + end. 35 + 36 + get_cwd() -> 37 + case file:get_cwd() of 38 + {ok, Cwd} -> {ok, unicode:characters_to_binary(Cwd)}; 39 + {error, Reason} -> {error, unicode:characters_to_binary(io_lib:format("~p", [Reason]))} 40 + end. 41 + 42 + format_rfc3339(Secs) when is_integer(Secs) -> 43 + {{Y, Mo, D}, {H, Mi, S}} = calendar:system_time_to_universal_time(Secs, second), 44 + list_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y, Mo, D, H, Mi, S])). 45 + 46 + %% {PublicKey :: binary(), PrivateSeed :: binary()} — both 32 bytes. 47 + ed25519_generate() -> 48 + {Pub, Priv} = crypto:generate_key(eddsa, ed25519), 49 + Seed = 50 + case byte_size(Priv) of 51 + 32 -> Priv; 52 + 64 -> binary:part(Priv, 0, 32); 53 + _ -> Priv 54 + end, 55 + {Pub, Seed}. 56 + 57 + ed25519_public(Seed) when is_binary(Seed), byte_size(Seed) =:= 32 -> 58 + {Pub, _} = crypto:generate_key(eddsa, ed25519, Seed), 59 + Pub; 60 + ed25519_public(Priv) when is_binary(Priv), byte_size(Priv) =:= 64 -> 61 + Seed = binary:part(Priv, 0, 32), 62 + {Pub, _} = crypto:generate_key(eddsa, ed25519, Seed), 63 + Pub. 64 + 65 + ed25519_sign(Msg, Seed) when is_binary(Msg), is_binary(Seed) -> 66 + crypto:sign(eddsa, none, Msg, [Seed, ed25519]). 67 + 68 + lookup_txt(NameBin) when is_binary(NameBin) -> 69 + Name = unicode:characters_to_list(NameBin), 70 + case inet_res:lookup(Name, in, txt) of 71 + Records when is_list(Records) -> 72 + lists:flatmap(fun txt_strings/1, Records); 73 + _ -> 74 + [] 75 + end. 76 + 77 + txt_strings([H | _] = L) when is_list(H) -> 78 + [list_to_binary(lists:flatten(L))]; 79 + txt_strings(Txt) when is_list(Txt) -> 80 + [list_to_binary(Txt)]; 81 + txt_strings(Bin) when is_binary(Bin) -> 82 + [Bin]; 83 + txt_strings(_) -> 84 + []. 85 + 86 + %% Accept one HTTP request on 127.0.0.1:Port and return the raw query string 87 + %% from the request target (everything after '?'), or {error, ReasonBin}. 88 + wait_http_callback(Port, TimeoutMs) when is_integer(Port), is_integer(TimeoutMs) -> 89 + Opts = [ 90 + binary, 91 + {packet, raw}, 92 + {active, false}, 93 + {reuseaddr, true}, 94 + {ip, {127, 0, 0, 1}} 95 + ], 96 + case gen_tcp:listen(Port, Opts) of 97 + {ok, Listen} -> 98 + case gen_tcp:accept(Listen, TimeoutMs) of 99 + {ok, Sock} -> 100 + case gen_tcp:recv(Sock, 0, TimeoutMs) of 101 + {ok, Data} -> 102 + Reply = 103 + <<"HTTP/1.1 200 OK\r\n", 104 + "Content-Type: text/html; charset=utf-8\r\n", 105 + "Connection: close\r\n", 106 + "Content-Length: 62\r\n", 107 + "\r\n", 108 + "<html><body>Authenticated. You can close this tab.</body></html>">>, 109 + _ = gen_tcp:send(Sock, Reply), 110 + gen_tcp:close(Sock), 111 + gen_tcp:close(Listen), 112 + case extract_query(Data) of 113 + {ok, Qs} -> {ok, Qs}; 114 + error -> {error, <<"could not parse callback request">>} 115 + end; 116 + {error, Reason} -> 117 + gen_tcp:close(Sock), 118 + gen_tcp:close(Listen), 119 + {error, reason_bin(Reason)} 120 + end; 121 + {error, Reason} -> 122 + gen_tcp:close(Listen), 123 + {error, reason_bin(Reason)} 124 + end; 125 + {error, Reason} -> 126 + {error, reason_bin(Reason)} 127 + end. 128 + 129 + extract_query(Data) when is_binary(Data) -> 130 + %% First line: METHOD SP target SP HTTP/… 131 + case binary:split(Data, <<"\r\n">>) of 132 + [Line | _] -> 133 + case binary:split(Line, <<" ">>, [global]) of 134 + [_, Target | _] -> 135 + case binary:split(Target, <<"?">>) of 136 + [_, Qs] -> {ok, Qs}; 137 + _ -> {ok, <<>>} 138 + end; 139 + _ -> 140 + error 141 + end; 142 + _ -> 143 + error 144 + end. 145 + 146 + reason_bin(Reason) -> 147 + unicode:characters_to_binary(io_lib:format("~p", [Reason])).
+35
test/gtg_test.gleam
··· 1 + import gleam/string 2 + import gleeunit 3 + import gtg/app/service 4 + import gtg/app/types 5 + import gtg/atproto/tid 6 + import gtg/gitutil 7 + 8 + pub fn main() -> Nil { 9 + gleeunit.main() 10 + } 11 + 12 + pub fn parse_target_test() { 13 + let assert Ok(types.Target(handle: "alice.bsky.social", repo: "cool")) = 14 + service.parse_target("alice.bsky.social/cool") 15 + let assert Error(_) = service.parse_target("nopath") 16 + let assert Error(_) = service.parse_target("a/b/c") 17 + } 18 + 19 + pub fn parse_tangled_url_test() { 20 + let assert Ok(gitutil.RepoContext(handle: "aly.codes", repo: "tg")) = 21 + gitutil.parse_tangled_url("git@tangled.org:aly.codes/tg") 22 + let assert Ok(gitutil.RepoContext(handle: "aly.codes", repo: "tg")) = 23 + gitutil.parse_tangled_url("ssh://git@tangled.org/aly.codes/tg.git") 24 + let assert Error(_) = gitutil.parse_tangled_url("git@github.com:foo/bar") 25 + } 26 + 27 + pub fn tid_length_test() { 28 + let t = tid.new_now() 29 + let assert True = string.length(t) == 13 30 + } 31 + 32 + pub fn target_string_test() { 33 + let assert "a/b" = 34 + types.target_string(types.Target(handle: "a", repo: "b")) 35 + }