···9292 let padded = Fmt.str "%-*s" handle_width h in
9393 Fmt.str "%a" Oi.Style.info_string padded
9494 in
9595- Fmt.pr "@.";
9595+ Oi.Say.newline ();
9696+ Oi.Say.header "Build summary";
9697 List.iter
9798 (fun (target, handle, r) ->
9899 if handle_width = 0 then
···121122 Fmt.pr " %a %s@." Oi.Style.dim_string "↳ solver log:" log_path
122123 | _ -> ())
123124 rows;
124124- Fmt.pr "@.%d ok, %d failed, %d depext-fail, %d skipped@." n_ok n_failed
125125- n_depext n_skipped;
125125+ Oi.Say.newline ();
126126+ Fmt.pr " %a %d %a %d %a %d %a %d@." Oi.Style.ok_string "ok" n_ok
127127+ Oi.Style.error_string "failed" n_failed Oi.Style.warn_string "depext-fail"
128128+ n_depext Oi.Style.warn_string "skipped" n_skipped;
126129 (* Dump per-target build-failure output at debug level so `-v` still
127130 shows the reason, without dumping a compiler transcript by
128131 default. *)
···388391 end
389392 else begin
390393 let layer_hashes =
394394+ let on_phase msg = Oi.Say.step "%s" msg in
391395 Oi.Pipeline.build ~sys ~proc_mgr ~fs ~clock ~cache ~data_dir ~conf ~os_key
392396 ~extra_repos:all_extras ~pins:url_project.pins ~refresh
393397 ~constraints:extra_constraints ?remote ?jobs ?toolchain
394394- ?local_packages_dir:url_project.packages_dir names
398398+ ?local_packages_dir:url_project.packages_dir ~on_phase names
395399 in
396400 match
397401 find_target_layer ~fs ~cache ~os_key ~pkg_name:target layer_hashes
+63-46
lib/cmd/clean.ml
···1919 let layers_root = Oi.Cache.root_s cache / "layers" / os_key in
2020 let index_path = Layer_index.ensure_local ~sys ~fs ~clock ~cache ~os_key in
2121 if not (Eio.Path.is_file Eio.Path.(fs / index_path)) then begin
2222- Fmt.pr "No layer index for %s; nothing to do.@." os_key;
2222+ Oi.Say.info "no layer index for %s; nothing to do" os_key;
2323 0
2424 end
2525 else begin
···3535 |> List.map (fun (n, v, h, _) -> (n, v, h))
3636 in
3737 if direct = [] then begin
3838- Fmt.pr "No cached layers found for %s.@." target;
3838+ Oi.Say.info "no cached layers found for %s" target;
3939 D10.Index.close db;
4040 0
4141 end
···5151 let dependents = close [] direct_hashes in
5252 let all_hashes = direct_hashes @ dependents in
5353 let verb = if dry_run then "Would remove" else "Removing" in
5454- Fmt.pr "@[<v>%s %d layer(s) for %s:@," verb (List.length direct) target;
5555- List.iter (fun (n, v, h) -> Fmt.pr " %s.%s %s@," n v (short h)) direct;
5454+ let dim s = Fmt.str "%a" Oi.Style.dim_string s in
5555+ Oi.Say.step "%s %d layer(s) for %s" verb (List.length direct) target;
5656+ List.iter
5757+ (fun (n, v, h) -> Oi.Say.info "%s.%s %s" n v (dim (short h)))
5858+ direct;
5659 if dependents <> [] then begin
5757- Fmt.pr "%s %d dependent layer(s):@," verb (List.length dependents);
5858- List.iter (fun h -> Fmt.pr " %s@," (short h)) dependents
6060+ Oi.Say.step "%s %d dependent layer(s)" verb (List.length dependents);
6161+ List.iter (fun h -> Oi.Say.info "%s" (dim (short h))) dependents
5962 end;
6060- Fmt.pr "@]@.";
6163 if not dry_run then begin
6264 List.iter
6365 (fun h ->
6464- let dir = layers_root / h in
6565- Eio.Path.rmtree ~missing_ok:true Eio.Path.(fs / dir))
6666+ Eio.Path.rmtree ~missing_ok:true Eio.Path.(fs / layers_root / h))
6667 all_hashes;
6768 D10.Index.delete_layers db ~hashes:all_hashes;
6868- Fmt.pr
6969- "Removed %d layer(s). Run 'oi build' to rebuild what you still \
7070- need.@."
7171- (List.length all_hashes)
6969+ Oi.Say.ok "removed %d layer(s)" (List.length all_hashes);
7070+ Oi.Say.info "run 'oi build' to rebuild what you still need"
7271 end;
7372 D10.Index.close db;
7473 List.length all_hashes
···79788079let cmd =
8180 let run () cache_dir data_dir all toolchains sources binaries dune_cache repos
8282- opam_root dry_run target =
8181+ opam_root pins dry_run target =
8382 Harness.run @@ fun env ->
8483 let { Harness.fs; clock; sys; os_key; cache; _ } =
8584 Harness.bootstrap env cache_dir
8685 in
8786 let bulk_flags =
8887 all || toolchains || sources || binaries || dune_cache || repos
8989- || opam_root
8888+ || opam_root || pins
9089 in
9190 match target with
9291 | Some t ->
9392 if bulk_flags then begin
9494- Fmt.epr
9595- "oi clean: PKG positional cannot be combined with --all / \
9696- --toolchains / --sources / --layers / --dune / --repos / \
9797- --opam-root.@.";
9393+ Oi.Say.error
9494+ "PKG positional cannot be combined with bulk flags (--all, \
9595+ --toolchains, --sources, --layers, --dune, --repos, --opam-root, \
9696+ --pins)";
9897 exit 1
9998 end
10099 else
···135134 rows
136135 in
137136 Tty.Table.pp Fmt.stdout table;
138138- Fmt.pr "@.Use --all to clean everything, or select specific items.@."
137137+ Oi.Say.newline ();
138138+ Oi.Say.info "use --all to clean everything, or select specific items"
139139 end
140140 else begin
141141 let items = Oi.Cache.cleanable_items cache ~data_dir in
142142- let find_item label =
143143- List.find_opt (fun (i : Oi.Cache.item) -> i.label = label) items
142142+ let rm_item (item : Oi.Cache.item) =
143143+ if Eio.Path.is_directory item.path || Eio.Path.is_file item.path
144144+ then begin
145145+ let sz = Oi.Cache.size ~sys item.path in
146146+ if dry_run then
147147+ Oi.Say.info "would remove %s (%a) %s" item.label
148148+ Oi.Cache.pp_size sz
149149+ (Eio.Path.native_exn item.path)
150150+ else begin
151151+ Eio.Path.rmtree ~missing_ok:true item.path;
152152+ Oi.Say.ok "removed %s (%a)" item.label Oi.Cache.pp_size sz
153153+ end
154154+ end
144155 in
145145- let rm label =
146146- match find_item label with
147147- | None -> ()
148148- | Some item ->
149149- if Eio.Path.is_directory item.path || Eio.Path.is_file item.path
150150- then begin
151151- let sz = Oi.Cache.size ~sys item.path in
152152- if dry_run then
153153- Fmt.pr "Would remove %s (%a) %s@." label Oi.Cache.pp_size sz
154154- (Eio.Path.native_exn item.path)
155155- else begin
156156- Eio.Path.rmtree ~missing_ok:true item.path;
157157- Fmt.pr "Removed %s (%a)@." label Oi.Cache.pp_size sz
158158- end
159159- end
156156+ (* [--all] sweeps everything in [cleanable_items], so adding
157157+ a new category there picks it up automatically. Per-flag
158158+ groups bundle related caches: [--layers] for instance also
159159+ clears the solve cache, run-cache, build state and
160160+ prefixes, since all of them index off layer hashes that
161161+ just got dropped. *)
162162+ let want_label = function
163163+ | _ when all -> true
164164+ | "toolchains" -> toolchains
165165+ | "sources" | "mirror" -> sources
166166+ | "layers" | "runs" | "run-cache" | "solve-cache" | "build"
167167+ | "prefixes" ->
168168+ binaries
169169+ | "dune" -> dune_cache
170170+ | "repos" -> repos
171171+ | "opam-root" -> opam_root
172172+ | "pins" -> pins
173173+ | _ -> false
160174 in
161161- if all || toolchains then rm "toolchains";
162162- if all || sources then rm "sources";
163163- if all || binaries then rm "layers";
164164- if all || binaries then rm "runs";
165165- if all || dune_cache then rm "dune";
166166- if all || repos then rm "repos";
167167- if all || opam_root then rm "opam-root";
168168- Fmt.pr "Done.@."
175175+ List.iter
176176+ (fun (item : Oi.Cache.item) ->
177177+ if want_label item.label then rm_item item)
178178+ items;
179179+ Oi.Say.step "Done"
169180 end
170181 in
171182 let all =
···202213 Regenerated on demand."
203214 [ "opam-root" ])
204215 in
216216+ let pins =
217217+ Arg.(
218218+ value & flag
219219+ & info ~doc:"Pin-depends sources and synthesized packages trees."
220220+ [ "pins" ])
221221+ in
205222 let dry_run =
206223 Arg.(
207224 value & flag
···240257 Cmd.v info
241258 Term.(
242259 const run $ Terms.log $ Terms.cache_dir $ Terms.data_dir $ all
243243- $ toolchains $ sources $ binaries $ dune_cache $ repos $ opam_root
260260+ $ toolchains $ sources $ binaries $ dune_cache $ repos $ opam_root $ pins
244261 $ dry_run $ target)
+16-12
lib/cmd/docker.ml
···4141 in
4242 let df = Registry_docker.dockerfile_project ~cmd ~generator distro in
4343 Registry_docker.write_dockerfile path df;
4444- Fmt.pr "Wrote %s@.Build with: docker build -t %s -f %s %s@." path tag_label
4545- path cwd_s
4444+ Oi.Say.step "Wrote %s" path;
4545+ Oi.Say.info "build with: docker build -t %s -f %s %s" tag_label path cwd_s
46464747(* Multi-distro registry build project: one [Dockerfile.<distro>] per
4848 target distribution, plus [Dockerfile.oi] (static oi builder) and
···5454 let df_oi = Registry_docker.dockerfile_oi ~src_context in
5555 let oi_path = output / "Dockerfile.oi" in
5656 Registry_docker.write_dockerfile oi_path df_oi;
5757- Fmt.pr "Computing overlay depexts for %d distros...@."
5757+ Oi.Say.step "Computing overlay depexts for %d distros"
5858 (List.length default_distros);
5959 let per_distro_depexts =
6060 try
···8181 ~registry_host_path:"./registry" ()
8282 in
8383 Registry_docker.write_file compose_path compose_yaml;
8484- Fmt.pr "Wrote:@.";
8585- Fmt.pr " %s@." oi_path;
8484+ Oi.Say.step "Wrote";
8585+ Oi.Say.info "%s" oi_path;
8686 List.iter
8787 (fun (_, path, n) ->
8888- if n = 0 then Fmt.pr " %s@." path
8989- else Fmt.pr " %s (%d overlay depexts)@." path n)
8888+ if n = 0 then Oi.Say.info "%s" path
8989+ else
9090+ Oi.Say.info "%s %a" path Oi.Style.dim_string
9191+ (Fmt.str "(%d overlay depexts)" n))
9092 per_distro_paths;
9191- Fmt.pr " %s@.@." compose_path;
9292- Fmt.pr "Static oi release binary:@.";
9393- Fmt.pr " docker buildx build -f %s --output type=local,dest=./oi-bin .@."
9393+ Oi.Say.info "%s" compose_path;
9494+ Oi.Say.newline ();
9595+ Oi.Say.step "Static oi release binary";
9696+ Oi.Say.info "docker buildx build -f %s --output type=local,dest=./oi-bin ."
9497 oi_path;
9595- Fmt.pr "Run the build + export:@.";
9696- Fmt.pr " docker compose up --build # writes ./registry/<os_key>/@."
9898+ Oi.Say.step "Run the build + export";
9999+ Oi.Say.info "docker compose up --build %a" Oi.Style.dim_string
100100+ "# writes ./registry/<os_key>/"
9710198102let cmd =
99103 let run () data_dir cache_dir refresh test_mode all distro src_context output
+1-3
lib/cmd/harness.ml
···5555 let net = Eio.Stdenv.net env in
5656 let stdout = Eio.Stdenv.stdout env in
5757 let stderr = Eio.Stdenv.stderr env in
5858- let sys =
5959- D10.Sysops.create ~stdout ~stderr ~proc_mgr ~fs ~net ~clock ()
6060- in
5858+ let sys = D10.Sysops.create ~stdout ~stderr ~proc_mgr ~fs ~net ~clock () in
6159 let platform = Osrel.detect ~proc_mgr ~fs in
6260 let os_key = D10.Os_key.(to_string (of_platform platform)) in
6361 let cache = Oi.Cache.create ~root:cache_dir fs in
+33-6
lib/cmd/layer_index.ml
···4646 end;
4747 index_path
48484949-let ensure_remote ~sys ~fs ~cache ~os_key ~registry =
4949+let ensure_remote ?on_phase ~sys ~fs ~cache ~os_key ~registry () =
5050 if registry = "" then None
5151 else
5252 let cache_root = Oi.Cache.root_s cache in
···6565 let dst = Eio.Path.(fs / tmp_path) in
6666 Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 Eio.Path.(fs / os_dir);
6767 (try Unix.unlink tmp_path with Unix.Unix_error _ -> ());
6868- Logs.app (fun m ->
6969- m "Fetching registry index from %s (this may take a moment)..." url);
7070- if D10.Sysops.Http.fetch sys ~url ~dst then begin
6868+ (* Visible status goes through [on_phase] so [oi run] can wire it
6969+ into its preflight bar; otherwise it's a quiet [Logs.info]. The
7070+ previous [Logs.app] leaked the message to stderr unconditionally,
7171+ polluting [oi run]'s output. *)
7272+ (match on_phase with
7373+ | Some f -> f "Fetching registry index"
7474+ | None ->
7575+ Logs.info (fun m ->
7676+ m "Fetching registry index from %s (this may take a moment)..."
7777+ url));
7878+ let fmt_size n =
7979+ if Int64.compare n 1_048_576L >= 0 then
8080+ Fmt.str "%.1fMB" (Int64.to_float n /. 1_048_576.)
8181+ else if Int64.compare n 1024L >= 0 then
8282+ Fmt.str "%.0fKB" (Int64.to_float n /. 1024.)
8383+ else Fmt.str "%LdB" n
8484+ in
8585+ let on_progress =
8686+ Option.map
8787+ (fun f ~received ~total ->
8888+ match total with
8989+ | Some t when Int64.compare t 0L > 0 ->
9090+ f
9191+ (Fmt.str "Fetching registry index (%s / %s)"
9292+ (fmt_size received) (fmt_size t))
9393+ | _ ->
9494+ f (Fmt.str "Fetching registry index (%s)" (fmt_size received)))
9595+ on_phase
9696+ in
9797+ if D10.Sysops.Http.fetch ?on_progress sys ~url ~dst then begin
7198 (try Unix.rename tmp_path local_path
7299 with Unix.Unix_error _ -> (
73100 try Unix.unlink tmp_path with Unix.Unix_error _ -> ()));
···100127 try Sys.remove remote_path with Sys_error _ -> ()));
101128 D10.Index.close db
102129103103-let binary_to_package ~sys ~fs ~clock ~cache ~os_key ~registry name =
130130+let binary_to_package ?on_phase ~sys ~fs ~clock ~cache ~os_key ~registry name =
104131 let clk = (clock :> D10.Config.clk) in
105132 let index_path = ensure_local ~sys ~fs ~clock:clk ~cache ~os_key in
106106- (match ensure_remote ~sys ~fs ~cache ~os_key ~registry with
133133+ (match ensure_remote ?on_phase ~sys ~fs ~cache ~os_key ~registry () with
107134 | Some remote_path -> merge_remote_into_local ~index_path ~remote_path
108135 | None -> ());
109136 if not (Eio.Path.is_file Eio.Path.(fs / index_path)) then None
+13-2
lib/cmd/layer_index.mli
···2525 exceeds the indexed-row count. Returns the index path. *)
26262727val ensure_remote :
2828+ ?on_phase:(string -> unit) ->
2829 sys:D10.Sysops.t ->
2930 fs:Eio.Fs.dir_ty Eio.Path.t ->
3031 cache:Oi.Cache.t ->
3132 os_key:string ->
3233 registry:string ->
3434+ unit ->
3335 string option
3436(** Download the remote registry's [{os_key}/index.db] to a local cache location
3537 ([<=1h] freshness window, atomic rename). Returns the local path on success,
3636- [None] when the registry is empty/unreachable. *)
3838+ [None] when the registry is empty/unreachable.
3939+4040+ [on_phase] surfaces the "fetching registry index" status to a caller-
4141+ supplied sink (e.g. a TTY progress bar). When omitted, the status is routed
4242+ to [Logs.info] (visible only with [-v]). *)
37433844val merge_remote_into_local : index_path:string -> remote_path:string -> unit
3945(** Open [index_path] (the local index) and merge every row from [remote_path].
4046 A corrupt remote file is unlinked so the next call re-downloads it. *)
41474248val binary_to_package :
4949+ ?on_phase:(string -> unit) ->
4350 sys:D10.Sysops.t ->
4451 fs:Eio.Fs.dir_ty Eio.Path.t ->
4552 clock:_ Eio.Time.clock ->
···5259 merged local+remote index has a row for the binary [name]. The overlay
5360 handle (if any) is load-bearing: the package may only be available in that
5461 overlay's [packages/] tree, so callers should add the handle to their repo
5555- set before solving. *)
6262+ set before solving.
6363+6464+ [on_phase], if supplied, is invoked with status updates while fetching the
6565+ remote index — used by [oi run]'s preflight bar to keep the user informed
6666+ during the network call. *)
+2-2
lib/cmd/registry_docker.ml
···336336 safety. Inside a Linux container with a high [nofile] ulimit (set on
337337 the service below) we want to use every core on the host. *)
338338let build_export_cmd () =
339339- "OI_BUILD_PARALLELISM=$(nproc) oi build --refresh --all --registry= \
340340- --export /out"
339339+ "OI_BUILD_PARALLELISM=$(nproc) oi build --refresh --all --registry= --export \
340340+ /out"
341341342342let docker_compose_yaml ~distros ~registry_host_path () =
343343 let buf = Buffer.create 1024 in
+6-6
lib/cmd/registry_docker.mli
···6262 whose services each bind-mount [registry_host_path] at [/out] and run
6363 [oi build --refresh --all --export /out]. Every container owns its own oi
6464 state — [oi] auto-clones the reporepo from its configured default URL
6565- (overridable via [OI_REPOREPO_URL] in the service environment) on first
6666- use — so containers are independent and safe to run in parallel. Layers
6767- are tagged with their overlay handle and version in the per-distro
6868- sqlite index so clients can scope queries to a specific overlay.
6565+ (overridable via [OI_REPOREPO_URL] in the service environment) on first use
6666+ — so containers are independent and safe to run in parallel. Layers are
6767+ tagged with their overlay handle and version in the per-distro sqlite index
6868+ so clients can scope queries to a specific overlay.
69697070 Each service is generated with [OI_BUILD_PARALLELISM=$(nproc)] in its
7171 command and a high [nofile] ulimit so the in-container build uses every
7272 available CPU rather than the [min cpu_count 4] default that
7373- {!Oi.Execute.default_build_parallelism} applies for macOS fd-limit
7474- safety. Suitable for many-core hosts. *)
7373+ {!Oi.Execute.default_build_parallelism} applies for macOS fd-limit safety.
7474+ Suitable for many-core hosts. *)
75757676val write_dockerfile : string -> Dockerfile.t -> unit
7777(** Serialise a {!Dockerfile.t} to [path] in Dockerfile syntax. *)
+5-5
lib/cmd/registry_export.ml
···4141 let dst = Eio.Path.(fs / output) in
4242 Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst;
4343 let count = D10.Layer.export_all d10 ~dst in
4444- Fmt.pr "Exported %d layer(s) to %s@." count output;
4444+ Oi.Say.step "Exported %d layer(s) to %s" count output;
4545 if Eio.Path.is_directory Eio.Path.(fs / output / os_key) then begin
4646 let index_path = output / os_key / "index.db" in
4747 (try Sys.remove index_path with Sys_error _ -> ());
···7070 let nl, nb, _ = D10.Index.stats db ~os_key in
7171 D10.Index.close db;
7272 finalize_sqlite_for_publish index_path;
7373- Fmt.pr " %s: %d layers, %d binaries@." os_key nl nb
7373+ Oi.Say.field "index" "%s: %d layers, %d binaries" os_key nl nb
7474 end;
7575 let n_sources = Oi.Source.Mirror.export ~cache ~dst in
7676 if n_sources > 0 then
7777- Fmt.pr " sources: %d blob(s) at %s/sources/@." n_sources output;
7777+ Oi.Say.field "sources" "%d blob(s) at %s/sources/" n_sources output;
7878 (* Manifest = Provenance ⨝ Audit. Provenance gives us one entry per
7979 successfully committed layer with its content fields; the audit log
8080 gives us a [callers[]] history per layer. Failed-build events that
···9595 with
9696 | Ok s ->
9797 Eio.Path.save ~create:(`Or_truncate 0o644) Eio.Path.(fs / path) s;
9898- Fmt.pr " manifest: %d entry(ies) at %s@." manifest.n_packages path
9898+ Oi.Say.field "manifest" "%d entry(ies) at %s" manifest.n_packages path
9999 | Error e -> Logs.warn (fun m -> m "manifest encode failed: %s" e));
100100 (* Ship the per-os audit slice so multi-host registry merges can
101101 union events. Sorted by event_id (ULID = monotonic) so the file is
102102 deterministic. *)
103103 if events <> [] then begin
104104 Oi.Audit.write_per_os ~fs ~output_dir:output ~os_key events;
105105- Fmt.pr " audit: %d event(s) at %s/audit.jsonl@." (List.length events)
105105+ Oi.Say.field "audit" "%d event(s) at %s/audit.jsonl" (List.length events)
106106 (output / os_key)
107107 end
108108 end
+79-7
lib/cmd/run.ml
···230230 can include them and suggest the right [oi run --with=…]
231231 invocation. *)
232232 let unfound_bins = ref [] in
233233+ (* TTY-only preflight spinner wrapper around [Pipeline.build].
234234+ Renders a single-line animated braille spinner with a status
235235+ message that updates at each preflight phase boundary (and at
236236+ ~20Hz during [fetch_remote_layers]'s byte counter). The line is
237237+ wiped clean by [preflight_done] before [Execute.run] takes over
238238+ with its own progress bar.
239239+240240+ We don't use [Tty.Progress] here because preflight phases don't
241241+ have a meaningful numeric progress fraction — the bar would just
242242+ show [0/1] forever, which is visual noise. A spinner says "I'm
243243+ alive, here's what I'm doing" without pretending to know how
244244+ close to done we are.
245245+246246+ On non-TTY, [enabled=false] makes everything a no-op so
247247+ [oi run]'s contract of "stdout is for the executed binary, not
248248+ oi narration" holds. *)
249249+ let with_preflight_bar f =
250250+ Eio.Switch.run @@ fun sw ->
251251+ let enabled = Tty.is_tty () in
252252+ let stopped = ref false in
253253+ let msg = ref "Preparing" in
254254+ let frames = [| "⠋"; "⠙"; "⠹"; "⠸"; "⠼"; "⠴"; "⠦"; "⠧"; "⠇"; "⠏" |] in
255255+ let frame = ref 0 in
256256+ (* Erase the current line and redraw spinner+message in place.
257257+ [\r] returns to column 0; [\027[K] erases to end-of-line. The
258258+ trailing [%!] flushes the formatter so the line shows up
259259+ immediately rather than at the OS buffer's discretion. *)
260260+ let render () =
261261+ if enabled && not !stopped then
262262+ Fmt.pr "\r\027[K%a %s%!" Oi.Style.accent_string
263263+ frames.(!frame mod Array.length frames)
264264+ !msg
265265+ in
266266+ let clear_line () = if enabled then Fmt.pr "\r\027[K%!" in
267267+ Eio.Fiber.fork_daemon ~sw (fun () ->
268268+ let rec loop () =
269269+ Eio.Time.sleep clock 0.1;
270270+ if !stopped then `Stop_daemon
271271+ else begin
272272+ incr frame;
273273+ render ();
274274+ loop ()
275275+ end
276276+ in
277277+ loop ());
278278+ let on_phase m =
279279+ if not !stopped then begin
280280+ msg := m;
281281+ render ()
282282+ end
283283+ in
284284+ let preflight_done () =
285285+ stopped := true;
286286+ clear_line ()
287287+ in
288288+ Fun.protect
289289+ ~finally:(fun () ->
290290+ stopped := true;
291291+ clear_line ())
292292+ (fun () -> f ~on_phase ~preflight_done)
293293+ in
233294 (* Solve [pkg_names], assemble the consumer prefix, exec
234295 [bin/<binary_name>] if it exists; falls back to looking under a
235296 non-relocatable toolchain's fixed prefix before giving up.
···245306 ~toolchain
246307 in
247308 let layer_hashes =
309309+ with_preflight_bar @@ fun ~on_phase ~preflight_done ->
248310 Oi.Pipeline.build ~sys ~proc_mgr ~fs ~clock ~cache ~data_dir ~conf ~os_key
249311 ~dry_run ~extra_repos:all_extras ~pins:project_pins ~refresh ?remote
250312 ?jobs ?toolchain ~constraints:extra_constraints ?local_packages_dir
251251- names
313313+ ~on_phase ~preflight_done names
252314 in
253315 Logs.info (fun m -> m "Got %d layer hashes" (List.length layer_hashes));
254316 let prefix =
···350412 let layer_hashes =
351413 if dep_opam_names = [] then []
352414 else
415415+ with_preflight_bar @@ fun ~on_phase ~preflight_done ->
353416 Oi.Pipeline.build ~sys ~proc_mgr ~fs ~clock ~cache ~data_dir ~conf
354417 ~os_key ~dry_run ~extra_repos:all_extras ~pins:project_pins ~refresh
355355- ?remote ?jobs ?toolchain ~constraints ?local_packages_dir
356356- dep_opam_names
418418+ ?remote ?jobs ?toolchain ~constraints ?local_packages_dir ~on_phase
419419+ ~preflight_done dep_opam_names
357420 in
358421 if dry_run && dep_opam_names = [] then
359422 (* No deps to solve, but still in dry-run mode — just exit *)
···486549 overlay (when present) through [@handle/pkg] so the
487550 overlay is added to [with_repos], then re-solve. *)
488551 let clk = (Eio.Stdenv.clock env :> D10.Config.clk) in
552552+ (* Open a TTY-only spinner around the index lookup so the multi-
553553+ second registry fetch isn't a silent freeze on cold caches.
554554+ The bar auto-clears when the lookup returns. *)
489555 let from_index =
490490- match
491491- Layer_index.binary_to_package ~sys ~fs ~clock:clk ~cache ~os_key
492492- ~registry binary_name
493493- with
556556+ with_preflight_bar @@ fun ~on_phase ~preflight_done ->
557557+ let r =
558558+ Layer_index.binary_to_package ~on_phase ~sys ~fs ~clock:clk ~cache
559559+ ~os_key ~registry binary_name
560560+ in
561561+ preflight_done ();
562562+ r
563563+ in
564564+ let from_index =
565565+ match from_index with
494566 | Some (pkg_name, _) when pkg_name <> binary_name ->
495567 (* Layer index says [bin/<binary_name>] is shipped by
496568 [pkg_name] (and optionally an overlay handle).
+1-1
lib/cmd/search.ml
···177177 let index_path =
178178 Layer_index.ensure_local ~sys ~fs ~clock:clk ~cache ~os_key
179179 in
180180- (match Layer_index.ensure_remote ~sys ~fs ~cache ~os_key ~registry with
180180+ (match Layer_index.ensure_remote ~sys ~fs ~cache ~os_key ~registry () with
181181 | Some remote_path ->
182182 Layer_index.merge_remote_into_local ~index_path ~remote_path
183183 | None -> ());
+35-19
lib/cmd/sync.ml
···3737let install_tools ?(quiet = false) ?refresh ?jobs ~proc_mgr ~fs ~clock ~sys
3838 ~cache ~data_dir ~conf ~os_key ~extra_repos ~pins ?toolchain ?remote ~cwd ()
3939 =
4040- let say fmt =
4040+ let say_step fmt =
4141 if quiet then Fmt.kstr (fun s -> Logs.info (fun m -> m "%s" s)) fmt
4242- else Fmt.kstr (fun s -> Fmt.pr "%s@." s) fmt
4242+ else Fmt.kstr (fun s -> Oi.Say.step "%s" s) fmt
4343+ in
4444+ let say_info fmt =
4545+ if quiet then Fmt.kstr (fun s -> Logs.info (fun m -> m "%s" s)) fmt
4646+ else Fmt.kstr (fun s -> Oi.Say.info "%s" s) fmt
4347 in
4448 let warn_named name fmt =
4545- Fmt.kstr
4646- (fun s -> Fmt.epr "%a tool %s: %s@." Oi.Style.warn_string "WARN" name s)
4747- fmt
4949+ Fmt.kstr (fun s -> Oi.Say.warn "tool %s: %s" name s) fmt
4850 in
4951 let install_named ~tool_name ~constraints =
5052 let name = OpamPackage.Name.of_string tool_name in
···5961 warn_named tool_name "layer for leaf package not found";
6062 None
6163 | Some h ->
6262- say "Tool %s: %d dep(s) built, leaf layer %s" tool_name
6464+ say_info "tool %s: %d dep(s) built, leaf layer %s" tool_name
6365 (List.length hashes - 1)
6466 (short_hash h);
6567 Some h
···9092 let probed_hits = Oi.Project.Tool.(hits (probe ~fs cwd)) in
9193 match (toolchain_tools, probed_hits) with
9294 | [], [] ->
9393- say "No dev tools to install";
9595+ say_info "no dev tools to install";
9496 None
9597 | _ -> (
9698 let from_toolchain =
···111113 let unique = List.sort_uniq String.compare leaves in
112114 D10.Prefix.assemble d10 ~layer_hashes:unique
113115 ~dst:Eio.Path.(fs / tools_dir);
114114- say "Tools assembled at %s (%d tool(s), %d leaf layer(s))" tools_dir
115115- (List.length leaves) (List.length unique);
116116+ say_step "Tools assembled at %s (%d tool(s), %d leaf layer(s))"
117117+ tools_dir (List.length leaves) (List.length unique);
116118 Some tools_dir)
117119118120(* -- sync ---------------------------------------------------------------- *)
···184186 ?(envrc_mode = `Detect) ~proc_mgr ~fs ~clock ~sys ~platform ~os_key ~cache
185187 ~data_dir ~registry ~cwd () =
186188 let toolchain_override = toolchain in
187187- let say fmt =
189189+ let say_step fmt =
188190 if quiet then Fmt.kstr (fun s -> Logs.info (fun m -> m "%s" s)) fmt
189189- else Fmt.kstr (fun s -> Fmt.pr "%s@." s) fmt
191191+ else Fmt.kstr (fun s -> Oi.Say.step "%s" s) fmt
192192+ in
193193+ let say_field label fmt =
194194+ if quiet then
195195+ Fmt.kstr (fun s -> Logs.info (fun m -> m "%s: %s" label s)) fmt
196196+ else Fmt.kstr (fun s -> Oi.Say.field label "%s" s) fmt
197197+ in
198198+ let say_info fmt =
199199+ if quiet then Fmt.kstr (fun s -> Logs.info (fun m -> m "%s" s)) fmt
200200+ else Fmt.kstr (fun s -> Oi.Say.info "%s" s) fmt
190201 in
191202 Oi.Pipeline.init_opam_root ~fs ~data_dir;
192203 ignore (Oi.Source.Reporepo.ensure_base ~fs ~sys ~data_dir ~refresh ());
···199210 let deps = project.deps in
200211 if deps = [] && extra_cli = [] && url_project.roots = [] then
201212 Oi.Error.config_error "No .opam files found in %s." cwd;
202202- say "Dependencies from opam files: %s" (String.concat ", " deps);
213213+ say_step "Sync %s" cwd;
214214+ if deps <> [] then say_field "deps" "%s" (String.concat ", " deps);
203215 if url_project.roots <> [] then
204204- say "URL-supplied packages: %s" (String.concat ", " url_project.roots);
216216+ say_field "with-deps" "%s" (String.concat ", " url_project.roots);
205217 let conf =
206218 Oi.Pipeline.make_conf ~platform ~ocaml_version:Workspace.ocaml_version
207219 in
···223235 ~reporepo_path:(Terms.reporepo_path ()) ~toolchain candidate_overlays
224236 in
225237 if project_overlays <> [] then
226226- say "Project overlays (from x-repos): %s"
227227- (String.concat ", " project_overlays);
238238+ say_field "overlays" "%s" (String.concat ", " project_overlays);
228239 let with_repos = project_overlays @ with_repos in
229240 let all_extras =
230241 Target.merge_extras
···232243 ~project:(project.extra_repos @ url_project.extra_repos)
233244 in
234245 if all_extras <> [] then
235235- say "Extra repositories: %s"
246246+ say_field "extra-repos" "%s"
236247 (String.concat ", "
237248 (List.map
238249 (fun (e : Oi.Project.extra_repo) -> Fmt.str "%s (%s)" e.name e.url)
···258269 | Some _ -> project.packages_dir
259270 | None -> url_project.packages_dir
260271 in
272272+ let on_phase msg =
273273+ if quiet then Logs.info (fun m -> m "%s" msg) else Oi.Say.step "%s" msg
274274+ in
261275 Oi.Pipeline.build ~sys ~proc_mgr ~fs ~clock ~cache ~data_dir ~conf ~os_key
262276 ~extra_repos:all_extras
263277 ~pins:(project.pins @ url_project.pins)
264278 ~refresh ~constraints:extra_constraints ~project_root:cwd ?remote ?jobs
265265- ?toolchain ?local_packages_dir names
279279+ ?toolchain ?local_packages_dir ~on_phase names
266280 in
267281 let oi_dir = cwd / "_oi" in
268282 let prefix = oi_dir / "prefix" in
···285299 in
286300 (try Eio.Path.unlink envrc_path with Eio.Exn.Io _ -> ());
287301 Eio.Path.save ~create:(`Exclusive 0o644) envrc_path envrc;
288288- say "Wrote .envrc (run 'direnv allow' to activate)"
302302+ say_step "Wrote .envrc";
303303+ say_info "run 'direnv allow' to activate"
289304 end
290305 else
291306 Logs.info (fun m ->
292307 m "Skipping .envrc (--envrc=%a)" pp_envrc_mode envrc_mode);
293293- say "Prefix assembled at %s (%d packages)" prefix (List.length layer_hashes);
308308+ say_step "Prefix assembled at %s (%d packages)" prefix
309309+ (List.length layer_hashes);
294310 (prefix, toolchain)
295311296312(* True if [cwd]/_oi/prefix is missing, or any *.opam in [cwd] has been
···214214215215(* -- Remote pull --------------------------------------------------------- *)
216216217217-let pull_remote (c : Config.t) ~remote ~hash ?sha256 () =
217217+let pull_remote (c : Config.t) ~remote ~hash ?on_progress ?sha256 () =
218218 if succeeded c ~hash then true
219219 else begin
220220 let url =
···226226 let layer_dir = dir c ~hash in
227227 let tmp_file = Eio.Path.(os_layer_dir / (hash ^ ".tar.zst.tmp")) in
228228 Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 os_layer_dir;
229229- let ok = Sysops.Http.fetch c.sys ~url ~dst:tmp_file in
229229+ let ok = Sysops.Http.fetch ?on_progress c.sys ~url ~dst:tmp_file in
230230 let cleanup_tmp () = try Eio.Path.unlink tmp_file with _ -> () in
231231 if ok then begin
232232 (* Verify sha256 if provided *)
+11-2
lib/d10/layer.mli
···125125 and size. Returns an empty table on failure. *)
126126127127val pull_remote :
128128- Config.t -> remote:remote -> hash:string -> ?sha256:string -> unit -> bool
128128+ Config.t ->
129129+ remote:remote ->
130130+ hash:string ->
131131+ ?on_progress:(received:int64 -> total:int64 option -> unit) ->
132132+ ?sha256:string ->
133133+ unit ->
134134+ bool
129135(** [pull_remote c ~remote ?sha256 ~hash] downloads layer [hash] from [remote],
130136 optionally verifying the SHA-256 checksum of the downloaded archive. Returns
131137 [true] if the layer is now available with [exit_status = 0]. No-op (returns
132132- [true]) if the layer already exists locally. *)
138138+ [true]) if the layer already exists locally.
139139+140140+ [on_progress] forwards through to {!Sysops.Http.fetch} for download
141141+ progress; see that function's docs. *)
133142134143(** {1 Export} *)
135144
+45-8
lib/d10/sysops.ml
···121121 {
122122 proc_mgr :> pm;
123123 fs;
124124- net = (net :> net);
125125- clock = (clock :> clk);
124124+ net :> net;
125125+ clock :> clk;
126126 stdout;
127127 stderr;
128128 tools = { tar = "tar" };
···205205end
206206207207module Http = struct
208208+ (* Stream [src] into [sink] while invoking [on_progress] with the
209209+ running byte count. Throttles callbacks to ~20Hz so a fast
210210+ download doesn't spam the renderer (each [Tty.Progress.set] is
211211+ a synchronous ANSI write that's expensive on slow terminals).
212212+ The final tick fires unconditionally so the bar always settles
213213+ at 100% / total. *)
214214+ let copy_with_progress ?on_progress ?total src sink =
215215+ match on_progress with
216216+ | None -> Eio.Flow.copy src sink
217217+ | Some on_progress ->
218218+ let buf = Cstruct.create 65536 in
219219+ let received = ref 0L in
220220+ let last_tick = ref 0.0 in
221221+ let throttle_s = 0.05 in
222222+ let maybe_tick () =
223223+ let now = Unix.gettimeofday () in
224224+ if now -. !last_tick >= throttle_s then begin
225225+ last_tick := now;
226226+ on_progress ~received:!received ~total
227227+ end
228228+ in
229229+ (try
230230+ while true do
231231+ let n = Eio.Flow.single_read src buf in
232232+ Eio.Flow.write sink [ Cstruct.sub buf 0 n ];
233233+ received := Int64.add !received (Int64.of_int n);
234234+ maybe_tick ()
235235+ done
236236+ with End_of_file -> ());
237237+ on_progress ~received:!received ~total
238238+208239 (* In-process HTTP fetch via the [requests] library. Behaviour:
209240210241 - silent: no progress output to stdout/stderr
···216247 Returns [true] on a 2xx response with the body fully written to
217248 [dst]; [false] on any error (4xx/5xx, network failure, TLS error,
218249 timeout). Never raises — callers use the [if not (fetch …) then …]
219219- pattern. *)
220220- let fetch t ~url ~dst =
250250+ pattern.
251251+252252+ [on_progress], when supplied, is called periodically with
253253+ [(received, total)] where [total = Some n] when the response carries
254254+ a [Content-Length] header and [None] otherwise (chunked transfer).
255255+ Throttled to ~20Hz inside [copy_with_progress] so terminal redraws
256256+ don't dominate the wall-clock cost on a fast LAN. *)
257257+ let fetch ?on_progress t ~url ~dst =
221258 Eio.Switch.run @@ fun sw ->
222259 try
223223- let resp =
224224- Requests.One.get ~sw ~clock:t.clock ~net:t.net url
225225- in
260260+ let resp = Requests.One.get ~sw ~clock:t.clock ~net:t.net url in
226261 if not (Requests.Response.ok resp) then begin
227262 Log.debug (fun m ->
228263 m "http %s: status %d" url (Requests.Response.status_code resp));
229264 false
230265 end
231266 else begin
267267+ let total = Requests.Response.content_length resp in
268268+ let body = Requests.Response.body resp in
232269 Eio.Path.with_open_out ~create:(`Or_truncate 0o644) dst (fun out ->
233233- Eio.Flow.copy (Requests.Response.body resp) out);
270270+ copy_with_progress ?on_progress ?total body out);
234271 true
235272 end
236273 with exn ->
+19-8
lib/d10/sysops.mli
···2323 unit ->
2424 t
2525(** [create ?stdout ?stderr ~proc_mgr ~fs ~net ~clock ()] detects tool paths
2626- (tar variant) by probing the system via [which]. Pass the parent's
2727- [stdout] and [stderr] to enable {!Cmd.run_inherit}, which streams
2828- subprocess output to the user's terminal. [net] and [clock] are needed
2929- by {!Http.fetch} for in-process HTTP downloads. Call once at startup. *)
2626+ (tar variant) by probing the system via [which]. Pass the parent's [stdout]
2727+ and [stderr] to enable {!Cmd.run_inherit}, which streams subprocess output
2828+ to the user's terminal. [net] and [clock] are needed by {!Http.fetch} for
2929+ in-process HTTP downloads. Call once at startup. *)
30303131(** {1 File queries} *)
3232···5757end
58585959module Http : sig
6060- val fetch : t -> url:string -> dst:_ Eio.Path.t -> bool
6060+ val fetch :
6161+ ?on_progress:(received:int64 -> total:int64 option -> unit) ->
6262+ t ->
6363+ url:string ->
6464+ dst:_ Eio.Path.t ->
6565+ bool
6166 (** [fetch t ~url ~dst] downloads [url] to [dst] via the in-process HTTP
6267 client (the [requests] library). Follows redirects, requires a 2xx
6363- response, writes nothing on failure. Returns [true] on success,
6464- [false] on any error (HTTP 4xx/5xx, network failure, TLS handshake
6565- error, timeout). Never raises. *)
6868+ response, writes nothing on failure. Returns [true] on success, [false] on
6969+ any error (HTTP 4xx/5xx, network failure, TLS handshake error, timeout).
7070+ Never raises.
7171+7272+ [on_progress] is invoked periodically (throttled to ~20Hz) with the
7373+ running byte count. [total] is [Some n] when the server sent a
7474+ [Content-Length] header, [None] for chunked-transfer responses where the
7575+ size isn't known up front. The final invocation always fires with the
7676+ final byte count so a UI bar can settle at 100%. *)
6677end
67786879(** {1 Low-level command execution} *)
+21-37
lib/oi/cache.ml
···8888let cleanable_items t ~data_dir =
8989 let p sub = Eio.Path.(t.fs / t.root / sub) in
9090 let d sub = Eio.Path.(t.fs / data_dir / sub) in
9191+ let item label path description = { label; path; description } in
9192 [
9292- {
9393- label = "sources";
9494- path = p "sources";
9595- description = "Downloaded source tarballs";
9696- };
9797- {
9898- label = "layers";
9999- path = p "layers";
100100- description = "Binary layer cache (day10 format)";
101101- };
102102- { label = "runs"; path = p "runs"; description = "Cached script builds" };
103103- {
104104- label = "prefixes";
105105- path = p "prefixes";
106106- description = "Assembled prefix cache (hardlinks)";
107107- };
108108- { label = "dune"; path = p "dune"; description = "Dune shared build cache" };
109109- { label = "repos"; path = d "repos"; description = "Cloned repositories" };
110110- {
111111- label = "opam-root";
112112- path = d "opam-root";
113113- description = "Opam scaffolding (regenerated on demand)";
114114- };
115115- {
116116- label = "pins";
117117- path = p "pins";
118118- description = "Pin-depends sources and synthesized packages trees";
119119- };
120120- {
121121- (* Resolved via [toolchains_root] rather than [p "toolchains"]
122122- because the toolchain path is XDG-derived independently of
123123- [OI_CACHE_DIR], and we want clean to nuke what's actually on
124124- disk regardless of any env mismatch. *)
125125- label = "toolchains";
126126- path = Eio.Path.(t.fs / toolchains_root ());
127127- description = "Built compiler toolchains (oxcaml, etc.)";
128128- };
9393+ item "sources" (p "sources") "Downloaded source tarballs";
9494+ item "mirror" (p "mirror") "Content-addressed source mirror";
9595+ item "layers" (p "layers") "Binary layer cache (day10 format)";
9696+ item "build" (p "build") "Build state: prefix, _build/, logs, audit log";
9797+ item "prefixes" (p "prefixes") "Assembled prefix cache (hardlinks)";
9898+ item "pins" (p "pins") "Pin-depends sources and synthesized packages trees";
9999+ item "solve-cache" (p "solve-cache")
100100+ "Solver memoisation (regenerated on demand)";
101101+ item "runs" (p "runs") "Cached script builds";
102102+ item "run-cache" (p "run-cache") "Fast-exec cache for [oi run]";
103103+ item "dune" (p "dune") "Dune shared build cache";
104104+ item "repos" (d "repos") "Cloned repositories";
105105+ item "opam-root" (d "opam-root") "Opam scaffolding (regenerated on demand)";
106106+ (* The toolchain path is XDG-derived independently of [OI_CACHE_DIR],
107107+ so we resolve it via [toolchains_root] rather than [p "toolchains"]
108108+ — clean should nuke what's actually on disk regardless of any env
109109+ mismatch. *)
110110+ item "toolchains"
111111+ Eio.Path.(t.fs / toolchains_root ())
112112+ "Built compiler toolchains (oxcaml, etc.)";
129113 ]
130114131115let size ~sys path =
+5-6
lib/oi/outcome.mli
···6565(** {1 Classification} *)
66666767val classify_fetch_msg : string -> fetch_kind
6868-(** Best-effort classification of an opam-side fetch error message. The
6969- message originates from {!OpamRepository.pull_*}, which itself shells
7070- out to git or an HTTP backend, so the strings being matched here are
7171- the surface-level forms those tools surface — e.g. ["fatal: …"] from
7272- git, [HTTP <code>] from the HTTP backend, ["could not resolve host"]
7373- from the resolver. *)
6868+(** Best-effort classification of an opam-side fetch error message. The message
6969+ originates from {!OpamRepository.pull_*}, which itself shells out to git or
7070+ an HTTP backend, so the strings being matched here are the surface-level
7171+ forms those tools surface — e.g. ["fatal: …"] from git, [HTTP <code>] from
7272+ the HTTP backend, ["could not resolve host"] from the resolver. *)
+109-37
lib/oi/pipeline.ml
···168168 match int_of_string_opt s with Some n when n > 0 -> n | _ -> 4)
169169 | None -> min (Domain.recommended_domain_count ()) 4)
170170171171-let fetch_remote_layers ?jobs ~remote ~d10 ~packages_dirs ~ctx ~pkgs build_plan
172172- =
171171+let fmt_mb n =
172172+ if Int64.compare n 1_048_576L >= 0 then
173173+ Fmt.str "%.1fMB" (Int64.to_float n /. 1_048_576.)
174174+ else if Int64.compare n 1024L >= 0 then
175175+ Fmt.str "%.0fKB" (Int64.to_float n /. 1024.)
176176+ else Fmt.str "%LdB" n
177177+178178+let fetch_remote_layers ?on_phase ?jobs ~remote ~d10 ~packages_dirs ~ctx ~pkgs
179179+ build_plan =
173180 match remote with
174181 | None -> build_plan
175182 | Some r ->
···194201 build_plan
195202 end
196203 else begin
204204+ let n_total = List.length available in
197205 Logs.info (fun m ->
198198- m "Fetching %d layer(s) from registry (%d needed)..."
199199- (List.length available)
206206+ m "Fetching %d layer(s) from registry (%d needed)..." n_total
200207 (List.length source_hashes));
208208+ (* Aggregate parallel fiber progress into one status line.
209209+ [done_count] increments on each successful pull;
210210+ [bytes_total] accumulates received bytes across all
211211+ in-flight fibers. We're under [Eio.Fiber.List.iter] which
212212+ on the default [Eio_posix] backend uses fibers (cooperative,
213213+ no preemption between yield points), so a plain ref is
214214+ safe — no mutex needed. *)
215215+ let done_count = ref 0 in
216216+ let bytes_total = ref 0L in
217217+ let last_emit = ref 0.0 in
218218+ let throttle_s = 0.05 in
219219+ let emit () =
220220+ match on_phase with
221221+ | None -> ()
222222+ | Some f ->
223223+ let now = Unix.gettimeofday () in
224224+ if now -. !last_emit >= throttle_s then begin
225225+ last_emit := now;
226226+ f
227227+ (Fmt.str "Fetching layers from registry (%d/%d, %s)"
228228+ !done_count n_total (fmt_mb !bytes_total))
229229+ end
230230+ in
231231+ (* Per-fiber received counter so we don't double-count when
232232+ retries / chunked downloads call [on_progress] cumulatively
233233+ within one fiber. *)
234234+ let fiber_progress hash_ref ~received ~total:_ =
235235+ let prev = !hash_ref in
236236+ hash_ref := received;
237237+ bytes_total := Int64.add !bytes_total (Int64.sub received prev);
238238+ emit ()
239239+ in
201240 Eio.Fiber.List.iter
202241 ~max_fibers:(fetch_parallelism ?jobs ())
203242 (fun hash ->
···206245 (fun (e : D10.Layer.index_entry) -> e.sha256)
207246 (Hashtbl.find_opt index hash)
208247 in
209209- if D10.Layer.pull_remote d10 ~remote:r ~hash ?sha256 () then
210210- Logs.info (fun m -> m "Fetched %s from registry" hash))
248248+ let received_ref = ref 0L in
249249+ let on_progress = fiber_progress received_ref in
250250+ if
251251+ D10.Layer.pull_remote d10 ~remote:r ~hash ~on_progress ?sha256
252252+ ()
253253+ then begin
254254+ incr done_count;
255255+ emit ();
256256+ Logs.info (fun m -> m "Fetched %s from registry" hash)
257257+ end)
211258 available;
259259+ (* Final line — guaranteed past the throttle window. *)
260260+ (match on_phase with
261261+ | None -> ()
262262+ | Some f ->
263263+ f
264264+ (Fmt.str "Fetched %d/%d layers from registry (%s)" !done_count
265265+ n_total (fmt_mb !bytes_total)));
212266 Plan.build ctx ~d10 ~packages_dirs pkgs
213267 end
214268 end
···218272let build ~sys ~proc_mgr ~fs ~clock ~cache ~data_dir ~conf ~os_key
219273 ?(dry_run = false) ?(extra_repos = []) ?(pins = []) ?(refresh = false)
220274 ?remote ?jobs ?toolchain ?(constraints = OpamPackage.Name.Map.empty)
221221- ?project_root ?local_packages_dir names =
275275+ ?project_root ?local_packages_dir ?on_phase ?preflight_done names =
276276+ let _ = preflight_done in
277277+ let on_phase =
278278+ match on_phase with
279279+ | Some f -> f
280280+ | None -> fun s -> Logs.info (fun m -> m "%s" s)
281281+ in
222282 let extra_pkg_dirs =
223283 Source.Repo.ensure_extra ~fs ~data_dir ~refresh extra_repos
224284 in
···275335 m "layer cache entry stale (layers missing), falling through");
276336 None)
277337 in
338338+ let fire_preflight_done () =
339339+ match preflight_done with Some f -> f () | None -> ()
340340+ in
278341 match fast_hashes with
279342 | Some hashes ->
280343 Logs.info (fun m ->
281344 m "layer cache hit %s (%d layers), skipping solve"
282345 (String.sub (Stdlib.Option.get layer_cache_key) 0 12)
283346 (List.length hashes));
347347+ fire_preflight_done ();
284348 hashes
285349 | None ->
350350+ (* Phase narration funneled through [on_phase] so the caller picks
351351+ the visual: [oi run] feeds into a TTY-only spinner that clears
352352+ on exit; [oi sync] / [oi build] feeds into [Say.step] for a
353353+ visible audit trail. *)
354354+ on_phase
355355+ (Fmt.str "Building solver context (%d package dirs)"
356356+ (List.length packages_dirs));
286357 let build_prefix = cache_root / "build" / "prefix" in
287358 let ctx =
288359 Solver.Ctx.create ~prefix:build_prefix ~packages_dirs ~conf
289360 ?toolchain:toolchain_ctx ()
290361 in
362362+ on_phase
363363+ (Fmt.str "Solving for %d root%s" (List.length names)
364364+ (if List.length names = 1 then "" else "s"));
291365 let pkgs =
292366 match
293367 Solver.solve ~fs ~cache_root ctx ~packages_dirs ~constraints names
···295369 | Ok pkgs -> pkgs
296370 | Error msg -> Error.no_solution msg
297371 in
372372+ on_phase
373373+ (Fmt.str "Planning %d package%s" (List.length pkgs)
374374+ (if List.length pkgs = 1 then "" else "s"));
298375 let build_plan = Plan.build ctx ~d10 ~packages_dirs pkgs in
299376 if dry_run then begin
300377 let remote_has =
···308385 exit 0
309386 end;
310387 let build_plan =
311311- fetch_remote_layers ?jobs ~remote ~d10 ~packages_dirs ~ctx ~pkgs
312312- build_plan
388388+ match remote with
389389+ | None -> build_plan
390390+ | Some _ ->
391391+ on_phase "Checking registry for prebuilt layers";
392392+ fetch_remote_layers ~on_phase ?jobs ~remote ~d10 ~packages_dirs ~ctx
393393+ ~pkgs build_plan
313394 in
314395 let hashes = Plan.layer_hashes build_plan in
315396 (* Every layer in the plan must be cached (Binary method) to skip
···327408 | None -> ()
328409 | Some k -> Solver.Cache.store_layers ~fs ~cache_root ~key:k hashes
329410 in
411411+ (* Hand off to the caller: the preflight bar (if any) clears here,
412412+ either because we're about to skip Execute.run (all-cached) or
413413+ because Execute.run is about to open its own progress bar. *)
414414+ fire_preflight_done ();
330415 if all_layers_cached then begin
331416 Logs.info (fun m -> m "Layers cached, skipping build");
332417 persist_layer_cache ();
···347432 in
348433 Log.info (fun m ->
349434 m
350350- "depext check: %d source pkg(s); platform os=%s os-distribution=%s \
351351- os-family=%s"
435435+ "depext check: %d source pkg(s); platform os=%s \
436436+ os-distribution=%s os-family=%s"
352437 (List.length source_pkgs) conf.os conf.os_distribution
353438 conf.os_family);
354439 if source_pkgs <> [] then begin
···370455 we emit a one-liner so the user isn't blindsided by a
371456 low-level [./configure] failure with no prior warning. *)
372457 if OpamSysPkg.Set.is_empty all && conf.os = "macos" then
373373- Fmt.epr
374374- "%a no system depexts matched for %d source package(s) on \
375375- macOS. If the build fails with missing headers (gmp.h, \
376376- openssl/ssl.h, …), install them with %a.@."
377377- Style.warn_string "note:"
378378- (List.length source_pkgs)
379379- Style.accent_string "brew install <pkg>";
458458+ Say.warn
459459+ "no system depexts matched for %d source package(s) on macOS. If \
460460+ the build fails with missing headers (gmp.h, openssl/ssl.h, …), \
461461+ install them with: brew install <pkg>"
462462+ (List.length source_pkgs);
380463 if not (OpamSysPkg.Set.is_empty all) then (
381464 let st = Depexts.status all in
382465 Log.info (fun m ->
···384467 (OpamSysPkg.Set.cardinal st.installed)
385468 (OpamSysPkg.Set.cardinal st.missing)
386469 (OpamSysPkg.Set.cardinal st.not_found));
387387- if not (OpamSysPkg.Set.is_empty st.missing) then begin
388388- Fmt.epr
389389- "%a system packages are not installed. The build may fail at \
390390- compile time. Install them with:@. %s@."
391391- Style.warn_string "warning:"
470470+ if not (OpamSysPkg.Set.is_empty st.missing) then
471471+ Say.warn
472472+ "system packages are not installed. The build may fail at \
473473+ compile time. Install them with: %s"
392474 (st.missing |> OpamSysPkg.Set.elements
393475 |> List.map OpamSysPkg.to_string
394394- |> String.concat " ")
395395- end;
476476+ |> String.concat " ");
396477 if not (OpamSysPkg.Set.is_empty st.not_found) then
397397- Fmt.epr
398398- "%a system packages are not known to the host package manager: \
399399- %s@."
400400- Style.warn_string "warning:"
478478+ Say.warn
479479+ "system packages are not known to the host package manager: %s"
401480 (st.not_found |> OpamSysPkg.Set.elements
402481 |> List.map OpamSysPkg.to_string
403403- |> String.concat ", "));
404404- (* Force the warnings out to the terminal *before* Plan.resolve
405405- takes a few seconds and the Execute.run progress bar takes
406406- over the cursor. Format's [@.] does flush, but we belt-and-
407407- braces it here because [Ui] uses ANSI cursor moves that have
408408- been observed to interleave oddly with buffered stderr in
409409- some macOS terminals. *)
410410- (try Stdlib.flush stderr with _ -> ())
482482+ |> String.concat ", "))
411483 end;
412484 let exec_plan =
413485 Plan.resolve ctx ~packages_dirs ~cache_root ~os_key
+19-1
lib/oi/pipeline.mli
···111111 registry, also the registry's [sources/] subtree. *)
112112113113val fetch_remote_layers :
114114+ ?on_phase:(string -> unit) ->
114115 ?jobs:int ->
115116 remote:D10.Layer.remote option ->
116117 d10:D10.Config.t ->
···121122 Plan.graph
122123(** Try fetching uncached [Source] layers from [remote]. Returns a new plan
123124 graph with downloaded layers promoted to [Binary]. No-op when
124124- [remote = None] or every layer is already cached. *)
125125+ [remote = None] or every layer is already cached.
126126+127127+ [on_phase] receives aggregated status across the parallel fiber pool — both
128128+ completed-layer counts and cumulative bytes received. Throttled to ~20Hz
129129+ internally; safe to wire into a [Tty.Progress] sink. *)
125130126131val build :
127132 sys:D10.Sysops.t ->
···142147 ?constraints:OpamFormula.version_constraint OpamTypes.name_map ->
143148 ?project_root:string ->
144149 ?local_packages_dir:string ->
150150+ ?on_phase:(string -> unit) ->
151151+ ?preflight_done:(unit -> unit) ->
145152 OpamPackage.Name.t list ->
146153 string list
147154(** [build] solves for [names], ensures every needed layer exists (building from
···151158 [project_root] is the directory that holds the project's [_oi/] tree; when
152159 supplied, every pin's URL is sha-pinned via [_oi/oi.lock] before fetch. The
153160 lock is transient build state, regenerated as needed.
161161+162162+ [on_phase] is invoked once per slow preflight step ("Building solver
163163+ context", "Solving for N roots", "Planning M packages", "Checking
164164+ registry"). [oi sync] / [oi build] route this to [Say.step] for visible
165165+ narration; [oi run] routes it to a TTY spinner that auto-clears. Defaults to
166166+ a [Logs.info] dispatcher.
167167+168168+ [preflight_done] is called exactly once just before the build phase starts
169169+ (i.e. before [Execute.run] takes over the cursor) — the cue for callers to
170170+ clear their own preflight progress bar so it doesn't fight the per-package
171171+ build bar.
154172155173 When [dry_run] is [true] the function prints the build plan and calls
156174 [Stdlib.exit 0] — same behaviour as [oi show]. *)
+68
lib/oi/say.ml
···11+(* Width chosen so the most common labels (deps, with-deps, overlays,
22+ extra-repos, prefix, tools, toolchain) all align to the same column.
33+ Longer labels overflow gracefully — they push the value over rather
44+ than truncating. *)
55+let label_width = 12
66+77+(* Flush stdout after every visible line so [oi run]'s preflight steps
88+ appear as soon as they're printed rather than at the OS buffer's
99+ discretion. The caller often immediately enters a slow phase
1010+ (toolchain solve, opam-file parsing) where the next output won't come
1111+ for hundreds of ms or more — without an explicit flush, line-buffered
1212+ stdout silently swallows the marker. *)
1313+let flush_out () = try Stdlib.flush stdout with _ -> ()
1414+1515+let step fmt =
1616+ Fmt.kstr
1717+ (fun s ->
1818+ Fmt.pr "%a %s@." Style.accent_string "▸" s;
1919+ flush_out ())
2020+ fmt
2121+2222+let info fmt =
2323+ Fmt.kstr
2424+ (fun s ->
2525+ Fmt.pr " %s@." s;
2626+ flush_out ())
2727+ fmt
2828+2929+let field label fmt =
3030+ Fmt.kstr
3131+ (fun v ->
3232+ Fmt.pr " %a %s@." Style.dim_string
3333+ (Fmt.str "%-*s" label_width (label ^ ":"))
3434+ v;
3535+ flush_out ())
3636+ fmt
3737+3838+let header fmt =
3939+ Fmt.kstr
4040+ (fun s ->
4141+ Fmt.pr "%a@." Style.header_string s;
4242+ flush_out ())
4343+ fmt
4444+4545+let ok fmt =
4646+ Fmt.kstr
4747+ (fun s ->
4848+ Fmt.pr " %a %s@." Style.ok_string "✓" s;
4949+ flush_out ())
5050+ fmt
5151+5252+let warn fmt =
5353+ Fmt.kstr
5454+ (fun s ->
5555+ Fmt.epr "%a %s@." Style.warn_string "warning:" s;
5656+ try Stdlib.flush stderr with _ -> ())
5757+ fmt
5858+5959+let error fmt =
6060+ Fmt.kstr
6161+ (fun s ->
6262+ Fmt.epr "%a %s@." Style.error_string "error:" s;
6363+ try Stdlib.flush stderr with _ -> ())
6464+ fmt
6565+6666+let newline () =
6767+ Fmt.pr "@.";
6868+ flush_out ()
+43
lib/oi/say.mli
···11+(** Consistent narration primitives for CLI commands.
22+33+ Every command's "I'm doing X" output should go through one of these helpers
44+ so colour, indentation, and the [▸] / [✓] / [⚠] / [✗] markers stay
55+ consistent across the codebase. Plain [Fmt.pr] calls in command bodies tend
66+ to drift in style (different prefixes, ad-hoc colours, inconsistent
77+ capitalization); routing through [Say.*] keeps them uniform.
88+99+ Output goes to stdout for {!step}, {!info}, {!field}, {!header}, {!ok}, and
1010+ to stderr for {!warn} and {!error}. None of these helpers interact with the
1111+ progress bar — callers inside an {!Ui.run} body should use {!Ui.log} or
1212+ {!Ui.suspend} instead. *)
1313+1414+val step : ('a, Format.formatter, unit, unit) format4 -> 'a
1515+(** [step "%s" msg] prints ["▸ msg"] in accent style — used for top-level action
1616+ steps ("Solving", "Assembling prefix", "Wrote .envrc"). *)
1717+1818+val info : ('a, Format.formatter, unit, unit) format4 -> 'a
1919+(** [info "%s" msg] prints [" msg"] in default style — secondary lines indented
2020+ under a {!step}. *)
2121+2222+val field : string -> ('a, Format.formatter, unit, unit) format4 -> 'a
2323+(** [field "deps" "%s" v] prints [" deps: v"] with the label dim and a fixed
2424+ alignment column for the value. *)
2525+2626+val header : ('a, Format.formatter, unit, unit) format4 -> 'a
2727+(** [header "%s" h] prints ["h"] in bold — used for section headers in
2828+ multi-block reports ([oi show], [oi config]). *)
2929+3030+val ok : ('a, Format.formatter, unit, unit) format4 -> 'a
3131+(** [ok "%s" msg] prints [" ✓ msg"] in success-green. *)
3232+3333+val warn : ('a, Format.formatter, unit, unit) format4 -> 'a
3434+(** [warn "%s" msg] prints ["warning: msg"] in yellow on stderr, then flushes —
3535+ protects against the message being eaten by a subsequent progress bar
3636+ redraw. *)
3737+3838+val error : ('a, Format.formatter, unit, unit) format4 -> 'a
3939+(** [error "%s" msg] prints ["error: msg"] in red on stderr, then flushes. *)
4040+4141+val newline : unit -> unit
4242+(** Emit a blank line on stdout — used to vertically separate logical sections.
4343+*)
+4-2
lib/osrel/osrel.ml
···178178 let detect_macos ~fs ~proc_mgr =
179179 let brew_paths =
180180 [
181181- "/opt/homebrew/bin/brew"; (* Apple Silicon default *)
182182- "/usr/local/bin/brew"; (* Intel default *)
181181+ "/opt/homebrew/bin/brew";
182182+ (* Apple Silicon default *)
183183+ "/usr/local/bin/brew";
184184+ (* Intel default *)
183185 ]
184186 in
185187 let port_paths = [ "/opt/local/bin/port" ] in