Monorepo management for opam overlays
0
fork

Configure Feed

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

Add opamverse federated monorepo collaboration

Introduce 'monopam verse' command group for tracking and collaborating
across multiple developers' monorepos. Members are identified by tangled
handles validated via AT Protocol.

New modules:
- verse_config: XDG-based workspace configuration (~/.config/monopam/opamverse.toml)
storing root path and handle, with all paths derived (mono/, src/, opam-repo/, verse/)
- verse_registry: Community registry management with clone/pull from
https://tangled.org/eeg.cl.cam.ac.uk/opamverse, members have handle/monorepo/opamrepo
- verse: Core operations for init, status, members, add, remove, pull, sync

CLI commands:
- verse init --handle <h> [--root <p>]: Create workspace, clone user's monorepo and opamrepo
- verse status: Show tracked members and their git state
- verse members: List registry members
- verse add/remove <handle>: Track/untrack a member's monorepo
- verse pull [<handle>]: Git pull tracked members
- verse sync: Update registry and pull all members

Also removes standalone 'monopam init' command - configuration now unified
through 'verse init'. Existing monopam commands (status, pull, push, etc.)
derive paths from the opamverse workspace configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+1449 -220
+1 -1
bin/dune
··· 2 2 (name main) 3 3 (public_name monopam) 4 4 (package monopam) 5 - (libraries monopam eio_main cmdliner fmt.tty fmt.cli logs.fmt logs.cli)) 5 + (libraries monopam requests eio_main cmdliner fmt.tty fmt.cli logs.fmt logs.cli))
+549 -216
bin/main.ml
··· 1 1 open Cmdliner 2 2 3 - let setup_logging style_renderer level = 3 + let setup_logging style_renderer level verbose_http = 4 4 Fmt_tty.setup_std_outputs ?style_renderer (); 5 + Logs.set_reporter (Logs_fmt.reporter ()); 6 + (* Set global log level for monopam's own logs *) 5 7 Logs.set_level level; 6 - Logs.set_reporter (Logs_fmt.reporter ()) 8 + (* Use Requests.Cmd.setup_log_sources to configure HTTP logging separately. 9 + This allows -v to show app logs without HTTP protocol details, 10 + while --verbose-http enables full HTTP tracing. *) 11 + Requests.Cmd.setup_log_sources ~verbose_http level 7 12 8 13 let logging_term = 9 - Term.(const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level ()) 10 - 11 - let config_file_arg = 12 - let doc = 13 - "Path to config file. If not specified, searches current directory then \ 14 - XDG locations." 14 + let verbose_http_term = 15 + Term.(const (fun ws -> ws.Requests.Cmd.value) $ Requests.Cmd.verbose_http_term "monopam") 15 16 in 16 - Arg.( 17 - value & opt (some string) None & info [ "c"; "config" ] ~docv:"FILE" ~doc) 17 + Term.(const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level () $ verbose_http_term) 18 18 19 19 let package_arg = 20 20 let doc = "Package name. If not specified, operates on all packages." in 21 21 Arg.(value & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc) 22 22 23 - let load_config env config_file = 23 + (* Load config from opamverse.toml and convert to Monopam.Config *) 24 + let load_config env = 24 25 let fs = Eio.Stdenv.fs env in 25 - let cwd = Eio.Stdenv.cwd env in 26 - match config_file with 27 - | Some path -> ( 28 - (* If absolute, use fs; if relative, use cwd *) 29 - let load_path = Fpath.v path in 30 - if Fpath.is_abs load_path then 31 - Monopam.Config.load ~fs ~root_fs:fs load_path 32 - else 33 - match 34 - Monopam.Config.load ~fs:(cwd :> _ Eio.Path.t) ~root_fs:fs load_path 35 - with 36 - | Ok c -> Ok c 37 - | Error msg -> Error msg) 38 - | None -> ( 39 - (* Try current directory first *) 40 - let cwd_config = Fpath.v "monopam.toml" in 41 - match 42 - Monopam.Config.load ~fs:(cwd :> _ Eio.Path.t) ~root_fs:fs cwd_config 43 - with 44 - | Ok c -> Ok c 45 - | Error _ -> ( 46 - (* Try XDG *) 47 - let xdg = Xdge.create fs "monopam" in 48 - match Monopam.Config.load_xdg ~xdg () with 49 - | Ok c -> Ok c 50 - | Error msg -> Error msg)) 26 + match Monopam.Verse_config.load ~fs () with 27 + | Error msg -> Error msg 28 + | Ok verse_config -> 29 + (* Convert Verse_config to Monopam.Config *) 30 + let opam_repo = Monopam.Verse_config.opam_repo_path verse_config in 31 + let checkouts = Monopam.Verse_config.src_path verse_config in 32 + let monorepo = Monopam.Verse_config.mono_path verse_config in 33 + let default_branch = Monopam.Verse_config.default_branch in 34 + Ok (Monopam.Config.create ~opam_repo ~checkouts ~monorepo ~default_branch ()) 51 35 52 - let with_config env config_file f = 53 - match load_config env config_file with 36 + let with_config env f = 37 + match load_config env with 54 38 | Ok config -> f config 55 39 | Error msg -> 56 40 Fmt.epr "Error loading config: %s@." msg; 41 + Fmt.epr "Run 'monopam verse init' first to create a workspace.@."; 57 42 `Error (false, "configuration error") 58 43 59 44 (* Status command *) ··· 77 62 ] 78 63 in 79 64 let info = Cmd.info "status" ~doc ~man in 80 - let run config_file () = 65 + let run () = 81 66 Eio_main.run @@ fun env -> 82 - with_config env config_file @@ fun config -> 67 + with_config env @@ fun config -> 83 68 let fs = Eio.Stdenv.fs env in 84 69 let proc = Eio.Stdenv.process_mgr env in 85 70 match Monopam.status ~proc ~fs ~config () with ··· 90 75 Fmt.epr "Error: %a@." Monopam.pp_error e; 91 76 `Error (false, "status failed") 92 77 in 93 - Cmd.v info Term.(ret (const run $ config_file_arg $ logging_term)) 78 + Cmd.v info Term.(ret (const run $ logging_term)) 94 79 95 80 (* Pull command *) 96 81 ··· 115 100 ] 116 101 in 117 102 let info = Cmd.info "pull" ~doc ~man in 118 - let run config_file package () = 103 + let run package () = 119 104 Eio_main.run @@ fun env -> 120 - with_config env config_file @@ fun config -> 105 + with_config env @@ fun config -> 121 106 let fs = Eio.Stdenv.fs env in 122 107 let proc = Eio.Stdenv.process_mgr env in 123 108 match Monopam.pull ~proc ~fs ~config ?package () with ··· 129 114 `Error (false, "pull failed") 130 115 in 131 116 Cmd.v info 132 - Term.(ret (const run $ config_file_arg $ package_arg $ logging_term)) 117 + Term.(ret (const run $ package_arg $ logging_term)) 133 118 134 119 (* Push command *) 135 120 ··· 162 147 in 163 148 Arg.(value & flag & info [ "upstream" ] ~doc) 164 149 in 165 - let run config_file package upstream () = 150 + let run package upstream () = 166 151 Eio_main.run @@ fun env -> 167 - with_config env config_file @@ fun config -> 152 + with_config env @@ fun config -> 168 153 let fs = Eio.Stdenv.fs env in 169 154 let proc = Eio.Stdenv.process_mgr env in 170 155 match Monopam.push ~proc ~fs ~config ?package ~upstream () with ··· 177 162 in 178 163 Cmd.v info 179 164 Term.( 180 - ret (const run $ config_file_arg $ package_arg $ upstream_arg $ logging_term)) 165 + ret (const run $ package_arg $ upstream_arg $ logging_term)) 181 166 182 167 (* Extract command *) 183 168 ··· 216 201 let doc = "Create opam package metadata in overlay" in 217 202 Arg.(value & flag & info [ "create-opam" ] ~doc) 218 203 in 219 - let run config_file subdir repo branch push create_opam () = 204 + let run subdir repo branch push create_opam () = 220 205 Eio_main.run @@ fun env -> 221 - with_config env config_file @@ fun config -> 206 + with_config env @@ fun config -> 222 207 let fs = Eio.Stdenv.fs env in 223 208 let proc = Eio.Stdenv.process_mgr env in 224 209 match Monopam.extract ~proc ~fs ~config ~subdir ~repo_url:repo ··· 229 214 `Error (false, "extract failed") 230 215 in 231 216 Cmd.v info 232 - Term.(ret (const run $ config_file_arg $ subdir_arg $ repo_arg 217 + Term.(ret (const run $ subdir_arg $ repo_arg 233 218 $ branch_arg $ push_arg $ create_opam_arg $ logging_term)) 234 219 235 220 (* Add command *) ··· 250 235 let doc = "Package name to add" in 251 236 Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc) 252 237 in 253 - let run config_file package () = 238 + let run package () = 254 239 Eio_main.run @@ fun env -> 255 - with_config env config_file @@ fun config -> 240 + with_config env @@ fun config -> 256 241 let fs = Eio.Stdenv.fs env in 257 242 let proc = Eio.Stdenv.process_mgr env in 258 243 match Monopam.add ~proc ~fs ~config ~package () with ··· 264 249 `Error (false, "add failed") 265 250 in 266 251 Cmd.v info 267 - Term.(ret (const run $ config_file_arg $ package_arg $ logging_term)) 252 + Term.(ret (const run $ package_arg $ logging_term)) 268 253 269 254 (* Remove command *) 270 255 ··· 284 269 let doc = "Package name to remove" in 285 270 Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc) 286 271 in 287 - let run config_file package () = 272 + let run package () = 288 273 Eio_main.run @@ fun env -> 289 - with_config env config_file @@ fun config -> 274 + with_config env @@ fun config -> 290 275 let fs = Eio.Stdenv.fs env in 291 276 let proc = Eio.Stdenv.process_mgr env in 292 277 match Monopam.remove ~proc ~fs ~config ~package () with ··· 298 283 `Error (false, "remove failed") 299 284 in 300 285 Cmd.v info 301 - Term.(ret (const run $ config_file_arg $ package_arg $ logging_term)) 302 - 303 - (* Init command *) 304 - 305 - let prompt_path ~stdin ~stdout ~cwd prompt ~default = 306 - let default_str = 307 - match default with Some d -> Fmt.str " [%a]" Fpath.pp d | None -> "" 308 - in 309 - Eio.Flow.copy_string (Fmt.str "%s%s: " prompt default_str) stdout; 310 - let input = String.trim (Eio.Buf_read.line stdin) in 311 - let input = 312 - if input = "" then Option.map Fpath.to_string default else Some input 313 - in 314 - match input with 315 - | None -> Error "Path is required" 316 - | Some s -> ( 317 - (* Expand tilde *) 318 - let s = 319 - if String.length s > 0 && s.[0] = '~' then 320 - match Sys.getenv_opt "HOME" with 321 - | Some home -> 322 - if String.length s = 1 then home 323 - else if s.[1] = '/' then 324 - home ^ String.sub s 1 (String.length s - 1) 325 - else s 326 - | None -> s 327 - else s 328 - in 329 - match Fpath.of_string s with 330 - | Error (`Msg m) -> Error m 331 - | Ok path -> 332 - (* Convert relative to absolute using cwd *) 333 - let path = 334 - if Fpath.is_abs path then path else Fpath.(cwd // path |> normalize) 335 - in 336 - Ok path) 337 - 338 - let init_cmd = 339 - let doc = "Initialize a new monopam configuration" in 340 - let man = 341 - [ 342 - `S Manpage.s_description; 343 - `P 344 - "Interactively creates a monopam.toml configuration file in the \ 345 - current directory. Prompts for the paths to the opam overlay, \ 346 - checkouts directory, and monorepo directory."; 347 - `P 348 - "All paths must be absolute. You can use ~/ for your home directory, \ 349 - and relative paths will be converted to absolute based on the current \ 350 - working directory."; 351 - ] 352 - in 353 - let info = Cmd.info "init" ~doc ~man in 354 - let output_arg = 355 - let doc = "Output path for config file (default: monopam.toml)" in 356 - Arg.( 357 - value & opt string "monopam.toml" 358 - & info [ "o"; "output" ] ~docv:"FILE" ~doc) 359 - in 360 - let run output () = 361 - Eio_main.run @@ fun env -> 362 - let _fs = Eio.Stdenv.fs env in 363 - let cwd_path = Eio.Stdenv.cwd env in 364 - let stdin = 365 - Eio.Buf_read.of_flow ~max_size:(1024 * 1024) (Eio.Stdenv.stdin env) 366 - in 367 - let stdout = Eio.Stdenv.stdout env in 368 - (* Get current working directory as Fpath *) 369 - let cwd = 370 - let _, cwd_str = (cwd_path :> _ Eio.Path.t) in 371 - match Fpath.of_string cwd_str with Ok p -> p | Error _ -> Fpath.v "/" 372 - in 373 - Eio.Flow.copy_string "Monopam Configuration Setup\n" stdout; 374 - Eio.Flow.copy_string "===========================\n\n" stdout; 375 - Eio.Flow.copy_string 376 - "All paths must be absolute. Use ~/ for home directory.\n" stdout; 377 - Eio.Flow.copy_string "Relative paths will be converted to absolute.\n\n" 378 - stdout; 379 - (* Prompt for opam_repo *) 380 - let opam_repo = ref None in 381 - while !opam_repo = None do 382 - match 383 - prompt_path ~stdin ~stdout ~cwd "Path to opam overlay repository" 384 - ~default:None 385 - with 386 - | Ok p -> opam_repo := Some p 387 - | Error msg -> 388 - Eio.Flow.copy_string 389 - (Fmt.str "Error: %s. Please try again.\n" msg) 390 - stdout 391 - done; 392 - let opam_repo = Option.get !opam_repo in 393 - (* Prompt for checkouts *) 394 - let default_checkouts = Fpath.(parent opam_repo / "src") in 395 - let checkouts = ref None in 396 - while !checkouts = None do 397 - match 398 - prompt_path ~stdin ~stdout ~cwd "Path for git checkouts" 399 - ~default:(Some default_checkouts) 400 - with 401 - | Ok p -> checkouts := Some p 402 - | Error msg -> 403 - Eio.Flow.copy_string 404 - (Fmt.str "Error: %s. Please try again.\n" msg) 405 - stdout 406 - done; 407 - let checkouts = Option.get !checkouts in 408 - (* Prompt for monorepo *) 409 - let default_monorepo = Fpath.(parent opam_repo / "mono") in 410 - let monorepo = ref None in 411 - while !monorepo = None do 412 - match 413 - prompt_path ~stdin ~stdout ~cwd "Path for monorepo" 414 - ~default:(Some default_monorepo) 415 - with 416 - | Ok p -> monorepo := Some p 417 - | Error msg -> 418 - Eio.Flow.copy_string 419 - (Fmt.str "Error: %s. Please try again.\n" msg) 420 - stdout 421 - done; 422 - let monorepo = Option.get !monorepo in 423 - (* Prompt for default branch *) 424 - Eio.Flow.copy_string "Default git branch [main]: " stdout; 425 - let branch_input = String.trim (Eio.Buf_read.line stdin) in 426 - let default_branch = if branch_input = "" then "main" else branch_input in 427 - (* Create config *) 428 - let config = 429 - Monopam.Config.create ~opam_repo ~checkouts ~monorepo ~default_branch () 430 - in 431 - (* Save config *) 432 - let output_path = Fpath.v output in 433 - match 434 - Monopam.Config.save ~fs:(cwd_path :> _ Eio.Path.t) config output_path 435 - with 436 - | Ok () -> 437 - Eio.Flow.copy_string 438 - (Fmt.str "\nConfiguration saved to %s\n" output) 439 - stdout; 440 - Eio.Flow.copy_string 441 - "\nYou can now run 'monopam pull' to initialize the monorepo.\n" 442 - stdout; 443 - `Ok () 444 - | Error msg -> 445 - Fmt.epr "Error saving config: %s@." msg; 446 - `Error (false, "init failed") 447 - in 448 - Cmd.v info Term.(ret (const run $ output_arg $ logging_term)) 286 + Term.(ret (const run $ package_arg $ logging_term)) 449 287 450 288 (* Changes command *) 451 289 ··· 511 349 let doc = "Skip generating .changes/YYYYMMDD.json aggregated file (--daily generates it by default)" in 512 350 Arg.(value & flag & info [ "no-aggregate" ] ~doc) 513 351 in 514 - let run config_file package daily weeks days history dry_run no_aggregate () = 352 + let run package daily weeks days history dry_run no_aggregate () = 515 353 Eio_main.run @@ fun env -> 516 - with_config env config_file @@ fun config -> 354 + with_config env @@ fun config -> 517 355 let fs = Eio.Stdenv.fs env in 518 356 let proc = Eio.Stdenv.process_mgr env in 519 357 let clock = Eio.Stdenv.clock env in ··· 541 379 Cmd.v info 542 380 Term.( 543 381 ret 544 - (const run $ config_file_arg $ package_arg $ daily $ weeks $ days $ history $ dry_run 382 + (const run $ package_arg $ daily $ weeks $ days $ history $ dry_run 545 383 $ no_aggregate $ logging_term)) 546 384 385 + (* Verse commands *) 386 + 387 + (* Helper to load verse config from XDG *) 388 + let with_verse_config env f = 389 + let fs = Eio.Stdenv.fs env in 390 + match Monopam.Verse_config.load ~fs () with 391 + | Ok config -> f config 392 + | Error msg -> 393 + Fmt.epr "Error loading opamverse config: %s@." msg; 394 + Fmt.epr "Run 'monopam verse init' to create a workspace.@."; 395 + `Error (false, "configuration error") 396 + 397 + let verse_root_arg = 398 + let doc = "Path to workspace root directory. Defaults to current directory." in 399 + Arg.( 400 + value 401 + & opt (some (conv (Fpath.of_string, Fpath.pp))) None 402 + & info [ "root" ] ~docv:"PATH" ~doc) 403 + 404 + let verse_handle_arg = 405 + let doc = "Tangled handle (e.g., alice.bsky.social)" in 406 + Arg.(required & opt (some string) None & info [ "handle" ] ~docv:"HANDLE" ~doc) 407 + 408 + let verse_handle_pos_arg = 409 + let doc = "Tangled handle (e.g., alice.bsky.social)" in 410 + Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc) 411 + 412 + let verse_handle_opt_pos_arg = 413 + let doc = "Tangled handle. If not specified, operates on all tracked members." in 414 + Arg.(value & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc) 415 + 416 + let verse_init_cmd = 417 + let doc = "Initialize a new opamverse workspace" in 418 + let man = 419 + [ 420 + `S Manpage.s_description; 421 + `P 422 + "Creates a new opamverse workspace for federated monorepo collaboration. \ 423 + An opamverse workspace lets you browse and track other developers' \ 424 + monorepos alongside your own."; 425 + `S "WORKSPACE STRUCTURE"; 426 + `P "The init command creates the following directory structure at the workspace root:"; 427 + `I ("mono/", "Your monorepo - use with standard monopam commands"); 428 + `I ("src/", "Your source checkouts - individual git repos"); 429 + `I ("verse/", "Other users' monorepos, organized by handle"); 430 + `P "Configuration and data are stored in XDG directories:"; 431 + `I ("~/.config/monopam/opamverse.toml", "Workspace configuration"); 432 + `I ("~/.local/share/monopam/opamverse-registry/", "Git clone of the community registry"); 433 + `S "CONFIGURATION FILE"; 434 + `P "The opamverse.toml file has the following structure:"; 435 + `Pre "[workspace]\n\ 436 + root = \"/path/to/workspace\"\n\ 437 + default_branch = \"main\"\n\n\ 438 + [paths]\n\ 439 + mono = \"mono\"\n\ 440 + src = \"src\"\n\ 441 + verse = \"verse\"\n\n\ 442 + [identity]\n\ 443 + handle = \"yourname.bsky.social\""; 444 + `S "AUTHENTICATION"; 445 + `P 446 + "Before running init, you must authenticate with the tangled network:"; 447 + `Pre "tangled auth login"; 448 + `P 449 + "The handle you provide is validated against the AT Protocol identity \ 450 + system to ensure it exists and you are authenticated."; 451 + `S "REGISTRY"; 452 + `P 453 + "The opamverse registry is a git repository containing an opamverse.toml \ 454 + file that lists community members and their monorepo URLs. The default \ 455 + registry is at: https://tangled.org/eeg.cl.cam.ac.uk/opamverse"; 456 + `S Manpage.s_examples; 457 + `P "Initialize a workspace in ~/tangled:"; 458 + `Pre "cd ~/tangled\n\ 459 + monopam verse init --handle alice.bsky.social"; 460 + `P "Initialize with explicit root path:"; 461 + `Pre "monopam verse init --root ~/my-workspace --handle alice.bsky.social"; 462 + ] 463 + in 464 + let info = Cmd.info "init" ~doc ~man in 465 + let run root handle () = 466 + Eio_main.run @@ fun env -> 467 + Eio.Switch.run @@ fun sw -> 468 + let fs = Eio.Stdenv.fs env in 469 + let proc = Eio.Stdenv.process_mgr env in 470 + let root = 471 + match root with 472 + | Some r -> r 473 + | None -> 474 + let cwd_path = Eio.Stdenv.cwd env in 475 + let _, cwd_str = (cwd_path :> _ Eio.Path.t) in 476 + match Fpath.of_string cwd_str with 477 + | Ok p -> p 478 + | Error (`Msg _) -> Fpath.v "." 479 + in 480 + match Monopam.Verse.init ~proc ~fs ~sw ~env ~root ~handle () with 481 + | Ok () -> 482 + Fmt.pr "Monoverse workspace initialized at %a@." Fpath.pp root; 483 + `Ok () 484 + | Error e -> 485 + Fmt.epr "Error: %a@." Monopam.Verse.pp_error e; 486 + `Error (false, "init failed") 487 + in 488 + Cmd.v info Term.(ret (const run $ verse_root_arg $ verse_handle_arg $ logging_term)) 489 + 490 + let verse_status_cmd = 491 + let doc = "Show workspace status" in 492 + let man = 493 + [ 494 + `S Manpage.s_description; 495 + `P 496 + "Displays the status of your opamverse workspace, including which \ 497 + members you're tracking and the git state of their local clones."; 498 + `S "OUTPUT"; 499 + `P "For each tracked member, shows:"; 500 + `I ("handle", "The member's tangled handle (e.g., alice.bsky.social)"); 501 + `I ("monorepo URL", "Git URL of their monorepo"); 502 + `I ("status", "One of: not cloned, clean, dirty, ahead N/behind M"); 503 + `S "STATUS INDICATORS"; 504 + `I ("not cloned", "Member added but monorepo not yet cloned locally"); 505 + `I ("clean", "Local clone matches remote, no uncommitted changes"); 506 + `I ("dirty", "Local clone has uncommitted changes"); 507 + `I ("ahead N, behind M", "Local is N commits ahead and M commits behind remote"); 508 + `S Manpage.s_examples; 509 + `Pre "$ monopam verse status\n\ 510 + Workspace: /home/user/tangled\n\ 511 + Registry: tangled-community\n\ 512 + Members:\n\ 513 + \ alice.bsky.social -> https://github.com/alice/mono [clean]\n\ 514 + \ bob.example.com -> https://github.com/bob/mono [ahead 2, behind 0]"; 515 + ] 516 + in 517 + let info = Cmd.info "status" ~doc ~man in 518 + let run () = 519 + Eio_main.run @@ fun env -> 520 + with_verse_config env @@ fun config -> 521 + let fs = Eio.Stdenv.fs env in 522 + let proc = Eio.Stdenv.process_mgr env in 523 + match Monopam.Verse.status ~proc ~fs ~config () with 524 + | Ok status -> 525 + Fmt.pr "%a@." Monopam.Verse.pp_status status; 526 + `Ok () 527 + | Error e -> 528 + Fmt.epr "Error: %a@." Monopam.Verse.pp_error e; 529 + `Error (false, "status failed") 530 + in 531 + Cmd.v info Term.(ret (const run $ logging_term)) 532 + 533 + let verse_members_cmd = 534 + let doc = "List registry members" in 535 + let man = 536 + [ 537 + `S Manpage.s_description; 538 + `P 539 + "Lists all members registered in the opamverse community registry. \ 540 + This shows everyone who has published their monorepo for collaboration."; 541 + `P 542 + "The registry is automatically pulled (git pull) when running this \ 543 + command to ensure you see the latest members."; 544 + `S "REGISTRY FORMAT"; 545 + `P 546 + "The registry is a git repository containing an opamverse.toml file \ 547 + with the following structure:"; 548 + `Pre "[registry]\n\ 549 + name = \"tangled-community\"\n\n\ 550 + [[members]]\n\ 551 + handle = \"alice.bsky.social\"\n\ 552 + monorepo = \"https://github.com/alice/mono\"\n\n\ 553 + [[members]]\n\ 554 + handle = \"bob.example.com\"\n\ 555 + monorepo = \"https://github.com/bob/mono\""; 556 + `S "OUTPUT"; 557 + `P "Each line shows a member's handle and their monorepo git URL:"; 558 + `Pre "alice.bsky.social -> https://github.com/alice/mono\n\ 559 + bob.example.com -> https://github.com/bob/mono"; 560 + `S "ADDING YOURSELF"; 561 + `P 562 + "To add yourself to the registry, submit a pull request to the \ 563 + registry repository adding your entry to opamverse.toml."; 564 + ] 565 + in 566 + let info = Cmd.info "members" ~doc ~man in 567 + let run () = 568 + Eio_main.run @@ fun env -> 569 + with_verse_config env @@ fun config -> 570 + let fs = Eio.Stdenv.fs env in 571 + let proc = Eio.Stdenv.process_mgr env in 572 + match Monopam.Verse.members ~proc ~fs ~config () with 573 + | Ok members -> 574 + Fmt.pr "@[<v>%a@]@." 575 + Fmt.(list ~sep:cut Monopam.Verse_registry.pp_member) 576 + members; 577 + `Ok () 578 + | Error e -> 579 + Fmt.epr "Error: %a@." Monopam.Verse.pp_error e; 580 + `Error (false, "members failed") 581 + in 582 + Cmd.v info Term.(ret (const run $ logging_term)) 583 + 584 + let verse_add_cmd = 585 + let doc = "Add a member to the workspace" in 586 + let man = 587 + [ 588 + `S Manpage.s_description; 589 + `P 590 + "Adds a community member's monorepo to your workspace by cloning it \ 591 + to the verse/<handle>/ directory."; 592 + `S "PROCESS"; 593 + `P "The add command performs the following steps:"; 594 + `I ("1.", "Validates the handle against the tangled network (AT Protocol)"); 595 + `I ("2.", "Looks up the handle in the opamverse registry"); 596 + `I ("3.", "Clones their monorepo to verse/<handle>/"); 597 + `S "HANDLE VALIDATION"; 598 + `P 599 + "Handles are validated against the AT Protocol identity system to \ 600 + ensure they exist. This requires prior authentication:"; 601 + `Pre "tangled auth login"; 602 + `S "AFTER ADDING"; 603 + `P 604 + "Once added, you can browse the member's code in verse/<handle>/. \ 605 + Their monorepo follows the same structure as yours (managed by monopam), \ 606 + so you can explore their packages and dependencies."; 607 + `P "Use 'monopam verse pull <handle>' to fetch updates later."; 608 + `S Manpage.s_examples; 609 + `Pre "# Add a member\n\ 610 + monopam verse add alice.bsky.social\n\n\ 611 + # Browse their code\n\ 612 + ls verse/alice.bsky.social/\n\ 613 + cd verse/alice.bsky.social && dune build"; 614 + `S "ERRORS"; 615 + `I ("Member not found", "The handle is not in the registry - they need to register first"); 616 + `I ("Handle not found", "The handle doesn't exist on the tangled network"); 617 + `I ("Not authenticated", "Run 'tangled auth login' first"); 618 + ] 619 + in 620 + let info = Cmd.info "add" ~doc ~man in 621 + let run handle () = 622 + Eio_main.run @@ fun env -> 623 + Eio.Switch.run @@ fun sw -> 624 + with_verse_config env @@ fun config -> 625 + let fs = Eio.Stdenv.fs env in 626 + let proc = Eio.Stdenv.process_mgr env in 627 + match Monopam.Verse.add ~proc ~fs ~sw ~env ~config ~handle () with 628 + | Ok () -> 629 + Fmt.pr "Added %s to workspace.@." handle; 630 + `Ok () 631 + | Error e -> 632 + Fmt.epr "Error: %a@." Monopam.Verse.pp_error e; 633 + `Error (false, "add failed") 634 + in 635 + Cmd.v info Term.(ret (const run $ verse_handle_pos_arg $ logging_term)) 636 + 637 + let verse_remove_cmd = 638 + let doc = "Remove a member from the workspace" in 639 + let man = 640 + [ 641 + `S Manpage.s_description; 642 + `P 643 + "Removes a member's monorepo from your workspace by deleting the \ 644 + verse/<handle>/ directory."; 645 + `P 646 + "This is a local operation - it only affects your workspace. The \ 647 + member remains in the registry and can be re-added later."; 648 + `S "WARNING"; 649 + `P 650 + "This permanently deletes the local clone. Any local changes you \ 651 + made in verse/<handle>/ will be lost. If you have uncommitted work, \ 652 + commit and push it first (if you have write access) or back it up."; 653 + `S Manpage.s_examples; 654 + `Pre "# Remove a member\n\ 655 + monopam verse remove alice.bsky.social\n\n\ 656 + # Re-add them later if needed\n\ 657 + monopam verse add alice.bsky.social"; 658 + ] 659 + in 660 + let info = Cmd.info "remove" ~doc ~man in 661 + let run handle () = 662 + Eio_main.run @@ fun env -> 663 + with_verse_config env @@ fun config -> 664 + let fs = Eio.Stdenv.fs env in 665 + match Monopam.Verse.remove ~fs ~config ~handle () with 666 + | Ok () -> 667 + Fmt.pr "Removed %s from workspace.@." handle; 668 + `Ok () 669 + | Error e -> 670 + Fmt.epr "Error: %a@." Monopam.Verse.pp_error e; 671 + `Error (false, "remove failed") 672 + in 673 + Cmd.v info Term.(ret (const run $ verse_handle_pos_arg $ logging_term)) 674 + 675 + let verse_pull_cmd = 676 + let doc = "Pull updates for tracked members" in 677 + let man = 678 + [ 679 + `S Manpage.s_description; 680 + `P 681 + "Fetches and merges git updates for tracked members' monorepos. \ 682 + This runs 'git pull' in each member's directory under verse/."; 683 + `S "SCOPE"; 684 + `P "With a handle argument: pulls only that specific member."; 685 + `P "Without arguments: pulls all tracked members in verse/."; 686 + `S "TRACKED MEMBERS"; 687 + `P 688 + "A member is 'tracked' if their directory exists under verse/. \ 689 + This happens after running 'monopam verse add <handle>'."; 690 + `S "ERROR HANDLING"; 691 + `P 692 + "If a pull fails for one member (e.g., merge conflict), the error \ 693 + is reported but other members are still pulled."; 694 + `P 695 + "Resolve conflicts manually in verse/<handle>/ and commit, or use \ 696 + 'git reset --hard origin/main' to discard local changes."; 697 + `S Manpage.s_examples; 698 + `Pre "# Pull all tracked members\n\ 699 + monopam verse pull\n\n\ 700 + # Pull a specific member\n\ 701 + monopam verse pull alice.bsky.social"; 702 + ] 703 + in 704 + let info = Cmd.info "pull" ~doc ~man in 705 + let run handle () = 706 + Eio_main.run @@ fun env -> 707 + with_verse_config env @@ fun config -> 708 + let fs = Eio.Stdenv.fs env in 709 + let proc = Eio.Stdenv.process_mgr env in 710 + match Monopam.Verse.pull ~proc ~fs ~config ?handle () with 711 + | Ok () -> 712 + Fmt.pr "Pull completed.@."; 713 + `Ok () 714 + | Error e -> 715 + Fmt.epr "Error: %a@." Monopam.Verse.pp_error e; 716 + `Error (false, "pull failed") 717 + in 718 + Cmd.v info Term.(ret (const run $ verse_handle_opt_pos_arg $ logging_term)) 719 + 720 + let verse_sync_cmd = 721 + let doc = "Sync the workspace" in 722 + let man = 723 + [ 724 + `S Manpage.s_description; 725 + `P 726 + "Synchronizes your entire opamverse workspace with the latest upstream \ 727 + changes. This is the command to run regularly to stay up to date."; 728 + `S "WHAT IT DOES"; 729 + `P "The sync command performs two operations:"; 730 + `I ("1.", "Updates the registry: git pull in ~/.local/share/monopam/opamverse-registry/"); 731 + `I ("2.", "Pulls all tracked members: git pull in each verse/<handle>/"); 732 + `S "USE CASES"; 733 + `P "Run sync when you want to:"; 734 + `I ("-", "See if any new members have joined the community"); 735 + `I ("-", "Get the latest code from all tracked members"); 736 + `I ("-", "Catch up after being away for a while"); 737 + `S "COMPARISON WITH PULL"; 738 + `P 739 + "'verse sync' updates the registry AND pulls members. \ 740 + 'verse pull' only pulls members (skips registry update)."; 741 + `S Manpage.s_examples; 742 + `Pre "# Daily sync routine\n\ 743 + cd ~/tangled\n\ 744 + monopam verse sync\n\ 745 + monopam verse status"; 746 + ] 747 + in 748 + let info = Cmd.info "sync" ~doc ~man in 749 + let run () = 750 + Eio_main.run @@ fun env -> 751 + with_verse_config env @@ fun config -> 752 + let fs = Eio.Stdenv.fs env in 753 + let proc = Eio.Stdenv.process_mgr env in 754 + match Monopam.Verse.sync ~proc ~fs ~config () with 755 + | Ok () -> 756 + Fmt.pr "Sync completed.@."; 757 + `Ok () 758 + | Error e -> 759 + Fmt.epr "Error: %a@." Monopam.Verse.pp_error e; 760 + `Error (false, "sync failed") 761 + in 762 + Cmd.v info Term.(ret (const run $ logging_term)) 763 + 764 + let verse_cmd = 765 + let doc = "Federated monorepo collaboration" in 766 + let man = 767 + [ 768 + `S Manpage.s_description; 769 + `P 770 + "The opamverse system enables federated collaboration across multiple \ 771 + developers' monorepos. Each developer maintains their own monorepo \ 772 + (managed by standard monopam commands), and can track other developers' \ 773 + monorepos for code browsing, learning, and collaboration."; 774 + `P 775 + "Members are identified by tangled handles - decentralized identities \ 776 + from the AT Protocol network (the same system used by Bluesky)."; 777 + `S "QUICK START FOR NEW USERS"; 778 + `P "Run these commands in order to get started:"; 779 + `Pre "# Step 1: Authenticate with tangled (one-time setup)\n\ 780 + tangled auth login\n\n\ 781 + # Step 2: Create and initialize your workspace\n\ 782 + mkdir ~/tangled && cd ~/tangled\n\ 783 + monopam verse init --handle yourname.bsky.social\n\n\ 784 + # Step 3: Browse available community members\n\ 785 + monopam verse members\n\n\ 786 + # Step 4: Add a member to track their monorepo\n\ 787 + monopam verse add alice.bsky.social\n\n\ 788 + # Step 5: Browse their code\n\ 789 + ls verse/alice.bsky.social/\n\ 790 + cd verse/alice.bsky.social && dune build\n\n\ 791 + # Step 6: Keep everything updated (run daily/weekly)\n\ 792 + monopam verse sync"; 793 + `S "KEY CONCEPTS"; 794 + `I ("Workspace", "A directory containing your monorepo plus tracked members' monorepos"); 795 + `I ("Registry", "A git repository listing community members and their monorepo URLs"); 796 + `I ("Handle", "A tangled identity like 'alice.bsky.social' validated via AT Protocol"); 797 + `I ("Tracking", "Cloning another member's monorepo to your verse/ directory"); 798 + `S "WORKSPACE STRUCTURE"; 799 + `P "An opamverse workspace has this layout:"; 800 + `Pre "~/tangled/ # workspace root\n\ 801 + ├── mono/ # YOUR monorepo (use monopam pull/push here)\n\ 802 + ├── src/ # YOUR fork checkouts\n\ 803 + └── verse/\n\ 804 + \ ├── alice.bsky.social/ # Alice's monorepo (read-only tracking)\n\ 805 + \ └── bob.example.com/ # Bob's monorepo (read-only tracking)"; 806 + `P "Configuration and data are stored in XDG directories:"; 807 + `Pre "~/.config/monopam/\n\ 808 + └── opamverse.toml # workspace configuration\n\n\ 809 + ~/.local/share/monopam/\n\ 810 + └── opamverse-registry/ # cloned registry git repo"; 811 + `S "COMMAND FLOW"; 812 + `P "The expected sequence of commands for typical workflows:"; 813 + `P "$(b,First-time setup) (once per machine):"; 814 + `Pre "tangled auth login # authenticate\n\ 815 + monopam verse init --handle you.bsky.social # create workspace"; 816 + `P "$(b,Adding members to track):"; 817 + `Pre "monopam verse members # list available members\n\ 818 + monopam verse add alice.bsky.social # clone their monorepo\n\ 819 + monopam verse status # verify it was added"; 820 + `P "$(b,Daily maintenance):"; 821 + `Pre "monopam verse sync # update everything\n\ 822 + monopam verse status # check for changes"; 823 + `P "$(b,Working in your own monorepo):"; 824 + `Pre "cd ~/tangled/mono\n\ 825 + monopam pull # fetch upstream changes\n\ 826 + # ... make edits ...\n\ 827 + monopam push # export to checkouts"; 828 + `S "INTEGRATION WITH MONOPAM"; 829 + `P 830 + "The verse system complements standard monopam commands. Your mono/ \ 831 + directory works exactly like a normal monopam-managed monorepo:"; 832 + `Pre "# Work in your monorepo\n\ 833 + cd ~/tangled/mono\n\ 834 + monopam status\n\ 835 + monopam pull\n\ 836 + # ... make changes ...\n\ 837 + monopam push"; 838 + `P 839 + "The verse/ directories are for reading and learning from others' code. \ 840 + You generally don't push to them (unless you're a collaborator)."; 841 + `S "REGISTRY FORMAT"; 842 + `P 843 + "The registry is a git repository containing opamverse.toml:"; 844 + `Pre "[registry]\n\ 845 + name = \"tangled-community\"\n\n\ 846 + [[members]]\n\ 847 + handle = \"alice.bsky.social\"\n\ 848 + monorepo = \"https://github.com/alice/mono\""; 849 + `P 850 + "Default registry: https://tangled.org/eeg.cl.cam.ac.uk/opamverse"; 851 + `S "COMMANDS REFERENCE"; 852 + `I ("init", "Create a new workspace with config and directories"); 853 + `I ("status", "Show tracked members and their git status"); 854 + `I ("members", "List all members in the registry"); 855 + `I ("add <handle>", "Clone a member's monorepo to verse/"); 856 + `I ("remove <handle>", "Delete a member's local clone"); 857 + `I ("pull [<handle>]", "Git pull tracked member(s)"); 858 + `I ("sync", "Update registry and pull all members"); 859 + `S "AUTHENTICATION"; 860 + `P 861 + "Handle validation uses the AT Protocol identity system. The tangled \ 862 + CLI stores session credentials that monopam verse commands reuse."; 863 + `P "If you see 'Not authenticated', run:"; 864 + `Pre "tangled auth login"; 865 + ] 866 + in 867 + let info = Cmd.info "verse" ~doc ~man in 868 + Cmd.group info 869 + [ 870 + verse_init_cmd; 871 + verse_status_cmd; 872 + verse_members_cmd; 873 + verse_add_cmd; 874 + verse_remove_cmd; 875 + verse_pull_cmd; 876 + verse_sync_cmd; 877 + ] 878 + 547 879 (* Main command group *) 548 880 549 881 let main_cmd = ··· 586 918 "Review changes in src/*/, then git push each one" ); 587 919 `S "CONFIGURATION"; 588 920 `P 589 - "Run $(b,monopam init) to interactively create a configuration file. \ 590 - Configuration is read from monopam.toml in the current directory or \ 591 - XDG config locations."; 592 - `P "All paths in the configuration must be absolute. Example:"; 921 + "Run $(b,monopam verse init --handle <handle>) to create a workspace. \ 922 + Configuration is stored in ~/.config/monopam/opamverse.toml and \ 923 + all paths are derived from the workspace root."; 924 + `P "Workspace structure:"; 593 925 `Pre 594 - "opam_repo = \"/home/user/opam-overlay\"\n\ 595 - checkouts = \"/home/user/src\"\n\ 596 - monorepo = \"/home/user/mono\"\n\ 597 - default_branch = \"main\""; 926 + "root/\n\ 927 + ├── mono/ # Your monorepo\n\ 928 + ├── src/ # Git checkouts\n\ 929 + ├── opam-repo/ # Opam overlay\n\ 930 + └── verse/ # Other members' monorepos"; 598 931 `S Manpage.s_commands; 599 932 `P "Use $(b,monopam COMMAND --help) for help on a specific command."; 600 933 ] 601 934 in 602 935 let info = Cmd.info "monopam" ~version:"%%VERSION%%" ~doc ~man in 603 936 Cmd.group info 604 - [ init_cmd; status_cmd; pull_cmd; push_cmd; extract_cmd; add_cmd; remove_cmd; changes_cmd ] 937 + [ status_cmd; pull_cmd; push_cmd; extract_cmd; add_cmd; remove_cmd; changes_cmd; verse_cmd ] 605 938 606 939 let () = exit (Cmd.eval main_cmd)
+1 -1
lib/dune
··· 1 1 (library 2 2 (name monopam) 3 3 (public_name monopam) 4 - (libraries eio tomlt tomlt.eio xdge opam-file-format fmt logs uri fpath claude jsont jsont.bytesrw ptime ptime.clock.os)) 4 + (libraries eio tomlt tomlt.eio xdge opam-file-format fmt logs uri fpath claude jsont jsont.bytesrw ptime ptime.clock.os tangled xrpc-auth))
+4 -2
lib/git.ml
··· 57 57 58 58 let is_repo ~proc ~fs path = 59 59 let cwd = path_to_eio ~fs path in 60 - let result = run_git ~proc ~cwd [ "rev-parse"; "--git-dir" ] in 61 - result.exit_code = 0 60 + try 61 + let result = run_git ~proc ~cwd [ "rev-parse"; "--git-dir" ] in 62 + result.exit_code = 0 63 + with Eio.Io _ -> false (* Directory doesn't exist or not accessible *) 62 64 63 65 let is_dirty ~proc ~fs path = 64 66 let cwd = path_to_eio ~fs path in
+3
lib/monopam.ml
··· 4 4 module Git = Git 5 5 module Status = Status 6 6 module Changes = Changes 7 + module Verse = Verse 8 + module Verse_config = Verse_config 9 + module Verse_registry = Verse_registry 7 10 8 11 let src = Logs.Src.create "monopam" ~doc:"Monopam operations" 9 12
+3
lib/monopam.mli
··· 28 28 module Git = Git 29 29 module Status = Status 30 30 module Changes = Changes 31 + module Verse = Verse 32 + module Verse_config = Verse_config 33 + module Verse_registry = Verse_registry 31 34 32 35 (** {1 High-Level Operations} *) 33 36
+346
lib/verse.ml
··· 1 + type error = 2 + | Config_error of string 3 + | Git_error of Git.error 4 + | Registry_error of string 5 + | Handle_not_found of string 6 + | Not_authenticated 7 + | Member_not_found of string 8 + | Workspace_exists of Fpath.t 9 + | Not_a_workspace of Fpath.t 10 + 11 + let pp_error ppf = function 12 + | Config_error msg -> Fmt.pf ppf "Configuration error: %s" msg 13 + | Git_error e -> Fmt.pf ppf "Git error: %a" Git.pp_error e 14 + | Registry_error msg -> Fmt.pf ppf "Registry error: %s" msg 15 + | Handle_not_found h -> Fmt.pf ppf "Handle not found: %s" h 16 + | Not_authenticated -> 17 + Fmt.pf ppf "Not authenticated. Run 'tangled auth login' first." 18 + | Member_not_found h -> Fmt.pf ppf "Member not in registry: %s" h 19 + | Workspace_exists p -> Fmt.pf ppf "Workspace already exists: %a" Fpath.pp p 20 + | Not_a_workspace p -> Fmt.pf ppf "Not a opamverse workspace: %a" Fpath.pp p 21 + 22 + type member_status = { 23 + handle : string; 24 + monorepo_url : string; 25 + local_path : Fpath.t; 26 + cloned : bool; 27 + clean : bool option; 28 + ahead_behind : Git.ahead_behind option; 29 + } 30 + 31 + type status = { 32 + config : Verse_config.t; 33 + registry : Verse_registry.t; 34 + tracked_members : member_status list; 35 + } 36 + 37 + let pp_member_status ppf m = 38 + let status = 39 + if not m.cloned then "not cloned" 40 + else 41 + match (m.clean, m.ahead_behind) with 42 + | Some false, _ -> "dirty" 43 + | Some true, Some ab when ab.ahead > 0 || ab.behind > 0 -> 44 + Fmt.str "ahead %d, behind %d" ab.ahead ab.behind 45 + | Some true, _ -> "clean" 46 + | None, _ -> "unknown" 47 + in 48 + Fmt.pf ppf "@[<hov 2>%s@ (%s)@ [%s]@]" m.handle m.monorepo_url status 49 + 50 + let pp_status ppf s = 51 + Fmt.pf ppf "@[<v>Workspace: %a@,Registry: %s@,Members:@, @[<v>%a@]@]" 52 + Fpath.pp (Verse_config.root s.config) 53 + s.registry.name 54 + Fmt.(list ~sep:cut pp_member_status) 55 + s.tracked_members 56 + 57 + (* Helper to validate handle via tangled API. 58 + We reuse the tangled CLI's session credentials - user must run 'tangled auth login' first. *) 59 + let validate_handle ~sw ~env handle = 60 + try 61 + (* Use app_name:"tangled" to reuse tangled CLI's session without polluting monopam's directory *) 62 + let api = 63 + Tangled.Api.create ~sw ~env ~app_name:"tangled" ~pds:"https://bsky.social" () 64 + in 65 + (* Try to load existing session from tangled CLI *) 66 + let session = Xrpc_auth.Session.load env#fs ~app_name:"tangled" () in 67 + match session with 68 + | None -> Error Not_authenticated 69 + | Some session -> ( 70 + Tangled.Api.resume api ~session; 71 + try 72 + let _did = Tangled.Api.resolve_handle api handle in 73 + Ok () 74 + with Eio.Io (Xrpc.Error.E _, _) -> Error (Handle_not_found handle)) 75 + with Eio.Io (Xrpc.Error.E _, _) -> Error Not_authenticated 76 + 77 + (* Helper to check if a path is a directory *) 78 + let is_directory ~fs path = 79 + let eio_path = Eio.Path.(fs / Fpath.to_string path) in 80 + match Eio.Path.kind ~follow:true eio_path with 81 + | `Directory -> true 82 + | _ -> false 83 + | exception _ -> false 84 + 85 + (* Helper to check if a path is a regular file *) 86 + let is_file ~fs path = 87 + let eio_path = Eio.Path.(fs / Fpath.to_string path) in 88 + match Eio.Path.kind ~follow:true eio_path with 89 + | `Regular_file -> true 90 + | _ -> false 91 + | exception _ -> false 92 + 93 + (* Helper to create a directory if it doesn't exist *) 94 + let ensure_dir ~fs path = 95 + let eio_path = Eio.Path.(fs / Fpath.to_string path) in 96 + try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> () 97 + 98 + (* Helper to recursively delete a directory *) 99 + let rec rm_rf ~fs path = 100 + let eio_path = Eio.Path.(fs / Fpath.to_string path) in 101 + match Eio.Path.kind ~follow:false eio_path with 102 + | `Directory -> 103 + (* List and delete contents first *) 104 + let entries = Eio.Path.read_dir eio_path in 105 + List.iter (fun name -> rm_rf ~fs Fpath.(path / name)) entries; 106 + Eio.Path.rmdir eio_path 107 + | `Regular_file | `Symbolic_link | `Block_device | `Character_special 108 + | `Fifo | `Socket | `Unknown -> 109 + Eio.Path.unlink eio_path 110 + | `Not_found -> () 111 + | exception _ -> () 112 + 113 + (* Get list of tracked members by looking at verse/ directory *) 114 + let get_tracked_handles ~fs config = 115 + let verse_path = Verse_config.verse_path config in 116 + if not (is_directory ~fs verse_path) then [] 117 + else 118 + let eio_path = Eio.Path.(fs / Fpath.to_string verse_path) in 119 + try 120 + Eio.Path.read_dir eio_path 121 + |> List.filter (fun name -> 122 + is_directory ~fs Fpath.(verse_path / name)) 123 + with Eio.Io _ -> [] 124 + 125 + let init ~proc ~fs ~sw ~env ~root ~handle () = 126 + (* Check if config already exists in XDG *) 127 + let config_file = Verse_config.config_file () in 128 + Logs.info (fun m -> m "Config file: %a" Fpath.pp config_file); 129 + if is_file ~fs config_file then begin 130 + Logs.err (fun m -> m "Config already exists at %a" Fpath.pp config_file); 131 + Error (Workspace_exists root) 132 + end 133 + else 134 + (* Resolve root to absolute path *) 135 + let root = 136 + if Fpath.is_abs root then root 137 + else 138 + (* Get absolute path via realpath *) 139 + let root_str = Fpath.to_string root in 140 + let eio_path = Eio.Path.(fs / root_str) in 141 + (* Ensure the directory exists first so realpath works *) 142 + (try Eio.Path.mkdirs ~perm:0o755 eio_path with Eio.Io _ -> ()); 143 + match Unix.realpath root_str with 144 + | abs_str -> (match Fpath.of_string abs_str with Ok p -> p | Error _ -> root) 145 + | exception _ -> root 146 + in 147 + Logs.info (fun m -> m "Workspace root: %a" Fpath.pp root); 148 + (* Validate handle *) 149 + Logs.info (fun m -> m "Validating handle: %s" handle); 150 + match validate_handle ~sw ~env handle with 151 + | Error e -> 152 + Logs.err (fun m -> m "Handle validation failed"); 153 + Error e 154 + | Ok () -> 155 + Logs.info (fun m -> m "Handle validated successfully"); 156 + (* Create config - need this temporarily to get paths *) 157 + let config = Verse_config.create ~root ~handle () in 158 + (* Clone registry first to look up user's repos *) 159 + Logs.info (fun m -> m "Cloning registry..."); 160 + match Verse_registry.clone_or_pull ~proc ~fs ~config () with 161 + | Error msg -> 162 + Logs.err (fun m -> m "Registry clone failed: %s" msg); 163 + Error (Registry_error msg) 164 + | Ok registry -> 165 + Logs.info (fun m -> m "Registry loaded"); 166 + (* Look up user in registry *) 167 + match Verse_registry.find_member registry ~handle with 168 + | None -> 169 + Logs.err (fun m -> m "Handle %s not found in registry" handle); 170 + Error (Member_not_found handle) 171 + | Some member -> 172 + Logs.info (fun m -> m "Found member: mono=%s opam=%s" member.monorepo member.opamrepo); 173 + (* Create workspace directories *) 174 + Logs.info (fun m -> m "Creating workspace directories..."); 175 + ensure_dir ~fs root; 176 + ensure_dir ~fs (Verse_config.src_path config); 177 + ensure_dir ~fs (Verse_config.verse_path config); 178 + (* Clone user's monorepo *) 179 + let mono_path = Verse_config.mono_path config in 180 + Logs.info (fun m -> m "Cloning monorepo to %a" Fpath.pp mono_path); 181 + let mono_url = Uri.of_string member.monorepo in 182 + (match Git.clone ~proc ~fs ~url:mono_url ~branch:Verse_config.default_branch mono_path with 183 + | Error e -> 184 + Logs.err (fun m -> m "Monorepo clone failed: %a" Git.pp_error e); 185 + Error (Git_error e) 186 + | Ok () -> 187 + Logs.info (fun m -> m "Monorepo cloned"); 188 + (* Clone user's opam repo *) 189 + let opam_path = Verse_config.opam_repo_path config in 190 + Logs.info (fun m -> m "Cloning opam repo to %a" Fpath.pp opam_path); 191 + let opam_url = Uri.of_string member.opamrepo in 192 + (match Git.clone ~proc ~fs ~url:opam_url ~branch:Verse_config.default_branch opam_path with 193 + | Error e -> 194 + Logs.err (fun m -> m "Opam repo clone failed: %a" Git.pp_error e); 195 + Error (Git_error e) 196 + | Ok () -> 197 + Logs.info (fun m -> m "Opam repo cloned"); 198 + (* Save config to XDG *) 199 + Logs.info (fun m -> m "Saving config to %a" Fpath.pp config_file); 200 + (match Verse_config.save ~fs config with 201 + | Error msg -> 202 + Logs.err (fun m -> m "Failed to save config: %s" msg); 203 + Error (Config_error msg) 204 + | Ok () -> 205 + Logs.info (fun m -> m "Workspace initialized successfully"); 206 + Ok ()))) 207 + 208 + let status ~proc ~fs ~config () = 209 + (* Load registry *) 210 + match Verse_registry.clone_or_pull ~proc ~fs ~config () with 211 + | Error msg -> Error (Registry_error msg) 212 + | Ok registry -> 213 + (* Get tracked handles *) 214 + let tracked_handles = get_tracked_handles ~fs config in 215 + (* Build status for each tracked member *) 216 + let tracked_members = 217 + List.filter_map 218 + (fun handle -> 219 + (* Find member in registry *) 220 + match Verse_registry.find_member registry ~handle with 221 + | None -> 222 + (* Member not in registry but locally tracked - show anyway *) 223 + let local_path = Fpath.(Verse_config.verse_path config / handle) in 224 + let cloned = is_directory ~fs local_path in 225 + Some 226 + { 227 + handle; 228 + monorepo_url = "(not in registry)"; 229 + local_path; 230 + cloned; 231 + clean = None; 232 + ahead_behind = None; 233 + } 234 + | Some member -> 235 + let local_path = 236 + Fpath.(Verse_config.verse_path config / handle) 237 + in 238 + let cloned = Git.is_repo ~proc ~fs local_path in 239 + let clean = 240 + if cloned then Some (not (Git.is_dirty ~proc ~fs local_path)) 241 + else None 242 + in 243 + let ahead_behind = 244 + if cloned then 245 + match Git.ahead_behind ~proc ~fs local_path with 246 + | Ok ab -> Some ab 247 + | Error _ -> None 248 + else None 249 + in 250 + Some 251 + { 252 + handle; 253 + monorepo_url = member.monorepo; 254 + local_path; 255 + cloned; 256 + clean; 257 + ahead_behind; 258 + }) 259 + tracked_handles 260 + in 261 + Ok { config; registry; tracked_members } 262 + 263 + let members ~proc ~fs ~config () = 264 + match Verse_registry.clone_or_pull ~proc ~fs ~config () with 265 + | Error msg -> Error (Registry_error msg) 266 + | Ok registry -> Ok registry.members 267 + 268 + let add ~proc ~fs ~sw ~env ~config ~handle () = 269 + Logs.info (fun m -> m "Adding member: %s" handle); 270 + (* Validate handle *) 271 + match validate_handle ~sw ~env handle with 272 + | Error e -> Error e 273 + | Ok () -> ( 274 + (* Load registry *) 275 + match Verse_registry.clone_or_pull ~proc ~fs ~config () with 276 + | Error msg -> Error (Registry_error msg) 277 + | Ok registry -> ( 278 + (* Find member *) 279 + match Verse_registry.find_member registry ~handle with 280 + | None -> Error (Member_not_found handle) 281 + | Some member -> 282 + (* Ensure verse directory exists *) 283 + let verse_dir = Verse_config.verse_path config in 284 + Logs.info (fun m -> m "Verse directory: %a" Fpath.pp verse_dir); 285 + ensure_dir ~fs verse_dir; 286 + let local_path = Fpath.(verse_dir / handle) in 287 + Logs.info (fun m -> m "Clone target: %a" Fpath.pp local_path); 288 + (* Check if already cloned *) 289 + if Git.is_repo ~proc ~fs local_path then begin 290 + Logs.info (fun m -> m "Already cloned"); 291 + Ok () 292 + end 293 + else begin 294 + (* Clone the monorepo *) 295 + let url = Uri.of_string member.monorepo in 296 + Logs.info (fun m -> m "Cloning from %s" member.monorepo); 297 + match Git.clone ~proc ~fs ~url ~branch:Verse_config.default_branch local_path with 298 + | Error e -> Error (Git_error e) 299 + | Ok () -> 300 + Logs.info (fun m -> m "Clone succeeded"); 301 + Ok () 302 + end)) 303 + 304 + let remove ~fs ~config ~handle () = 305 + let local_path = Fpath.(Verse_config.verse_path config / handle) in 306 + if not (is_directory ~fs local_path) then 307 + Error (Member_not_found handle) 308 + else begin 309 + rm_rf ~fs local_path; 310 + Ok () 311 + end 312 + 313 + let pull ~proc ~fs ~config ?handle () = 314 + match handle with 315 + | Some h -> 316 + let local_path = Fpath.(Verse_config.verse_path config / h) in 317 + if not (Git.is_repo ~proc ~fs local_path) then 318 + Error (Member_not_found h) 319 + else 320 + (match Git.pull ~proc ~fs local_path with 321 + | Error e -> Error (Git_error e) 322 + | Ok () -> Ok ()) 323 + | None -> 324 + (* Pull all tracked members *) 325 + let tracked_handles = get_tracked_handles ~fs config in 326 + let errors = 327 + List.filter_map 328 + (fun h -> 329 + let local_path = Fpath.(Verse_config.verse_path config / h) in 330 + if Git.is_repo ~proc ~fs local_path then 331 + match Git.pull ~proc ~fs local_path with 332 + | Error e -> Some (Fmt.str "%s: %a" h Git.pp_error e) 333 + | Ok () -> None 334 + else None) 335 + tracked_handles 336 + in 337 + if errors = [] then Ok () 338 + else Error (Git_error (Git.Io_error (String.concat "; " errors))) 339 + 340 + let sync ~proc ~fs ~config () = 341 + (* Update registry *) 342 + match Verse_registry.clone_or_pull ~proc ~fs ~config () with 343 + | Error msg -> Error (Registry_error msg) 344 + | Ok _registry -> 345 + (* Pull all tracked members *) 346 + pull ~proc ~fs ~config ()
+145
lib/verse.mli
··· 1 + (** Monoverse operations. 2 + 3 + Federated monorepo collaboration. Members are identified by tangled handles 4 + with strict validation via the AT Protocol identity system. *) 5 + 6 + (** {1 Error Types} *) 7 + 8 + type error = 9 + | Config_error of string (** Configuration loading/saving error *) 10 + | Git_error of Git.error (** Git operation failed *) 11 + | Registry_error of string (** Registry clone/pull/parse error *) 12 + | Handle_not_found of string (** Handle could not be resolved *) 13 + | Not_authenticated (** Tangled login required *) 14 + | Member_not_found of string (** Handle not in registry *) 15 + | Workspace_exists of Fpath.t (** Workspace already initialized *) 16 + | Not_a_workspace of Fpath.t (** Not a opamverse workspace *) 17 + 18 + val pp_error : error Fmt.t 19 + (** [pp_error] formats errors. *) 20 + 21 + (** {1 Status Types} *) 22 + 23 + type member_status = { 24 + handle : string; (** Member's tangled handle *) 25 + monorepo_url : string; (** Git URL of member's monorepo *) 26 + local_path : Fpath.t; (** Local path under verse/ *) 27 + cloned : bool; (** Whether the monorepo is cloned locally *) 28 + clean : bool option; (** Whether the clone is clean (None if not cloned) *) 29 + ahead_behind : Git.ahead_behind option; (** Ahead/behind status (None if not cloned) *) 30 + } 31 + (** Status of a member's monorepo in the workspace. *) 32 + 33 + type status = { 34 + config : Verse_config.t; (** Workspace configuration *) 35 + registry : Verse_registry.t; (** Registry contents *) 36 + tracked_members : member_status list; (** Status of tracked members *) 37 + } 38 + (** Workspace status. *) 39 + 40 + val pp_member_status : member_status Fmt.t 41 + (** [pp_member_status] formats a member's status. *) 42 + 43 + val pp_status : status Fmt.t 44 + (** [pp_status] formats workspace status. *) 45 + 46 + (** {1 Operations} *) 47 + 48 + val init : 49 + proc:_ Eio.Process.mgr -> 50 + fs:Eio.Fs.dir_ty Eio.Path.t -> 51 + sw:Eio.Switch.t -> 52 + env:< clock : _ Eio.Time.clock ; net : _ Eio.Net.t ; fs : Eio.Fs.dir_ty Eio.Path.t ; .. > -> 53 + root:Fpath.t -> 54 + handle:string -> 55 + unit -> 56 + (unit, error) result 57 + (** [init ~proc ~fs ~sw ~env ~root ~handle ()] initializes a new opamverse workspace. 58 + 59 + Creates the workspace structure: 60 + - [root/.opamverse/config.toml] 61 + - [root/.opamverse/registry/] (cloned registry) 62 + - [root/mono/] (user's monorepo) 63 + - [root/src/] (source checkouts) 64 + - [root/verse/] (other users' monorepos) 65 + 66 + The handle is validated against the tangled network (requires prior login). 67 + 68 + @param proc Eio process manager 69 + @param fs Eio filesystem 70 + @param sw Eio switch 71 + @param env Eio environment for tangled API 72 + @param root Workspace root (must be absolute) 73 + @param handle User's tangled handle *) 74 + 75 + val status : 76 + proc:_ Eio.Process.mgr -> 77 + fs:Eio.Fs.dir_ty Eio.Path.t -> 78 + config:Verse_config.t -> 79 + unit -> 80 + (status, error) result 81 + (** [status ~proc ~fs ~config ()] returns the workspace status. 82 + 83 + Shows which members are tracked and the state of their local clones. *) 84 + 85 + val members : 86 + proc:_ Eio.Process.mgr -> 87 + fs:Eio.Fs.dir_ty Eio.Path.t -> 88 + config:Verse_config.t -> 89 + unit -> 90 + (Verse_registry.member list, error) result 91 + (** [members ~proc ~fs ~config ()] returns all registry members. 92 + 93 + Pulls the latest registry before returning the member list. *) 94 + 95 + val add : 96 + proc:_ Eio.Process.mgr -> 97 + fs:Eio.Fs.dir_ty Eio.Path.t -> 98 + sw:Eio.Switch.t -> 99 + env:< clock : _ Eio.Time.clock ; net : _ Eio.Net.t ; fs : Eio.Fs.dir_ty Eio.Path.t ; .. > -> 100 + config:Verse_config.t -> 101 + handle:string -> 102 + unit -> 103 + (unit, error) result 104 + (** [add ~proc ~fs ~sw ~env ~config ~handle ()] adds a member to the workspace. 105 + 106 + Validates the handle against tangled, looks up the monorepo URL from the 107 + registry, and clones it to [verse/<handle>/]. 108 + 109 + @param handle Tangled handle of the member to add *) 110 + 111 + val remove : 112 + fs:Eio.Fs.dir_ty Eio.Path.t -> 113 + config:Verse_config.t -> 114 + handle:string -> 115 + unit -> 116 + (unit, error) result 117 + (** [remove ~fs ~config ~handle ()] removes a member from the workspace. 118 + 119 + Deletes the member's monorepo clone from [verse/<handle>/]. 120 + 121 + @param handle Tangled handle of the member to remove *) 122 + 123 + val pull : 124 + proc:_ Eio.Process.mgr -> 125 + fs:Eio.Fs.dir_ty Eio.Path.t -> 126 + config:Verse_config.t -> 127 + ?handle:string -> 128 + unit -> 129 + (unit, error) result 130 + (** [pull ~proc ~fs ~config ?handle ()] pulls updates for members. 131 + 132 + If [handle] is specified, only pulls that member. Otherwise pulls all 133 + tracked members. 134 + 135 + @param handle Optional specific member to pull *) 136 + 137 + val sync : 138 + proc:_ Eio.Process.mgr -> 139 + fs:Eio.Fs.dir_ty Eio.Path.t -> 140 + config:Verse_config.t -> 141 + unit -> 142 + (unit, error) result 143 + (** [sync ~proc ~fs ~config ()] syncs the workspace. 144 + 145 + Updates the registry and pulls updates for all tracked members. *)
+119
lib/verse_config.ml
··· 1 + let app_name = "monopam" 2 + 3 + (* Simplified config: just root and handle. Paths are hardcoded. *) 4 + type t = { 5 + root : Fpath.t; 6 + handle : string; 7 + } 8 + 9 + let root t = t.root 10 + let handle t = t.handle 11 + 12 + (* Hardcoded paths derived from root *) 13 + let default_branch = "main" 14 + let mono_path t = Fpath.(t.root / "mono") 15 + let src_path t = Fpath.(t.root / "src") 16 + let opam_repo_path t = Fpath.(t.root / "opam-repo") 17 + let verse_path t = Fpath.(t.root / "verse") 18 + 19 + (* Compute XDG directories following XDG Base Directory Specification *) 20 + let xdg_config_home () = 21 + match Sys.getenv_opt "XDG_CONFIG_HOME" with 22 + | Some dir when dir <> "" -> Fpath.v dir 23 + | _ -> 24 + match Sys.getenv_opt "HOME" with 25 + | Some home -> Fpath.(v home / ".config") 26 + | None -> Fpath.v "/tmp" 27 + 28 + let xdg_data_home () = 29 + match Sys.getenv_opt "XDG_DATA_HOME" with 30 + | Some dir when dir <> "" -> Fpath.v dir 31 + | _ -> 32 + match Sys.getenv_opt "HOME" with 33 + | Some home -> Fpath.(v home / ".local" / "share") 34 + | None -> Fpath.v "/tmp" 35 + 36 + let config_dir () = Fpath.(xdg_config_home () / app_name) 37 + let data_dir () = Fpath.(xdg_data_home () / app_name) 38 + let config_file () = Fpath.(config_dir () / "opamverse.toml") 39 + let registry_path () = Fpath.(data_dir () / "opamverse-registry") 40 + 41 + let create ~root ~handle () = { root; handle } 42 + 43 + let expand_tilde s = 44 + if String.length s > 0 && s.[0] = '~' then 45 + match Sys.getenv_opt "HOME" with 46 + | Some home -> 47 + if String.length s = 1 then home 48 + else if s.[1] = '/' then home ^ String.sub s 1 (String.length s - 1) 49 + else s 50 + | None -> s 51 + else s 52 + 53 + let fpath_codec : Fpath.t Tomlt.t = 54 + Tomlt.map 55 + ~dec:(fun s -> 56 + let s = expand_tilde s in 57 + match Fpath.of_string s with Ok p -> p | Error (`Msg m) -> failwith m) 58 + ~enc:Fpath.to_string Tomlt.string 59 + 60 + (* Simplified TOML structure: 61 + [workspace] 62 + root = "~/tangled" 63 + 64 + [identity] 65 + handle = "anil.recoil.org" 66 + *) 67 + 68 + type workspace_section = { w_root : Fpath.t } 69 + type identity_section = { i_handle : string } 70 + 71 + let workspace_codec : workspace_section Tomlt.t = 72 + Tomlt.( 73 + Table.( 74 + obj (fun w_root -> { w_root }) 75 + |> mem "root" fpath_codec ~enc:(fun w -> w.w_root) 76 + |> finish)) 77 + 78 + let identity_codec : identity_section Tomlt.t = 79 + Tomlt.( 80 + Table.( 81 + obj (fun i_handle -> { i_handle }) 82 + |> mem "handle" string ~enc:(fun i -> i.i_handle) 83 + |> finish)) 84 + 85 + let codec : t Tomlt.t = 86 + Tomlt.( 87 + Table.( 88 + obj (fun workspace identity -> 89 + { root = workspace.w_root; handle = identity.i_handle }) 90 + |> mem "workspace" workspace_codec ~enc:(fun t -> { w_root = t.root }) 91 + |> mem "identity" identity_codec ~enc:(fun t -> { i_handle = t.handle }) 92 + |> finish)) 93 + 94 + let load ~fs () = 95 + let path = config_file () in 96 + let path_str = Fpath.to_string path in 97 + try Ok (Tomlt_eio.decode_path_exn codec ~fs path_str) 98 + with 99 + | Eio.Io _ as e -> Error (Printexc.to_string e) 100 + | Failure msg -> Error (Fmt.str "Invalid config: %s" msg) 101 + 102 + let save ~fs t = 103 + let dir = config_dir () in 104 + let path = config_file () in 105 + try 106 + (* Ensure XDG config directory exists *) 107 + let dir_path = Eio.Path.(fs / Fpath.to_string dir) in 108 + (try Eio.Path.mkdirs ~perm:0o755 dir_path with Eio.Io _ -> ()); 109 + Tomlt_eio.encode_path codec t ~fs (Fpath.to_string path); 110 + Ok () 111 + with Eio.Io _ as e -> Error (Printexc.to_string e) 112 + 113 + let pp ppf t = 114 + Fmt.pf ppf 115 + "@[<v>workspace:@,\ 116 + \ root: %a@,\ 117 + identity:@,\ 118 + \ handle: %s@]" 119 + Fpath.pp t.root t.handle
+83
lib/verse_config.mli
··· 1 + (** Opamverse workspace configuration. 2 + 3 + Configuration is stored in the XDG config directory at 4 + [~/.config/monopam/opamverse.toml]. 5 + 6 + The config stores just the workspace root and user's handle. 7 + All paths are derived from the root: 8 + - [mono/] - user's monorepo 9 + - [src/] - git checkouts for subtrees 10 + - [opam-repo/] - opam overlay repository 11 + - [verse/] - other members' monorepos *) 12 + 13 + (** {1 Types} *) 14 + 15 + type t 16 + (** Opamverse workspace configuration. *) 17 + 18 + (** {1 Accessors} *) 19 + 20 + val root : t -> Fpath.t 21 + (** [root t] returns the workspace root directory. *) 22 + 23 + val handle : t -> string 24 + (** [handle t] returns the user's tangled handle. *) 25 + 26 + (** {1 Derived Paths} *) 27 + 28 + val default_branch : string 29 + (** Default git branch, always ["main"]. *) 30 + 31 + val mono_path : t -> Fpath.t 32 + (** [mono_path t] returns the path to the user's monorepo ([root/mono/]). *) 33 + 34 + val src_path : t -> Fpath.t 35 + (** [src_path t] returns the path to git checkouts ([root/src/]). *) 36 + 37 + val opam_repo_path : t -> Fpath.t 38 + (** [opam_repo_path t] returns the path to the opam overlay ([root/opam-repo/]). *) 39 + 40 + val verse_path : t -> Fpath.t 41 + (** [verse_path t] returns the path to tracked members' monorepos ([root/verse/]). *) 42 + 43 + (** {1 XDG Paths} *) 44 + 45 + val config_dir : unit -> Fpath.t 46 + (** [config_dir ()] returns the XDG config directory for monopam 47 + (~/.config/monopam). *) 48 + 49 + val data_dir : unit -> Fpath.t 50 + (** [data_dir ()] returns the XDG data directory for monopam 51 + (~/.local/share/monopam). *) 52 + 53 + val config_file : unit -> Fpath.t 54 + (** [config_file ()] returns the path to the opamverse config file 55 + (~/.config/monopam/opamverse.toml). *) 56 + 57 + val registry_path : unit -> Fpath.t 58 + (** [registry_path ()] returns the path to the cloned registry git repo 59 + (~/.local/share/monopam/opamverse-registry). *) 60 + 61 + (** {1 Loading and Saving} *) 62 + 63 + val load : fs:Eio.Fs.dir_ty Eio.Path.t -> unit -> (t, string) result 64 + (** [load ~fs ()] loads the workspace configuration from the XDG config file. 65 + 66 + @param fs Eio filesystem *) 67 + 68 + val save : fs:Eio.Fs.dir_ty Eio.Path.t -> t -> (unit, string) result 69 + (** [save ~fs config] saves the configuration to the XDG config file. 70 + 71 + @param fs Eio filesystem 72 + @param config Configuration to save *) 73 + 74 + val create : root:Fpath.t -> handle:string -> unit -> t 75 + (** [create ~root ~handle ()] creates a new configuration. 76 + 77 + @param root Workspace root directory (absolute path) 78 + @param handle User's tangled handle *) 79 + 80 + (** {1 Pretty Printing} *) 81 + 82 + val pp : t Fmt.t 83 + (** [pp] formats a workspace configuration. *)
+133
lib/verse_registry.ml
··· 1 + type member = { handle : string; monorepo : string; opamrepo : string } 2 + type t = { name : string; members : member list } 3 + 4 + let default_url = "https://tangled.org/eeg.cl.cam.ac.uk/opamverse" 5 + 6 + let pp_member ppf m = 7 + Fmt.pf ppf "@[<hov 2>%s ->@ mono:%s@ opam:%s@]" m.handle m.monorepo m.opamrepo 8 + 9 + let pp ppf t = 10 + Fmt.pf ppf "@[<v>registry: %s@,members:@, @[<v>%a@]@]" 11 + t.name Fmt.(list ~sep:cut pp_member) t.members 12 + 13 + (* TOML structure: 14 + [registry] 15 + name = "tangled-community" 16 + 17 + [[members]] 18 + handle = "alice.bsky.social" 19 + monorepo = "https://github.com/alice/mono" 20 + opamrepo = "https://github.com/alice/opam-repo" 21 + *) 22 + 23 + let member_codec : member Tomlt.t = 24 + Tomlt.( 25 + Table.( 26 + obj (fun handle monorepo opamrepo -> { handle; monorepo; opamrepo }) 27 + |> mem "handle" string ~enc:(fun m -> m.handle) 28 + |> mem "monorepo" string ~enc:(fun m -> m.monorepo) 29 + |> mem "opamrepo" string ~enc:(fun m -> m.opamrepo) 30 + |> finish)) 31 + 32 + type registry_info = { r_name : string } 33 + 34 + let registry_info_codec : registry_info Tomlt.t = 35 + Tomlt.( 36 + Table.( 37 + obj (fun r_name -> { r_name }) 38 + |> mem "name" string ~enc:(fun r -> r.r_name) 39 + |> finish)) 40 + 41 + let codec : t Tomlt.t = 42 + Tomlt.( 43 + Table.( 44 + obj (fun registry members -> 45 + { name = registry.r_name; members = Option.value ~default:[] members }) 46 + |> mem "registry" registry_info_codec ~enc:(fun t -> { r_name = t.name }) 47 + |> opt_mem "members" (list member_codec) ~enc:(fun t -> 48 + match t.members with [] -> None | ms -> Some ms) 49 + |> finish)) 50 + 51 + let empty_registry = { name = "opamverse"; members = [] } 52 + 53 + let load ~fs path = 54 + let path_str = Fpath.to_string path in 55 + Logs.info (fun m -> m "Loading registry from path: %s" path_str); 56 + try 57 + let registry = Tomlt_eio.decode_path_exn codec ~fs path_str in 58 + Logs.info (fun m -> m "Registry loaded: %d members" (List.length registry.members)); 59 + Ok registry 60 + with 61 + | Eio.Io _ as e -> 62 + Logs.err (fun m -> m "Eio.Io error: %s" (Printexc.to_string e)); 63 + Error (Fmt.str "Registry IO error: %s" (Printexc.to_string e)) 64 + | Failure msg -> 65 + Logs.err (fun m -> m "Registry parse error: %s" msg); 66 + Error (Fmt.str "Invalid registry: %s" msg) 67 + | exn -> 68 + Logs.err (fun m -> m "Unexpected registry error: %s" (Printexc.to_string exn)); 69 + Error (Fmt.str "Registry error: %s" (Printexc.to_string exn)) 70 + 71 + let save ~fs path registry = 72 + let path_str = Fpath.to_string path in 73 + try 74 + Tomlt_eio.encode_path codec registry ~fs path_str; 75 + Ok () 76 + with Eio.Io _ as e -> Error (Printexc.to_string e) 77 + 78 + let clone_or_pull ~proc ~fs ~config:_ () = 79 + let registry_path = Verse_config.registry_path () in 80 + let registry_toml = Fpath.(registry_path / "opamverse.toml") in 81 + Logs.info (fun m -> m "Registry path: %a" Fpath.pp registry_path); 82 + (* Check if registry directory exists as a git repo *) 83 + let exists = 84 + let path = Eio.Path.(fs / Fpath.to_string registry_path) in 85 + match Eio.Path.kind ~follow:true path with 86 + | `Directory -> Git.is_repo ~proc ~fs registry_path 87 + | _ -> false 88 + | exception _ -> false 89 + in 90 + if exists then begin 91 + Logs.info (fun m -> m "Registry exists, pulling updates..."); 92 + (* Pull updates, but don't fail if pull fails *) 93 + (match Git.pull ~proc ~fs registry_path with 94 + | Ok () -> Logs.info (fun m -> m "Registry pull succeeded") 95 + | Error e -> Logs.warn (fun m -> m "Registry pull failed: %a (using cached)" Git.pp_error e)); 96 + Logs.info (fun m -> m "Loading registry from %a" Fpath.pp registry_toml); 97 + load ~fs registry_toml 98 + end 99 + else begin 100 + Logs.info (fun m -> m "Registry not found, cloning from %s..." default_url); 101 + (* Ensure parent directory exists *) 102 + let parent = Fpath.parent registry_path in 103 + let parent_path = Eio.Path.(fs / Fpath.to_string parent) in 104 + (try Eio.Path.mkdirs ~perm:0o755 parent_path with Eio.Io _ -> ()); 105 + (* Try to clone the registry *) 106 + let url = Uri.of_string default_url in 107 + let branch = "main" in 108 + match Git.clone ~proc ~fs ~url ~branch registry_path with 109 + | Ok () -> 110 + Logs.info (fun m -> m "Registry cloned successfully"); 111 + load ~fs registry_toml 112 + | Error e -> 113 + Logs.warn (fun m -> m "Registry clone failed: %a" Git.pp_error e); 114 + Logs.info (fun m -> m "Creating empty local registry..."); 115 + (* Clone failed - create local registry directory with empty registry *) 116 + let registry_eio = Eio.Path.(fs / Fpath.to_string registry_path) in 117 + (try Eio.Path.mkdirs ~perm:0o755 registry_eio with Eio.Io _ -> ()); 118 + (* Initialize as git repo *) 119 + (match Git.init ~proc ~fs registry_path with 120 + | Ok () -> () 121 + | Error _ -> ()); 122 + (* Create empty registry file *) 123 + (match save ~fs registry_toml empty_registry with 124 + | Ok () -> () 125 + | Error _ -> ()); 126 + Ok empty_registry 127 + end 128 + 129 + let find_member t ~handle = 130 + List.find_opt (fun m -> m.handle = handle) t.members 131 + 132 + let find_members t ~handles = 133 + List.filter (fun m -> List.mem m.handle handles) t.members
+62
lib/verse_registry.mli
··· 1 + (** Opamverse registry management. 2 + 3 + The registry is a git repository containing a [opamverse.toml] file that 4 + lists community members and their monorepo URLs. *) 5 + 6 + (** {1 Types} *) 7 + 8 + type member = { 9 + handle : string; (** Tangled handle (e.g., "alice.bsky.social") *) 10 + monorepo : string; (** Git URL of the member's monorepo *) 11 + opamrepo : string; (** Git URL of the member's opam overlay repository *) 12 + } 13 + (** A registry member entry. *) 14 + 15 + type t = { 16 + name : string; (** Registry name *) 17 + members : member list; (** List of registered members *) 18 + } 19 + (** The parsed registry contents. *) 20 + 21 + (** {1 Registry Operations} *) 22 + 23 + val default_url : string 24 + (** Default registry URL: [https://tangled.org/eeg.cl.cam.ac.uk/opamverse] *) 25 + 26 + val clone_or_pull : 27 + proc:_ Eio.Process.mgr -> 28 + fs:Eio.Fs.dir_ty Eio.Path.t -> 29 + config:Verse_config.t -> 30 + unit -> 31 + (t, string) result 32 + (** [clone_or_pull ~proc ~fs ~config ()] clones the registry if not present, 33 + or pulls updates if it exists. Returns the parsed registry contents. 34 + 35 + The registry is cloned to [config.registry_path]. 36 + 37 + @param proc Eio process manager 38 + @param fs Eio filesystem 39 + @param config Workspace configuration *) 40 + 41 + val load : fs:Eio.Fs.dir_ty Eio.Path.t -> Fpath.t -> (t, string) result 42 + (** [load ~fs path] loads the registry from a [opamverse.toml] file. 43 + 44 + @param fs Eio filesystem 45 + @param path Path to the opamverse.toml file *) 46 + 47 + (** {1 Member Lookup} *) 48 + 49 + val find_member : t -> handle:string -> member option 50 + (** [find_member registry ~handle] finds a member by their handle. *) 51 + 52 + val find_members : t -> handles:string list -> member list 53 + (** [find_members registry ~handles] finds multiple members by their handles. 54 + Returns only the members that were found. *) 55 + 56 + (** {1 Pretty Printing} *) 57 + 58 + val pp_member : member Fmt.t 59 + (** [pp_member] formats a registry member. *) 60 + 61 + val pp : t Fmt.t 62 + (** [pp] formats the registry. *)