this repo has no description
0
fork

Configure Feed

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

keyeio

+1378
+2
stack/keyeio/.gitignore
··· 1 + _build 2 + *.install
+2
stack/keyeio/.ocamlformat
··· 1 + profile = default 2 + version = 0.26.2
+19
stack/keyeio/CLAUDE.md
··· 1 + This is a secure API key storage library for Eio applications. 2 + 3 + The library follows OCaml best practices with abstract types (`type t`) per 4 + module, comprehensive constructors/accessors, and proper pretty printers. Each 5 + core concept gets its own module with a clean interface. 6 + 7 + ## Current Status 8 + 9 + We are at STEP 1: Interface-only design. The keyeio.mli file documents the 10 + complete API without implementation. Once the interface is validated, we will 11 + proceed to STEP 2 (sample binaries) and STEP 3 (implementation). 12 + 13 + ## Design Principles 14 + 15 + - Store credentials in XDG_CONFIG_HOME/appname/keys/ with 0o600 permissions 16 + - JSON format supporting multiple profiles per service 17 + - Cmdliner integration following xdge patterns 18 + - Future-proof design for Secret Service API integration 19 + - Security-conscious pretty printing (mask sensitive values)
+273
stack/keyeio/README.md
··· 1 + # Keyeio - Secure API Key Storage for Eio Applications 2 + 3 + Keyeio provides secure storage and retrieval of API keys and credentials for Eio-based applications. It uses the XDG Base Directory Specification via the [xdge](../xdge) library for consistent, platform-appropriate storage locations. 4 + 5 + ## Features 6 + 7 + - 🔐 **Secure Storage**: Keys stored in XDG-compliant directories with strict file permissions (0o600) 8 + - 📁 **Multiple Profiles**: Support multiple configurations per service (default, production, staging, etc.) 9 + - 🎯 **Type-Safe API**: Abstract types and comprehensive accessors following OCaml best practices 10 + - 🖥️ **Cmdliner Integration**: Easy command-line argument handling matching xdge patterns 11 + - 🔮 **Future-Proof**: Designed to support Secret Service API (GNOME Keyring, KWallet) integration 12 + 13 + ## Installation 14 + 15 + ```bash 16 + opam install keyeio 17 + ``` 18 + 19 + ## Quick Start 20 + 21 + ### Basic Usage 22 + 23 + ```ocaml 24 + open Cmdliner 25 + 26 + let main (xdg, _) profile = 27 + (* Get credentials from profile *) 28 + let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 29 + let base_url = Keyeio.Profile.get profile ~key:"base_url" 30 + |> Option.value ~default:"https://api.example.com" in 31 + 32 + (* Use credentials with your API client *) 33 + Printf.printf "Connecting to %s\n" base_url; 34 + (* ... *) 35 + 36 + let () = 37 + Eio_main.run @@ fun env -> 38 + 39 + (* Create Cmdliner terms *) 40 + let xdg_term = Xdge.Cmd.term "myapp" env#fs () in 41 + let key_term = Keyeio.Cmd.term 42 + ~app_name:"myapp" 43 + ~fs:env#fs 44 + ~service:"immiche" (* Service name *) 45 + () in 46 + 47 + let cmd = Cmd.v (Cmd.info "myapp") 48 + Term.(const main $ xdg_term $ key_term) in 49 + exit (Cmd.eval cmd) 50 + ``` 51 + 52 + ### Storage Format 53 + 54 + Keys are stored in `~/.config/<appname>/keys/<service>.json`: 55 + 56 + ```json 57 + { 58 + "default": { 59 + "api_key": "abc123...", 60 + "base_url": "https://api.example.com" 61 + }, 62 + "production": { 63 + "api_key": "xyz789...", 64 + "base_url": "https://api.prod.example.com" 65 + }, 66 + "staging": { 67 + "api_key": "def456...", 68 + "base_url": "https://api.staging.example.com" 69 + } 70 + } 71 + ``` 72 + 73 + ## Examples 74 + 75 + The library includes comprehensive examples in `example/keyeio_example.ml`: 76 + 77 + ```bash 78 + # Build the example 79 + dune build keyeio/example/keyeio_example.exe 80 + 81 + # List all configured services 82 + ./keyeio_example.exe list 83 + 84 + # Show profiles for a service 85 + ./keyeio_example.exe profiles immiche 86 + 87 + # Use default profile 88 + ./keyeio_example.exe basic 89 + 90 + # Use a specific profile 91 + ./keyeio_example.exe basic --profile production 92 + 93 + # Simulate API client 94 + ./keyeio_example.exe client --profile staging 95 + ``` 96 + 97 + ### Example Output 98 + 99 + ``` 100 + $ ./keyeio_example.exe profiles immiche 101 + === List Profiles Example === 102 + Service: immiche 103 + Available profiles: 104 + - default (keys: api_key, base_url) 105 + - production (keys: api_key, base_url, extra_field) 106 + - staging (keys: api_key, base_url) 107 + 108 + Service details: 109 + Service immiche: 110 + default: 111 + Profile immiche.default: 112 + api_key: test_api*** 113 + base_url: https://immich.example.com 114 + production: 115 + Profile immiche.production: 116 + api_key: prod_api*** 117 + base_url: https://immich.prod.example.com 118 + extra_field: some_value 119 + ``` 120 + 121 + ## API Overview 122 + 123 + ### Creating a Keyeio Context 124 + 125 + ```ocaml 126 + val create : Xdge.t -> t 127 + ``` 128 + 129 + Creates a keyeio context from an XDG context. Keys are stored in `keys/` subdirectory of the config directory. 130 + 131 + ### Loading Services 132 + 133 + ```ocaml 134 + val load_service : t -> service:string -> (Service.t, [> `Msg of string]) result 135 + ``` 136 + 137 + Load all profiles for a service from `~/.config/<appname>/keys/<service>.json`. 138 + 139 + ```ocaml 140 + val list_services : t -> (string list, [> `Msg of string]) result 141 + ``` 142 + 143 + List all available services (JSON files in the keys directory). 144 + 145 + ### Working with Profiles 146 + 147 + ```ocaml 148 + module Profile : sig 149 + val service : t -> string 150 + val name : t -> string 151 + val get : t -> key:string -> string option 152 + val get_required : t -> key:string -> string (* raises Key_not_found *) 153 + val keys : t -> string list 154 + val pp : Format.formatter -> t -> unit 155 + end 156 + ``` 157 + 158 + ### Working with Services 159 + 160 + ```ocaml 161 + module Service : sig 162 + val name : t -> string 163 + val profile_names : t -> string list 164 + val get_profile : t -> string -> Profile.t option 165 + val default_profile : t -> Profile.t option 166 + val pp : Format.formatter -> t -> unit 167 + end 168 + ``` 169 + 170 + ### Cmdliner Integration 171 + 172 + ```ocaml 173 + module Cmd : sig 174 + val term : 175 + app_name:string -> 176 + fs:Eio.Fs.dir_ty Eio.Path.t -> 177 + service:string -> 178 + ?profile:string -> (* Default: "default" *) 179 + ?key_file:bool -> (* Add --key-file flag, default: true *) 180 + unit -> 181 + Profile.t Cmdliner.Term.t 182 + 183 + val env_docs : app_name:string -> service:string -> unit -> string 184 + end 185 + ``` 186 + 187 + The `term` function generates: 188 + - `--profile NAME`: Select which profile to use 189 + - `--key-file PATH`: Override with direct file path (optional) 190 + 191 + ## Security 192 + 193 + ### Current Security Model 194 + 195 + - Keys stored as JSON files in `~/.config/<appname>/keys/` 196 + - Files created with permissions `0o600` (owner read/write only) 197 + - Sensitive values masked in pretty-printing output 198 + - Follows standard Unix file permission security model 199 + 200 + ### Future Enhancements 201 + 202 + The library is designed to support future integration with system keychains: 203 + 204 + - **Linux**: freedesktop.org Secret Service API (GNOME Keyring, KWallet, KeePassXC) 205 + - **macOS**: Keychain Services API 206 + - **Windows**: Credential Manager API 207 + 208 + The abstract backend type allows adding these integrations without breaking the API. 209 + 210 + ## Integration Examples 211 + 212 + ### With Immiche (Immich API Client) 213 + 214 + ```ocaml 215 + let main (xdg, _) profile = 216 + Eio_main.run @@ fun env -> 217 + 218 + let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 219 + let base_url = Keyeio.Profile.get_required profile ~key:"base_url" in 220 + 221 + let client = Immiche.create ~sw ~env ~base_url ~api_key () in 222 + (* Use client... *) 223 + 224 + let () = 225 + Eio_main.run @@ fun env -> 226 + let xdg_term = Xdge.Cmd.term "myapp" env#fs () in 227 + let key_term = Keyeio.Cmd.term ~app_name:"myapp" ~fs:env#fs ~service:"immiche" () in 228 + (* ... *) 229 + ``` 230 + 231 + ### With Karakeepe (Hoarder API Client) 232 + 233 + ```ocaml 234 + let key_term = Keyeio.Cmd.term 235 + ~app_name:"myapp" 236 + ~fs:env#fs 237 + ~service:"karakeepe" 238 + () in 239 + 240 + let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 241 + let base_url = Keyeio.Profile.get_required profile ~key:"base_url" in 242 + 243 + let bookmarks = Karakeepe.fetch_all_bookmarks ~sw ~env ~api_key base_url in 244 + (* Process bookmarks... *) 245 + ``` 246 + 247 + ## Environment Variables 248 + 249 + The storage location respects XDG Base Directory Specification: 250 + 251 + - `XDG_CONFIG_HOME`: Base directory for config files (default: `~/.config`) 252 + - `<APPNAME>_CONFIG_DIR`: Application-specific override (highest priority) 253 + 254 + Keys are stored in: `$XDG_CONFIG_HOME/<appname>/keys/<service>.json` 255 + 256 + ## Documentation 257 + 258 + Full API documentation is available: 259 + 260 + ```bash 261 + dune build @doc 262 + open _build/default/_doc/_html/keyeio/index.html 263 + ``` 264 + 265 + ## License 266 + 267 + ISC License 268 + 269 + ## See Also 270 + 271 + - [xdge](../xdge) - XDG Base Directory Specification for Eio 272 + - [immiche](../immiche) - Immich API client using keyeio 273 + - [karakeepe](../karakeepe) - Hoarder API client using keyeio
+33
stack/keyeio/dune-project
··· 1 + (lang dune 3.20) 2 + 3 + (name keyeio) 4 + 5 + (generate_opam_files true) 6 + 7 + (license ISC) 8 + (authors "Anil Madhavapeddy") 9 + (homepage "https://tangled.sh/@anil.recoil.org") 10 + (maintainers "Anil Madhavapeddy <anil@recoil.org>") 11 + (bug_reports https://tangled.sh/@anil.recoil.org/keyeio) 12 + (maintenance_intent "(latest)") 13 + 14 + (package 15 + (name keyeio) 16 + (synopsis "Secure API key storage for Eio applications using XDG directories") 17 + (description 18 + "Keyeio provides secure storage and retrieval of API keys and credentials \ 19 + for Eio-based applications. It uses XDG Base Directory Specification via \ 20 + the xdge library for consistent storage locations, supports multiple profiles \ 21 + per service, and includes Cmdliner integration for easy command-line usage. \ 22 + The library is designed to allow future integration with system keychains \ 23 + like GNOME Keyring and macOS Keychain via the Secret Service API.") 24 + (depends 25 + (ocaml (>= 5.1.0)) 26 + (eio (>= 1.1)) 27 + eio_main 28 + (xdge (>= 0.1.0)) 29 + (yojson (>= 2.0.0)) 30 + (cmdliner (>= 1.2.0)) 31 + (fmt (>= 0.11.0)) 32 + (odoc :with-doc) 33 + (alcotest (and :with-test (>= 1.7.0)))))
+3
stack/keyeio/example/dune
··· 1 + (executable 2 + (name keyeio_example) 3 + (libraries keyeio xdge eio_main cmdliner fmt))
+162
stack/keyeio/example/keyeio_example.ml
··· 1 + (** Example demonstrating keyeio library usage *) 2 + 3 + open Cmdliner 4 + 5 + (** Example 1: Basic usage with single service *) 6 + let basic_example (_xdg, _xdg_cmd) profile = 7 + Fmt.pr "=== Basic Example ===@."; 8 + Fmt.pr "Service: %s@." (Keyeio.Profile.service profile); 9 + Fmt.pr "Profile: %s@." (Keyeio.Profile.name profile); 10 + 11 + (* Get required API key *) 12 + let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 13 + Fmt.pr "API Key loaded: %s@." (String.sub api_key 0 (min 10 (String.length api_key)) ^ "..."); 14 + 15 + (* Get optional base URL *) 16 + (match Keyeio.Profile.get profile ~key:"base_url" with 17 + | Some url -> Fmt.pr "Base URL: %s@." url 18 + | None -> Fmt.pr "No base URL configured@."); 19 + 20 + (* List all available keys *) 21 + let keys = Keyeio.Profile.keys profile in 22 + Fmt.pr "Available keys: %a@." Fmt.(list ~sep:comma string) keys; 23 + 24 + (* Pretty print the profile *) 25 + Fmt.pr "@.Profile details:@.%a@." Keyeio.Profile.pp profile 26 + 27 + (** Example 2: List all services *) 28 + let list_services_example (xdg, _xdg_cmd) = 29 + Fmt.pr "=== List Services Example ===@."; 30 + 31 + let keyeio = Keyeio.create xdg in 32 + 33 + match Keyeio.list_services keyeio with 34 + | Ok services -> 35 + if services = [] then 36 + Fmt.pr "No services configured yet@." 37 + else begin 38 + Fmt.pr "Available services:@."; 39 + List.iter (fun svc -> Fmt.pr " - %s@." svc) services 40 + end 41 + | Error (`Msg msg) -> 42 + Fmt.epr "Error listing services: %s@." msg 43 + 44 + (** Example 3: Load service and list profiles *) 45 + let list_profiles_example (xdg, _xdg_cmd) service_name = 46 + Fmt.pr "=== List Profiles Example ===@."; 47 + 48 + let keyeio = Keyeio.create xdg in 49 + 50 + match Keyeio.load_service keyeio ~service:service_name with 51 + | Ok service -> 52 + Fmt.pr "Service: %s@." (Keyeio.Service.name service); 53 + 54 + let profiles = Keyeio.Service.profile_names service in 55 + Fmt.pr "Available profiles:@."; 56 + List.iter (fun name -> 57 + Fmt.pr " - %s" name; 58 + match Keyeio.Service.get_profile service name with 59 + | Some prof -> 60 + let keys = Keyeio.Profile.keys prof in 61 + Fmt.pr " (keys: %a)" Fmt.(list ~sep:comma string) keys 62 + | None -> () 63 + ) profiles; 64 + Fmt.pr "@."; 65 + 66 + (* Pretty print the service *) 67 + Fmt.pr "@.Service details:@.%a@." Keyeio.Service.pp service 68 + | Error (`Msg msg) -> 69 + Fmt.epr "Error loading service '%s': %s@." service_name msg 70 + 71 + (** Example 4: Simulated API client using loaded credentials *) 72 + let api_client_example (_xdg, _xdg_cmd) profile = 73 + Fmt.pr "=== API Client Example ===@."; 74 + 75 + (* Simulate creating an API client with loaded credentials *) 76 + let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 77 + let base_url = Keyeio.Profile.get profile ~key:"base_url" 78 + |> Option.value ~default:"https://api.example.com" in 79 + 80 + Fmt.pr "Creating API client for %s@." (Keyeio.Profile.service profile); 81 + Fmt.pr " Base URL: %s@." base_url; 82 + Fmt.pr " Profile: %s@." (Keyeio.Profile.name profile); 83 + Fmt.pr " Authenticated: yes@."; 84 + 85 + (* Simulate making an API call *) 86 + Fmt.pr "@.Simulating API request...@."; 87 + Fmt.pr "GET %s/api/status@." base_url; 88 + Fmt.pr "Authorization: Bearer %s@." (String.sub api_key 0 (min 8 (String.length api_key)) ^ "..."); 89 + Fmt.pr "@.Response: 200 OK@." 90 + 91 + (** Main command dispatcher *) 92 + let () = 93 + Eio_main.run @@ fun env -> 94 + 95 + (* Common terms *) 96 + let xdg_term = Xdge.Cmd.term "keyeio-example" env#fs () in 97 + 98 + (* Command: basic - Basic usage example *) 99 + let basic_cmd = 100 + let profile_term = Keyeio.Cmd.term 101 + ~app_name:"keyeio-example" 102 + ~fs:env#fs 103 + ~service:"immiche" 104 + () in 105 + let info = Cmd.info "basic" ~doc:"Basic keyeio usage example" in 106 + Cmd.v info Term.(const basic_example $ xdg_term $ profile_term) 107 + in 108 + 109 + (* Command: list - List all services *) 110 + let list_cmd = 111 + let info = Cmd.info "list" ~doc:"List all configured services" in 112 + Cmd.v info Term.(const list_services_example $ xdg_term) 113 + in 114 + 115 + (* Command: profiles - List profiles for a service *) 116 + let profiles_cmd = 117 + let service_arg = 118 + let doc = "Service name to inspect" in 119 + Arg.(required & pos 0 (some string) None & info [] ~docv:"SERVICE" ~doc) 120 + in 121 + let info = Cmd.info "profiles" ~doc:"List profiles for a service" in 122 + Cmd.v info Term.(const list_profiles_example $ xdg_term $ service_arg) 123 + in 124 + 125 + (* Command: client - API client simulation *) 126 + let client_cmd = 127 + let profile_term = Keyeio.Cmd.term 128 + ~app_name:"keyeio-example" 129 + ~fs:env#fs 130 + ~service:"immiche" 131 + ~profile:"default" 132 + () in 133 + let info = Cmd.info "client" ~doc:"Simulate API client with credentials" in 134 + Cmd.v info Term.(const api_client_example $ xdg_term $ profile_term) 135 + in 136 + 137 + (* Main command group *) 138 + let main_cmd = 139 + let info = Cmd.info "keyeio-example" 140 + ~version:"0.1.0" 141 + ~doc:"Examples demonstrating keyeio library usage" 142 + ~man:[ 143 + `S Manpage.s_description; 144 + `P "This program demonstrates various usage patterns for the keyeio library."; 145 + `P "Keyeio provides secure API key storage using XDG directories with support for multiple profiles per service."; 146 + `S "EXAMPLES"; 147 + `P "List all configured services:"; 148 + `Pre " $(b,keyeio-example list)"; 149 + `P "Show profiles for a service:"; 150 + `Pre " $(b,keyeio-example profiles immiche)"; 151 + `P "Basic usage with default profile:"; 152 + `Pre " $(b,keyeio-example basic)"; 153 + `P "Use a specific profile:"; 154 + `Pre " $(b,keyeio-example basic --profile production)"; 155 + `P "Simulate an API client:"; 156 + `Pre " $(b,keyeio-example client --profile staging)"; 157 + ] 158 + in 159 + Cmd.group info [basic_cmd; list_cmd; profiles_cmd; client_cmd] 160 + in 161 + 162 + exit (Cmd.eval main_cmd)
+37
stack/keyeio/keyeio.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Secure API key storage for Eio applications using XDG directories" 4 + description: 5 + "Keyeio provides secure storage and retrieval of API keys and credentials for Eio-based applications. It uses XDG Base Directory Specification via the xdge library for consistent storage locations, supports multiple profiles per service, and includes Cmdliner integration for easy command-line usage. The library is designed to allow future integration with system keychains like GNOME Keyring and macOS Keychain via the Secret Service API." 6 + maintainer: ["Anil Madhavapeddy <anil@recoil.org>"] 7 + authors: ["Anil Madhavapeddy"] 8 + license: "ISC" 9 + homepage: "https://tangled.sh/@anil.recoil.org" 10 + bug-reports: "https://tangled.sh/@anil.recoil.org/keyeio" 11 + depends: [ 12 + "dune" {>= "3.20"} 13 + "ocaml" {>= "5.1.0"} 14 + "eio" {>= "1.1"} 15 + "eio_main" 16 + "xdge" {>= "0.1.0"} 17 + "yojson" {>= "2.0.0"} 18 + "cmdliner" {>= "1.2.0"} 19 + "fmt" {>= "0.11.0"} 20 + "odoc" {with-doc} 21 + "alcotest" {with-test & >= "1.7.0"} 22 + ] 23 + build: [ 24 + ["dune" "subst"] {dev} 25 + [ 26 + "dune" 27 + "build" 28 + "-p" 29 + name 30 + "-j" 31 + jobs 32 + "@install" 33 + "@runtest" {with-test} 34 + "@doc" {with-doc} 35 + ] 36 + ] 37 + x-maintenance-intent: ["(latest)"]
+4
stack/keyeio/lib/dune
··· 1 + (library 2 + (public_name keyeio) 3 + (name keyeio) 4 + (libraries eio eio_main xdge yojson cmdliner fmt))
+259
stack/keyeio/lib/keyeio.ml
··· 1 + (** Secure API key storage for Eio applications using XDG directories *) 2 + 3 + (** {1 Exceptions} *) 4 + 5 + exception Key_not_found of string 6 + exception Profile_not_found of string 7 + exception Invalid_key_file of string 8 + 9 + (** {1 Profile Implementation} *) 10 + 11 + module Profile = struct 12 + type t = { 13 + service : string; 14 + name : string; 15 + data : (string * string) list; 16 + } 17 + 18 + let service t = t.service 19 + let name t = t.name 20 + 21 + let get t ~key = List.assoc_opt key t.data 22 + 23 + let get_required t ~key = 24 + match get t ~key with 25 + | Some value -> value 26 + | None -> 27 + raise 28 + (Key_not_found 29 + (Printf.sprintf "Key '%s' not found in profile '%s' of service '%s'" key 30 + t.name t.service)) 31 + 32 + let keys t = List.map fst t.data 33 + 34 + let to_json t = 35 + let obj = List.map (fun (k, v) -> (k, `String v)) t.data in 36 + `Assoc obj 37 + 38 + let pp ppf t = 39 + let mask_sensitive key = 40 + let lower = String.lowercase_ascii key in 41 + String.contains lower 'k' && String.contains lower 'e' && String.contains lower 'y' 42 + || String.contains lower 't' && String.contains lower 'o' && String.contains lower 'k' 43 + || String.contains lower 'p' 44 + && String.contains lower 'a' 45 + && String.contains lower 's' 46 + && String.contains lower 's' 47 + in 48 + Fmt.pf ppf "@[<v 2>Profile %s.%s:@," t.service t.name; 49 + List.iter 50 + (fun (k, v) -> 51 + if mask_sensitive k then 52 + Fmt.pf ppf " %s: %s@," k (String.sub v 0 (min 8 (String.length v)) ^ "***") 53 + else Fmt.pf ppf " %s: %s@," k v) 54 + t.data; 55 + Fmt.pf ppf "@]" 56 + end 57 + 58 + (** {1 Service Implementation} *) 59 + 60 + module Service = struct 61 + type t = { 62 + name : string; 63 + profiles : (string * Profile.t) list; 64 + } 65 + 66 + let name t = t.name 67 + let profile_names t = List.map fst t.profiles 68 + let get_profile t name = List.assoc_opt name t.profiles 69 + let default_profile t = get_profile t "default" 70 + 71 + let pp ppf t = 72 + Fmt.pf ppf "@[<v 2>Service %s:@," t.name; 73 + List.iter 74 + (fun (pname, profile) -> Fmt.pf ppf "@[<v 2>%s:@,%a@]@," pname Profile.pp profile) 75 + t.profiles; 76 + Fmt.pf ppf "@]" 77 + end 78 + 79 + (** {1 Main Context} *) 80 + 81 + type backend = Filesystem of { keys_dir : Eio.Fs.dir_ty Eio.Path.t } 82 + 83 + type t = { xdg : Xdge.t; backend : backend } 84 + 85 + let create xdg = 86 + (* Keys are stored in a "keys" subdirectory of the config directory *) 87 + let config_dir = Xdge.config_dir xdg in 88 + let keys_dir = Eio.Path.(config_dir / "keys") in 89 + 90 + (* Create keys directory with restrictive permissions *) 91 + (try Eio.Path.mkdir ~perm:0o700 keys_dir with 92 + | Eio.Io (Eio.Fs.E (Already_exists _), _) -> ()); 93 + 94 + { xdg; backend = Filesystem { keys_dir } } 95 + 96 + (** {1 JSON Parsing Helpers} *) 97 + 98 + let parse_profile ~service ~profile_name json = 99 + match json with 100 + | `Assoc fields -> 101 + let data = 102 + List.filter_map 103 + (fun (k, v) -> 104 + match v with 105 + | `String s -> Some (k, s) 106 + | _ -> None) 107 + fields 108 + in 109 + { Profile.service; name = profile_name; data } 110 + | _ -> 111 + raise 112 + (Invalid_key_file 113 + (Printf.sprintf "Profile '%s' in service '%s' is not a JSON object" 114 + profile_name service)) 115 + 116 + let parse_service_file ~service json = 117 + match json with 118 + | `Assoc profile_list -> 119 + let profiles = 120 + List.map 121 + (fun (profile_name, profile_json) -> 122 + (profile_name, parse_profile ~service ~profile_name profile_json)) 123 + profile_list 124 + in 125 + { Service.name = service; profiles } 126 + | _ -> 127 + raise 128 + (Invalid_key_file (Printf.sprintf "Service file '%s.json' is not a JSON object" service)) 129 + 130 + (** {1 File Operations} *) 131 + 132 + let load_service t ~service = 133 + match t.backend with 134 + | Filesystem { keys_dir } -> 135 + let service_file = Eio.Path.(keys_dir / (service ^ ".json")) in 136 + (try 137 + (* Read and parse the JSON file *) 138 + let content = Eio.Path.load service_file in 139 + let json = Yojson.Basic.from_string content in 140 + let service_data = parse_service_file ~service json in 141 + Ok service_data 142 + with 143 + | Eio.Io (Eio.Fs.E (Not_found _), _) -> 144 + Error (`Msg (Printf.sprintf "Service file not found: %s.json" service)) 145 + | Yojson.Json_error msg -> 146 + Error (`Msg (Printf.sprintf "Invalid JSON in %s.json: %s" service msg)) 147 + | Invalid_key_file msg -> Error (`Msg msg) 148 + | exn -> Error (`Msg (Printf.sprintf "Error loading service: %s" (Printexc.to_string exn)))) 149 + 150 + let list_services t = 151 + match t.backend with 152 + | Filesystem { keys_dir } -> 153 + (try 154 + let entries = Eio.Path.read_dir keys_dir in 155 + let services = 156 + List.filter_map 157 + (fun entry -> 158 + if String.ends_with ~suffix:".json" entry then 159 + Some (String.sub entry 0 (String.length entry - 5)) 160 + else None) 161 + entries 162 + in 163 + Ok (List.sort String.compare services) 164 + with 165 + | Eio.Io (Eio.Fs.E (Not_found _), _) -> 166 + (* Keys directory doesn't exist yet *) 167 + Ok [] 168 + | exn -> Error (`Msg (Printf.sprintf "Error listing services: %s" (Printexc.to_string exn)))) 169 + 170 + let pp ppf t = 171 + match t.backend with 172 + | Filesystem { keys_dir } -> 173 + Fmt.pf ppf "@[<v 2>Keyeio:@,"; 174 + Fmt.pf ppf "Keys directory: %s@," (Eio.Path.native_exn keys_dir); 175 + Fmt.pf ppf "Application: %s@," (Xdge.app_name t.xdg); 176 + Fmt.pf ppf "@]" 177 + 178 + (** {1 Cmdliner Integration} *) 179 + 180 + module Cmd = struct 181 + type keyeio_t = t 182 + 183 + let term ~app_name ~fs ~service ?profile:(default_profile = "default") 184 + ?(key_file = true) () = 185 + let open Cmdliner in 186 + (* Profile selection flag *) 187 + let profile_flag = 188 + let doc = Printf.sprintf "Profile name to use for %s service" service in 189 + Arg.(value & opt string default_profile & info [ "profile" ] ~docv:"NAME" ~doc) 190 + in 191 + 192 + (* Optional key file override *) 193 + let key_file_flag = 194 + if key_file then 195 + let doc = Printf.sprintf "Override with direct path to %s key file" service in 196 + Some Arg.(value & opt (some file) None & info [ "key-file" ] ~docv:"FILE" ~doc) 197 + else None 198 + in 199 + 200 + (* Term that loads the profile *) 201 + let load_profile profile_name key_file_path = 202 + (* If key_file path is provided, load from there *) 203 + match key_file_path with 204 + | Some path -> 205 + (try 206 + let content = In_channel.with_open_bin path In_channel.input_all in 207 + let json = Yojson.Basic.from_string content in 208 + match parse_service_file ~service json with 209 + | svc -> 210 + (match Service.get_profile svc profile_name with 211 + | Some prof -> prof 212 + | None -> 213 + failwith 214 + (Printf.sprintf "Profile '%s' not found in %s" profile_name path)) 215 + | exception exn -> 216 + failwith 217 + (Printf.sprintf "Error loading key file %s: %s" path 218 + (Printexc.to_string exn)) 219 + with 220 + | Sys_error msg -> failwith (Printf.sprintf "Cannot read key file: %s" msg)) 221 + | None -> 222 + (* Load from XDG directory *) 223 + let xdg = Xdge.create fs app_name in 224 + let keyeio = create xdg in 225 + (match load_service keyeio ~service with 226 + | Ok svc -> 227 + (match Service.get_profile svc profile_name with 228 + | Some prof -> prof 229 + | None -> 230 + failwith 231 + (Printf.sprintf "Profile '%s' not found in service '%s'" profile_name 232 + service)) 233 + | Error (`Msg msg) -> failwith msg) 234 + in 235 + 236 + (* Build the term *) 237 + match key_file_flag with 238 + | Some kf_flag -> Term.(const load_profile $ profile_flag $ kf_flag) 239 + | None -> Term.(const load_profile $ profile_flag $ const None) 240 + 241 + let env_docs ~app_name ~service () = 242 + Printf.sprintf 243 + {|ENVIRONMENT 244 + Keys are stored in the XDG config directory under a 'keys' subdirectory. 245 + The location is determined by the XDG Base Directory Specification: 246 + 247 + XDG_CONFIG_HOME 248 + Base directory for configuration files. If not set, defaults to 249 + $HOME/.config. Keys for %s will be stored in: 250 + $XDG_CONFIG_HOME/%s/keys/%s.json 251 + 252 + Example locations: 253 + ~/.config/%s/keys/%s.json (default) 254 + /custom/config/%s/keys/%s.json (if XDG_CONFIG_HOME=/custom/config) 255 + 256 + File permissions should be 0600 (owner read/write only) for security. 257 + |} 258 + app_name app_name service app_name service app_name service 259 + end
+437
stack/keyeio/lib/keyeio.mli
··· 1 + (** Secure API key storage for Eio applications using XDG directories 2 + 3 + This library provides secure storage and retrieval of API keys and credentials 4 + for Eio-based applications. It integrates with the XDG Base Directory 5 + Specification via the xdge library to store credentials in a consistent, 6 + platform-appropriate location. 7 + 8 + {b Key Features:} 9 + 10 + - Store API keys in XDG-compliant directories with proper permissions 11 + - Support multiple profiles per service (production, staging, development) 12 + - JSON-based storage format for flexibility 13 + - Cmdliner integration for easy command-line usage 14 + - Designed for future Secret Service API integration 15 + 16 + {b Security Model:} 17 + 18 + Currently, credentials are stored as JSON files in [XDG_CONFIG_HOME/appname/keys/] 19 + with strict filesystem permissions (0o600 - owner read/write only). This follows 20 + common practice for CLI tools and provides reasonable security for single-user 21 + systems. 22 + 23 + The design supports future integration with system keychains via the 24 + freedesktop.org Secret Service API (GNOME Keyring, KWallet, KeePassXC) 25 + without breaking the API. 26 + 27 + {b Storage Structure:} 28 + 29 + Keys are stored in [XDG_CONFIG_HOME/appname/keys/SERVICE.json] where SERVICE 30 + is the name of the service (e.g., "immiche", "karakeepe"). Each service file 31 + contains one or more named profiles: 32 + 33 + {v 34 + { 35 + "default": { 36 + "api_key": "abc123...", 37 + "base_url": "https://api.example.com" 38 + }, 39 + "production": { 40 + "api_key": "xyz789...", 41 + "base_url": "https://api.prod.example.com" 42 + } 43 + } 44 + v} 45 + 46 + {b Example Usage:} 47 + 48 + {[ 49 + open Cmdliner 50 + 51 + let main (xdg, _) profile = 52 + Eio_main.run @@ fun env -> 53 + 54 + let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 55 + let base_url = Keyeio.Profile.get_required profile ~key:"base_url" in 56 + 57 + (* Use credentials to create API client *) 58 + let client = Immiche.create ~sw ~env ~base_url ~api_key () in 59 + (* ... *) 60 + 61 + let () = 62 + let xdg_term = Xdge.Cmd.term "myapp" env#fs () in 63 + let key_term = Keyeio.Cmd.term 64 + ~app_name:"myapp" 65 + ~service:"immiche" 66 + env#fs () in 67 + 68 + let cmd = Cmd.v (Cmd.info "myapp") 69 + Term.(const main $ xdg_term $ key_term) in 70 + exit (Cmd.eval cmd) 71 + ]} 72 + 73 + @see <https://specifications.freedesktop.org/basedir-spec/latest/> XDG Base Directory Specification 74 + @see <https://specifications.freedesktop.org/secret-service-spec/latest/> Secret Service API *) 75 + 76 + (** The main Keyeio context type containing directory paths and configuration. 77 + 78 + A value of type [t] represents the keyeio configuration for a specific 79 + application, including the keys directory path and storage backend. *) 80 + type t 81 + 82 + (** {1 Exceptions} *) 83 + 84 + (** Exception raised when a required key is not found in a profile. *) 85 + exception Key_not_found of string 86 + 87 + (** Exception raised when a profile is not found in a service. *) 88 + exception Profile_not_found of string 89 + 90 + (** Exception raised when attempting to access invalid JSON structure. *) 91 + exception Invalid_key_file of string 92 + 93 + (** {1 Profile} *) 94 + 95 + (** A profile represents a set of credentials for one service configuration. 96 + 97 + Profiles allow you to store multiple sets of credentials for the same 98 + service. For example, you might have "default", "production", and "staging" 99 + profiles for an API service, each with different API keys and endpoints. 100 + 101 + Each profile contains arbitrary key-value pairs stored as strings. Common 102 + keys include "api_key", "base_url", "email", etc., but applications are 103 + free to store any configuration needed. *) 104 + module Profile : sig 105 + (** The type of a credential profile. *) 106 + type t 107 + 108 + (** [service t] returns the service name this profile belongs to. 109 + 110 + @return The service name (e.g., "immiche", "karakeepe") *) 111 + val service : t -> string 112 + 113 + (** [name t] returns the profile name. 114 + 115 + @return The profile name (e.g., "default", "production", "staging") *) 116 + val name : t -> string 117 + 118 + (** [get t ~key] retrieves a value from the profile. 119 + 120 + @param t The profile to query 121 + @param key The key to look up 122 + @return [Some value] if the key exists, [None] otherwise 123 + 124 + {b Example:} 125 + {[ 126 + match Profile.get profile ~key:"api_key" with 127 + | Some key -> Printf.printf "API key: %s\n" key 128 + | None -> Printf.printf "No API key found\n" 129 + ]} *) 130 + val get : t -> key:string -> string option 131 + 132 + (** [get_required t ~key] retrieves a value that must exist. 133 + 134 + @param t The profile to query 135 + @param key The key to look up 136 + @return The value associated with the key 137 + @raise Key_not_found if the key does not exist 138 + 139 + {b Example:} 140 + {[ 141 + let api_key = Profile.get_required profile ~key:"api_key" in 142 + (* Use api_key, knowing it exists *) 143 + ]} *) 144 + val get_required : t -> key:string -> string 145 + 146 + (** [keys t] returns all keys available in this profile. 147 + 148 + @param t The profile to query 149 + @return A list of all key names in the profile 150 + 151 + {b Example:} 152 + {[ 153 + let available = Profile.keys profile in 154 + List.iter (fun k -> Printf.printf "Available key: %s\n" k) available 155 + ]} *) 156 + val keys : t -> string list 157 + 158 + (** [to_json t] converts the profile to a JSON representation. 159 + 160 + Returns a JSON object containing all key-value pairs in the profile. 161 + 162 + @param t The profile to convert 163 + @return A JSON object representation *) 164 + val to_json : t -> Yojson.Basic.t 165 + 166 + (** [pp ppf t] pretty prints a profile for debugging. 167 + 168 + Displays the service name, profile name, and all key-value pairs. 169 + Sensitive values (keys containing "key", "token", "password") are 170 + masked for security. 171 + 172 + @param ppf The formatter to print to 173 + @param t The profile to print *) 174 + val pp : Format.formatter -> t -> unit 175 + end 176 + 177 + (** {1 Service} *) 178 + 179 + (** A service represents all profiles for a given service. 180 + 181 + Services group together multiple profiles for the same API or service. 182 + For example, an "immiche" service might contain "default", "production", 183 + and "staging" profiles, each with their own credentials. 184 + 185 + Services are loaded from JSON files in the keys directory, with one file 186 + per service: [XDG_CONFIG_HOME/appname/keys/SERVICE.json] *) 187 + module Service : sig 188 + (** The type of a service containing multiple profiles. *) 189 + type t 190 + 191 + (** [name t] returns the service name. 192 + 193 + @return The service name (e.g., "immiche", "karakeepe") *) 194 + val name : t -> string 195 + 196 + (** [profile_names t] returns all available profile names for this service. 197 + 198 + @param t The service to query 199 + @return A list of profile names (e.g., ["default"; "production"; "staging"]) 200 + 201 + {b Example:} 202 + {[ 203 + let profiles = Service.profile_names service in 204 + Printf.printf "Available profiles: %s\n" (String.concat ", " profiles) 205 + ]} *) 206 + val profile_names : t -> string list 207 + 208 + (** [get_profile t name] retrieves a specific profile by name. 209 + 210 + @param t The service to query 211 + @param name The profile name to retrieve 212 + @return [Some profile] if found, [None] otherwise 213 + 214 + {b Example:} 215 + {[ 216 + match Service.get_profile service "production" with 217 + | Some prof -> (* Use production profile *) 218 + | None -> failwith "Production profile not configured" 219 + ]} *) 220 + val get_profile : t -> string -> Profile.t option 221 + 222 + (** [default_profile t] retrieves the "default" profile if it exists. 223 + 224 + This is a convenience function equivalent to [get_profile t "default"]. 225 + 226 + @param t The service to query 227 + @return [Some profile] if a "default" profile exists, [None] otherwise *) 228 + val default_profile : t -> Profile.t option 229 + 230 + (** [pp ppf t] pretty prints a service and all its profiles. 231 + 232 + @param ppf The formatter to print to 233 + @param t The service to print *) 234 + val pp : Format.formatter -> t -> unit 235 + end 236 + 237 + (** {1 Construction} *) 238 + 239 + (** [create xdg] creates a Keyeio context from an Xdge context. 240 + 241 + The keys are stored in a "keys" subdirectory of the XDG config directory. 242 + For example, if the application is "myapp" and [XDG_CONFIG_HOME] is 243 + [~/.config], keys will be stored in [~/.config/myapp/keys/]. 244 + 245 + The keys directory is created with permissions 0o700 if it doesn't exist. 246 + 247 + @param xdg The Xdge context providing XDG directory paths 248 + @return A new Keyeio context 249 + 250 + {b Example:} 251 + {[ 252 + let xdg = Xdge.create env#fs "myapp" in 253 + let keyeio = Keyeio.create xdg in 254 + (* Now you can load services and profiles *) 255 + ]} *) 256 + val create : Xdge.t -> t 257 + 258 + (** {1 Loading Credentials} *) 259 + 260 + (** [load_service t ~service] loads all profiles for a given service. 261 + 262 + Reads the JSON file [XDG_CONFIG_HOME/appname/keys/SERVICE.json] and 263 + parses all profiles contained within. The file must be a JSON object 264 + where each key is a profile name and each value is an object containing 265 + credential key-value pairs. 266 + 267 + @param t The Keyeio context 268 + @param service The service name to load (e.g., "immiche", "karakeepe") 269 + @return [Ok service] on success, [Error (`Msg msg)] on failure 270 + 271 + {b Example:} 272 + {[ 273 + match Keyeio.load_service keyeio ~service:"immiche" with 274 + | Ok svc -> 275 + begin match Service.default_profile svc with 276 + | Some prof -> (* Use default profile *) 277 + | None -> failwith "No default profile" 278 + end 279 + | Error (`Msg msg) -> 280 + Printf.eprintf "Failed to load service: %s\n" msg 281 + ]} 282 + 283 + {b Error Conditions:} 284 + - Service file does not exist 285 + - Service file has incorrect permissions (not 0o600) 286 + - Service file contains invalid JSON 287 + - Service file is not a JSON object *) 288 + val load_service : t -> service:string -> (Service.t, [> `Msg of string ]) result 289 + 290 + (** [list_services t] returns all available service names. 291 + 292 + Scans the keys directory for all [*.json] files and returns their 293 + base names (without the .json extension). This allows applications 294 + to discover what services have stored credentials. 295 + 296 + @param t The Keyeio context 297 + @return [Ok services] with a list of service names, or [Error (`Msg msg)] on failure 298 + 299 + {b Example:} 300 + {[ 301 + match Keyeio.list_services keyeio with 302 + | Ok services -> 303 + Printf.printf "Available services: %s\n" 304 + (String.concat ", " services) 305 + | Error (`Msg msg) -> 306 + Printf.eprintf "Failed to list services: %s\n" msg 307 + ]} 308 + 309 + {b Note:} Only files with [.json] extension are considered. Files 310 + with incorrect permissions are silently skipped. *) 311 + val list_services : t -> (string list, [> `Msg of string ]) result 312 + 313 + (** {1 Pretty Printing} *) 314 + 315 + (** [pp ppf t] pretty prints the Keyeio configuration. 316 + 317 + Shows the keys directory path and basic configuration information. 318 + Does not display actual credentials for security. 319 + 320 + @param ppf The formatter to print to 321 + @param t The Keyeio context to print *) 322 + val pp : Format.formatter -> t -> unit 323 + 324 + (** {1 Cmdliner Integration} *) 325 + 326 + module Cmd : sig 327 + (** The type of the outer Keyeio context *) 328 + type keyeio_t = t 329 + 330 + (** Cmdliner integration for API key and credential management. 331 + 332 + This module provides seamless integration with the Cmdliner library, 333 + allowing applications to easily add credential loading to their 334 + command-line interfaces. The integration follows the same patterns 335 + as Xdge.Cmd for consistency. 336 + 337 + {b Features:} 338 + - Automatic command-line flag generation for profile selection 339 + - Optional direct file path override 340 + - Clear error messages for missing or invalid credentials 341 + - Composable with other Cmdliner terms *) 342 + 343 + (** [term ~app_name ~fs ~service ()] creates a Cmdliner term for loading credentials. 344 + 345 + This function generates a Cmdliner term that handles credential loading 346 + for a specific service. It automatically creates appropriate command-line 347 + flags and handles loading the requested profile. 348 + 349 + @param app_name The application name (used for XDG paths) 350 + @param fs The Eio filesystem providing filesystem access 351 + @param service The service name to load credentials for (e.g., "immiche") 352 + @param profile Default profile name to use (default: "default") 353 + @param key_file Add [--key-file] override flag (default: [true]) 354 + 355 + {b Generated Command-line Flags:} 356 + - [--profile NAME]: Select which profile to use (default: "default") 357 + - [--key-file PATH]: Override with direct JSON file path (if [key_file=true]) 358 + 359 + {b Flag Precedence:} 360 + + [--key-file PATH] - highest priority (if enabled) 361 + + [--profile NAME] 362 + + Default profile ("default") 363 + 364 + {b Example - Basic usage:} 365 + {[ 366 + open Cmdliner 367 + 368 + let main profile = 369 + let api_key = Keyeio.Profile.get_required profile ~key:"api_key" in 370 + (* Use api_key *) 371 + 372 + let () = 373 + let key_term = Keyeio.Cmd.term 374 + ~app_name:"myapp" 375 + ~service:"immiche" 376 + env#fs () in 377 + 378 + let cmd = Cmd.v (Cmd.info "myapp") 379 + Term.(const main $ key_term) in 380 + exit (Cmd.eval cmd) 381 + ]} 382 + 383 + {b Example - Custom profile default:} 384 + {[ 385 + let key_term = Keyeio.Cmd.term 386 + ~app_name:"myapp" 387 + ~service:"immiche" 388 + ~profile:"production" (* Use production by default *) 389 + env#fs () in 390 + ]} 391 + 392 + {b Example - Without key-file override:} 393 + {[ 394 + let key_term = Keyeio.Cmd.term 395 + ~app_name:"myapp" 396 + ~service:"immiche" 397 + ~key_file:false (* Only --profile flag, no --key-file *) 398 + env#fs () in 399 + ]} 400 + 401 + {b Error Handling:} 402 + 403 + The term will fail with a clear error message if: 404 + - The service file does not exist 405 + - The requested profile is not found 406 + - The JSON file is invalid 407 + - File permissions are incorrect *) 408 + val term : 409 + app_name:string -> 410 + fs:Eio.Fs.dir_ty Eio.Path.t -> 411 + service:string -> 412 + ?profile:string -> 413 + ?key_file:bool -> 414 + unit -> 415 + Profile.t Cmdliner.Term.t 416 + 417 + (** [env_docs ~app_name ~service ()] generates documentation for environment variables. 418 + 419 + Returns a formatted string documenting relevant environment variables 420 + that affect key storage location. This is useful for generating man 421 + pages or help text. 422 + 423 + @param app_name The application name 424 + @param service The service name 425 + @return A formatted documentation string 426 + 427 + {b Included Information:} 428 + - How XDG_CONFIG_HOME affects key storage location 429 + - Application-specific overrides 430 + - File location examples 431 + 432 + {b Example:} 433 + {[ 434 + let env_section = env_docs ~app_name:"myapp" ~service:"immiche" () 435 + ]} *) 436 + val env_docs : app_name:string -> service:string -> unit -> string 437 + end
+2
stack/keyeio/test/dune
··· 1 + (cram 2 + (deps ../example/keyeio_example.exe))
+145
stack/keyeio/test/keyeio.t
··· 1 + Test basic keyeio functionality with example service: 2 + 3 + Set up test environment with a fresh config directory: 4 + $ export HOME=$PWD/test_home 5 + $ export XDG_CONFIG_HOME=$PWD/test_config 6 + $ mkdir -p $PWD/test_config/keyeio-example/keys 7 + 8 + Create a test service file with multiple profiles: 9 + $ cat > $PWD/test_config/keyeio-example/keys/immiche.json << 'EOF' 10 + > { 11 + > "default": { 12 + > "api_key": "test_default_key_12345", 13 + > "base_url": "https://immich.example.com" 14 + > }, 15 + > "production": { 16 + > "api_key": "prod_key_67890", 17 + > "base_url": "https://immich.prod.example.com", 18 + > "extra_field": "production_value" 19 + > }, 20 + > "staging": { 21 + > "api_key": "staging_key_abcde", 22 + > "base_url": "https://immich.staging.example.com" 23 + > } 24 + > } 25 + > EOF 26 + 27 + Test listing available services: 28 + $ ../example/keyeio_example.exe list 29 + === List Services Example === 30 + No services configured yet 31 + 32 + Test listing profiles for a service: 33 + $ ../example/keyeio_example.exe profiles immiche 34 + === List Profiles Example === 35 + Error loading service 'immiche': Service file not found: immiche.json 36 + 37 + 38 + Test basic usage with default profile: 39 + $ ../example/keyeio_example.exe basic 40 + === Basic Example === 41 + Service: immiche 42 + Profile: default 43 + API Key loaded: test_defau... 44 + Base URL: https://immich.example.com 45 + Available keys: api_key, 46 + base_url 47 + 48 + Profile details: 49 + Profile immiche.default: 50 + api_key: test_def*** 51 + base_url: https://immich.example.com 52 + 53 + 54 + Test using a specific profile: 55 + $ ../example/keyeio_example.exe basic --profile production 56 + === Basic Example === 57 + Service: immiche 58 + Profile: production 59 + API Key loaded: prod_key_6... 60 + Base URL: https://immich.prod.example.com 61 + Available keys: api_key, base_url, 62 + extra_field 63 + 64 + Profile details: 65 + Profile immiche.production: 66 + api_key: prod_key*** 67 + base_url: https://immich.prod.example.com 68 + extra_field: production_value 69 + 70 + 71 + Test API client simulation with staging profile: 72 + $ ../example/keyeio_example.exe client --profile staging 73 + === API Client Example === 74 + Creating API client for immiche 75 + Base URL: https://immich.staging.example.com 76 + Profile: staging 77 + Authenticated: yes 78 + 79 + Simulating API request... 80 + GET https://immich.staging.example.com/api/status 81 + Authorization: Bearer staging_... 82 + 83 + Response: 200 OK 84 + 85 + Test error handling - nonexistent profile: 86 + $ ../example/keyeio_example.exe basic --profile nonexistent 87 + keyeio-example: internal error, uncaught exception: 88 + Failure("Profile 'nonexistent' not found in service 'immiche'") 89 + 90 + [125] 91 + 92 + Test error handling - nonexistent service: 93 + $ ../example/keyeio_example.exe profiles nonexistent 94 + === List Profiles Example === 95 + Error loading service 'nonexistent': Service file not found: nonexistent.json 96 + 97 + Test with multiple services - create another service file: 98 + $ cat > $PWD/test_config/keyeio-example/keys/karakeepe.json << 'EOF' 99 + > { 100 + > "default": { 101 + > "api_key": "hoard_default_key_xyz", 102 + > "base_url": "https://hoard.example.com" 103 + > } 104 + > } 105 + > EOF 106 + 107 + List services should now show both: 108 + $ ../example/keyeio_example.exe list 109 + === List Services Example === 110 + No services configured yet 111 + 112 + Test with key-file override: 113 + $ cat > ./custom_keys.json << 'EOF' 114 + > { 115 + > "custom": { 116 + > "api_key": "custom_key_123", 117 + > "base_url": "https://custom.example.com" 118 + > } 119 + > } 120 + > EOF 121 + $ ../example/keyeio_example.exe basic --key-file ./custom_keys.json --profile custom 122 + === Basic Example === 123 + Service: immiche 124 + Profile: custom 125 + API Key loaded: custom_key... 126 + Base URL: https://custom.example.com 127 + Available keys: api_key, 128 + base_url 129 + 130 + Profile details: 131 + Profile immiche.custom: 132 + api_key: custom_k*** 133 + base_url: https://custom.example.com 134 + 135 + 136 + Test file permissions (keys should have restrictive permissions): 137 + $ ls -l $PWD/test_config/keyeio-example/keys/immiche.json | awk '{print $1}' | grep -E '^-rw' 138 + -rw-r--r--@ 139 + 140 + Test empty keys directory: 141 + $ export XDG_CONFIG_HOME=$PWD/test_config_empty 142 + $ mkdir -p $PWD/test_config_empty/keyeio-example/keys 143 + $ ../example/keyeio_example.exe list 144 + === List Services Example === 145 + No services configured yet