Skip to main content

mitosis/
cli.rs

1use crate::budget;
2use crate::client;
3use crate::config::{Config, ConfigEntry};
4use crate::daemon;
5use crate::doctor;
6use crate::envfile;
7use crate::error::{ErrorCode, MitosisError, Result};
8use crate::events;
9use crate::mcp;
10use crate::names::{format_duration, parse_duration, parse_wait, validate_name};
11use crate::paths::Home;
12use crate::protocol::{
13    BranchArgs, CloneCreateArgs, CloneInfo, EventFilter, EventRow, MigrateCmd, Request, Response,
14    TemplateInfo, TemplateInitArgs, TemplateSetArgs, TemplateSettings, TemplateSource,
15};
16use crate::service;
17use crate::templates::now;
18use clap::{Parser, Subcommand};
19use std::process::ExitCode;
20use std::time::{Duration, Instant};
21
22#[derive(Parser)]
23#[command(
24    name = "mitosis",
25    version,
26    about = "Copy-on-write Postgres clones for AI agent tasks"
27)]
28struct Cli {
29    #[command(subcommand)]
30    command: Cmd,
31}
32
33// Parsed once per run, so the size gap between variants costs nothing.
34#[allow(clippy::large_enum_variant)]
35#[derive(Subcommand)]
36enum Cmd {
37    /// Build, refresh and list templates.
38    Template {
39        #[command(subcommand)]
40        command: TemplateCmd,
41    },
42    /// Create a clone of a template and print its URL.
43    Clone {
44        #[arg(long, default_value = "main")]
45        template: String,
46        #[arg(long)]
47        name: Option<String>,
48        /// Time to live, like 30m, 2h or 1d.
49        #[arg(long, default_value = "2h")]
50        ttl: String,
51        /// Keep fsync on (slower, survives a crash).
52        #[arg(long)]
53        durable: bool,
54        /// Destroy a running clone of this name first (same URL, fresh data).
55        #[arg(long)]
56        fresh: bool,
57        /// Never expire; needs --name. Destroy it with `mitosis destroy <name> --force`.
58        #[arg(long)]
59        protect: bool,
60        /// Write the URL into this .env file (restored when the clone is destroyed).
61        #[arg(long)]
62        env_file: Option<std::path::PathBuf>,
63        /// The variable to set in --env-file.
64        #[arg(long, requires = "env_file")]
65        env_var: Option<String>,
66        /// How long to wait for room under the limits, like 30s or 2m; 0 fails at once.
67        #[arg(long)]
68        wait: Option<String>,
69        /// Clone the template built for a git branch (default: the branch checked out here),
70        /// layered on --template by running its migrate command in this checkout.
71        #[arg(long, num_args = 0..=1, default_missing_value = "")]
72        branch: Option<String>,
73        #[arg(long)]
74        json: bool,
75    },
76    /// Print `DATABASE_URL=<url>` (or --var) for a running clone.
77    Env {
78        id_or_name: String,
79        #[arg(long, default_value = "DATABASE_URL")]
80        var: String,
81        /// Print the URL straight to the clone's port instead of the router URL (wakes a
82        /// sleeping clone first).
83        #[arg(long)]
84        direct: bool,
85    },
86    /// List clones.
87    List {
88        #[arg(long)]
89        json: bool,
90    },
91    /// Live view of clones, memory and disk against the limits (q to quit).
92    Top,
93    /// Destroy a clone by id or name.
94    Destroy {
95        id_or_name: String,
96        /// Needed for a protected clone.
97        #[arg(long)]
98        force: bool,
99        /// Also release the name's reserved port and password.
100        #[arg(long)]
101        forget: bool,
102    },
103    /// Start a sleeping clone now instead of on its first connection.
104    Wake { id_or_name: String },
105    /// Show what a clone changed against the template generation it came from.
106    Diff {
107        id_or_name: String,
108        /// Compare one table row by row (needs a primary key).
109        #[arg(long)]
110        table: Option<String>,
111        /// Rows shown per kind with --table.
112        #[arg(long, requires = "table")]
113        limit: Option<u32>,
114        #[arg(long)]
115        json: bool,
116    },
117    /// Make a running clone the newest generation of its template (or of --template).
118    Promote {
119        id_or_name: String,
120        /// Promote into this template instead of the clone's own; created when it does not exist.
121        #[arg(long)]
122        template: Option<String>,
123    },
124    /// Remove expired clones and leftovers.
125    Gc,
126    /// What the daemon did and why: creates, destroys, builds, rollbacks, gc, recovery, queue waits.
127    Events {
128        /// Keep running and print new events as they happen.
129        #[arg(long)]
130        follow: bool,
131        /// Only events of this clone (name or id).
132        #[arg(long)]
133        clone: Option<String>,
134        /// Only events of this template.
135        #[arg(long)]
136        template: Option<String>,
137        /// Only this kind (`clone.created`) or group (`clone`).
138        #[arg(long)]
139        kind: Option<String>,
140        /// Only events from this long ago, like 30m, 2h or 1d.
141        #[arg(long)]
142        since: Option<String>,
143        /// Only the newest N matching events (default 1000; with --follow, 20 before the new ones).
144        #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
145        limit: Option<u32>,
146        /// A JSON array; with --follow one JSON object per line.
147        #[arg(long)]
148        json: bool,
149    },
150    /// Download and manage Postgres builds (the only commands that ever download).
151    Pg {
152        #[command(subcommand)]
153        command: PgCmd,
154    },
155    /// Check the home, config, daemon, Postgres installs and templates for problems.
156    Doctor {
157        /// Finish pending .env restores and run gc.
158        #[arg(long)]
159        fix: bool,
160        #[arg(long)]
161        json: bool,
162    },
163    /// Run the daemon in the foreground, or manage it.
164    Daemon {
165        #[command(subcommand)]
166        command: Option<DaemonCmd>,
167    },
168    /// Serve the MCP tools over stdio.
169    Mcp,
170    /// Keep the daemon running from login (launchd user agent).
171    Service {
172        #[command(subcommand)]
173        command: ServiceCmd,
174    },
175    /// Show the settings and where each value comes from, or change one.
176    #[command(args_conflicts_with_subcommands = true)]
177    Config {
178        #[command(subcommand)]
179        command: Option<ConfigCmd>,
180        #[arg(long)]
181        json: bool,
182    },
183    /// Set up a ZFS dataset for the zfs back end (Linux).
184    Zfs {
185        #[command(subcommand)]
186        command: ZfsCmd,
187    },
188}
189
190#[derive(Subcommand)]
191enum ConfigCmd {
192    /// Write a setting to config.toml and tell a running daemon to reload.
193    Set {
194        /// One of the settings `mitosis config` lists, like max_clones or idle_sleep.
195        key: String,
196        /// A number, a size like 4GB, a duration like 60s, or off.
197        value: String,
198    },
199}
200
201#[derive(Subcommand)]
202enum TemplateCmd {
203    /// Build a template from a database URL, a physical copy of a server, a ZFS snapshot of a
204    /// running server, or a seed command.
205    Init {
206        #[arg(default_value = "main")]
207        name: String,
208        /// Copy an existing database with pg_dump.
209        #[arg(
210            long,
211            conflicts_with_all = ["seed", "basebackup"],
212            required_unless_present_any = ["seed", "basebackup", "from_running"]
213        )]
214        from_url: Option<String>,
215        /// Snapshot the ZFS dataset whose root is a live Postgres data directory (zfs back end).
216        #[arg(
217            long,
218            value_name = "DATASET",
219            conflicts_with_all = ["from_url", "basebackup", "seed"]
220        )]
221        from_running: Option<String>,
222        /// Copy a running server physically with pg_basebackup (needs a role with REPLICATION).
223        #[arg(long, conflicts_with = "seed")]
224        basebackup: Option<String>,
225        /// Shell command run in this folder with DATABASE_URL set.
226        #[arg(long)]
227        seed: Option<String>,
228        /// The database to keep (a --basebackup copy has all of the server's; default: the URL's).
229        #[arg(long)]
230        database: Option<String>,
231        /// Migrations folder; the template is flagged stale when it changes.
232        #[arg(long)]
233        migrations: Option<std::path::PathBuf>,
234        /// Postgres major to build with, like 17 (default: the newest installed).
235        #[arg(long)]
236        pg_major: Option<u32>,
237        #[command(flatten)]
238        settings: SettingsFlags,
239    },
240    /// Rebuild a template from its recorded source.
241    Refresh {
242        #[arg(default_value = "main")]
243        name: String,
244        /// Copy the current generation and run only the migrate command on it.
245        #[arg(long)]
246        layered: bool,
247    },
248    /// Change a template's settings.
249    Set {
250        #[arg(default_value = "main")]
251        name: String,
252        #[command(flatten)]
253        settings: SettingsFlags,
254        /// Restart automatic builds stopped by a rollback.
255        #[arg(long)]
256        resume: bool,
257    },
258    /// List templates.
259    List {
260        #[arg(long)]
261        json: bool,
262    },
263    /// List a template's generations; `*` marks the current one.
264    History {
265        #[arg(default_value = "main")]
266        name: String,
267        #[arg(long)]
268        json: bool,
269    },
270    /// Point a template back at an older generation and pause its scheduled refreshes.
271    Rollback {
272        #[arg(default_value = "main")]
273        name: String,
274        /// Generation number; the newest one older than the current by default.
275        #[arg(long)]
276        to: Option<u32>,
277    },
278    /// Move a template to a newer Postgres major with pg_upgrade.
279    Upgrade {
280        name: String,
281        /// Target Postgres major, like 18.
282        #[arg(long)]
283        to: u32,
284        /// Make a new template with this name instead; the original stays as it is.
285        #[arg(long = "as")]
286        as_name: Option<String>,
287    },
288}
289
290/// Template settings shared by `template init` and `template set`.
291#[derive(clap::Args)]
292struct SettingsFlags {
293    /// Age after which clones warn, like 7d.
294    #[arg(long)]
295    max_age: Option<String>,
296    /// Generations to keep on disk (at least 1).
297    #[arg(long)]
298    keep: Option<u32>,
299    /// Rebuild on a schedule, like 24h (at least 1m), or off.
300    #[arg(long)]
301    every: Option<String>,
302    /// How scheduled rebuilds run.
303    #[arg(long, value_parser = ["full", "layered"])]
304    every_mode: Option<String>,
305    /// Migration command for layered refreshes, run in this folder with DATABASE_URL set, or off.
306    #[arg(long)]
307    migrate: Option<String>,
308    /// Run a layered refresh by itself when the migrations folder changes.
309    #[arg(long, value_parser = ["on", "off"])]
310    auto_migrate: Option<String>,
311    /// When a clone's template is older than --max-age: warn, or also refresh in the background.
312    #[arg(long, value_parser = ["warn", "refresh"])]
313    on_stale: Option<String>,
314    /// Masking file (TOML: columns to mask, row subsets) applied to every build, or off.
315    #[arg(long)]
316    mask: Option<std::path::PathBuf>,
317    /// The git branch the template's checkout follows (layered builds skip on other branches),
318    /// or off.
319    #[arg(long)]
320    base_branch: Option<String>,
321}
322
323/// A protocol enum from its snake_case name (clap already limited the choices).
324fn enum_arg<T: serde::de::DeserializeOwned>(s: Option<String>) -> Result<Option<T>> {
325    Ok(
326        s.map(|s| serde_json::from_value(serde_json::Value::String(s)))
327            .transpose()?,
328    )
329}
330
331impl SettingsFlags {
332    /// The max age in seconds, and the settings.
333    fn parse(self) -> Result<(Option<u64>, TemplateSettings)> {
334        let max_age_secs = self
335            .max_age
336            .as_deref()
337            .map(parse_duration)
338            .transpose()?
339            .map(|d| d.as_secs());
340        let (every_secs, every_off) = match self.every.as_deref() {
341            Some("off") => (None, true),
342            Some(d) => (Some(parse_duration(d)?.as_secs()), false),
343            None => (None, false),
344        };
345        let (migrate, migrate_off) = match self.migrate {
346            Some(c) if c == "off" => (None, true),
347            // Runs where and with the PATH it would in this shell, like a seed.
348            Some(command) => (
349                Some(MigrateCmd {
350                    command,
351                    cwd: std::env::current_dir()?,
352                    path: std::env::var("PATH").ok(),
353                }),
354                false,
355            ),
356            None => (None, false),
357        };
358        let (mask, mask_off) = match self.mask {
359            Some(p) if p.as_os_str() == "off" => (None, true),
360            // Stored absolute, like --migrations, so the daemon finds it from any folder.
361            Some(p) => (
362                Some(std::fs::canonicalize(&p).map_err(|e| {
363                    MitosisError::new(
364                        ErrorCode::InvalidRequest,
365                        format!("masking file {}: {e}", p.display()),
366                    )
367                })?),
368                false,
369            ),
370            None => (None, false),
371        };
372        let settings = TemplateSettings {
373            keep: self.keep,
374            every_secs,
375            every_off,
376            every_mode: enum_arg(self.every_mode)?,
377            migrate,
378            migrate_off,
379            auto_migrate: self.auto_migrate.map(|a| a == "on"),
380            on_stale: enum_arg(self.on_stale)?,
381            mask,
382            mask_off,
383            base_branch: self.base_branch.clone().filter(|b| b != "off"),
384            base_branch_off: self.base_branch.as_deref() == Some("off"),
385        };
386        Ok((max_age_secs, settings))
387    }
388}
389
390#[derive(Subcommand)]
391enum PgCmd {
392    /// Download, verify and unpack the newest build of a Postgres major into the Mitosis home.
393    Install {
394        major: u32,
395        /// An exact minor (17.9), or a release tag (17.9.1), instead of the newest.
396        #[arg(long)]
397        version: Option<String>,
398    },
399    /// List the builds installed by `pg install` and which ones Mitosis uses.
400    List {
401        #[arg(long)]
402        json: bool,
403    },
404    /// Remove an installed build by major (17) or version (17.9).
405    Remove { spec: String },
406}
407
408#[derive(Subcommand)]
409enum ServiceCmd {
410    /// Install and start the login service for this binary and home.
411    Install,
412    /// Stop and remove the login service.
413    Uninstall,
414    /// Show whether the login service is installed and running.
415    Status {
416        #[arg(long)]
417        json: bool,
418    },
419}
420
421#[derive(Subcommand)]
422enum ZfsCmd {
423    /// Print the `zfs allow` and sudoers lines an admin runs once, then check them.
424    Setup { dataset: String },
425}
426
427#[derive(Subcommand)]
428enum DaemonCmd {
429    /// Show whether the daemon is running.
430    Status,
431    /// Stop the daemon; clones keep running.
432    Stop,
433}
434
435pub fn main() -> ExitCode {
436    let cli = Cli::parse();
437    let rt = match tokio::runtime::Builder::new_multi_thread()
438        .enable_all()
439        .build()
440    {
441        Ok(rt) => rt,
442        Err(e) => return fail(&MitosisError::from(e)),
443    };
444    match rt.block_on(run(cli)) {
445        Ok(code) => code,
446        Err(e) => fail(&e),
447    }
448}
449
450fn fail(e: &MitosisError) -> ExitCode {
451    eprintln!("error: {}", e.message);
452    if let Some(hint) = &e.hint {
453        eprintln!("hint: {hint}");
454    }
455    // Only `mitosis daemon` returns AlreadyRunning; `client::ensure_daemon` looks for this status.
456    if e.code == ErrorCode::AlreadyRunning {
457        // Under launchd a non-zero exit means "relaunch me": another daemon already serves, so stop quietly.
458        if std::env::var_os("MITOSIS_SERVICE").is_some_and(|v| v == "1") {
459            return ExitCode::SUCCESS;
460        }
461        return ExitCode::from(client::EXIT_ALREADY_RUNNING);
462    }
463    ExitCode::FAILURE
464}
465
466// Deliberately not `{r:?}`: a reply can hold a password-bearing URL. Same in `unexpected` in mcp.rs.
467fn unexpected(_: Response) -> MitosisError {
468    MitosisError::new(ErrorCode::ProtocolMismatch, "unexpected daemon reply")
469        .with_hint("restart the daemon: mitosis daemon stop")
470}
471
472fn print_json<T: serde::Serialize>(v: &T) -> Result<()> {
473    println!("{}", serde_json::to_string_pretty(v)?);
474    Ok(())
475}
476
477async fn run(cli: Cli) -> Result<ExitCode> {
478    let home = Home::from_env()?;
479    match cli.command {
480        Cmd::Daemon { command: None } => {
481            let config = Config::load(&home)?;
482            daemon::run(home, config).await?
483        }
484        Cmd::Mcp => mcp::serve_stdio(home).await?,
485        Cmd::Service { command } => match command {
486            ServiceCmd::Install => {
487                service::install()?;
488                let exe = std::env::current_exe()?;
489                println!("service installed: the daemon starts at login");
490                println!("binary    {}", exe.display());
491                if exe.components().any(|c| c.as_os_str() == "target") {
492                    eprintln!(
493                        "warning: the service points at a build folder; it breaks if you run cargo clean (use cargo install for a stable path)"
494                    );
495                }
496            }
497            ServiceCmd::Uninstall => {
498                service::uninstall()?;
499                println!("service removed");
500            }
501            ServiceCmd::Status { json } => {
502                let s = service::status()?;
503                if json {
504                    print_json(&s)?;
505                } else {
506                    println!("installed {}", s.installed);
507                    println!("loaded    {}", s.loaded);
508                    println!("running   {}", s.running);
509                    if let Some(b) = &s.binary {
510                        let gone = if s.binary_exists { "" } else { "  (missing)" };
511                        println!("binary    {}{gone}", b.display());
512                    }
513                    if s.installed {
514                        if let Some(hint) = service::linger_hint() {
515                            println!("{hint}");
516                        }
517                    }
518                }
519            }
520        },
521        Cmd::Config {
522            command: None,
523            json,
524        } => {
525            let entries = Config::describe(&home)?;
526            if json {
527                print_json(&entries)?;
528            } else {
529                print_config(&entries);
530            }
531        }
532        Cmd::Config {
533            command: Some(ConfigCmd::Set { key, value }),
534            ..
535        } => {
536            Config::set_in_file(&home, &key, &value)?;
537            // No daemon start: a stopped daemon reads the file when it next starts.
538            client::request_if_running(&home, Request::ConfigReload).await?;
539            println!("{key} = {}", value.trim());
540            // The file is written, but an environment variable of this shell still wins.
541            if let Some((var, value)) = Config::env_override(&home, &key)? {
542                eprintln!("note: {var} is set and overrides this; the value in effect is {value}");
543            }
544        }
545        Cmd::Template { command } => template(&home, command).await?,
546        Cmd::Pg { command } => pg(&home, command).await?,
547        Cmd::Clone {
548            template,
549            name,
550            ttl,
551            durable,
552            fresh,
553            protect,
554            env_file,
555            env_var,
556            wait,
557            branch,
558            json,
559        } => {
560            validate_name("template", &template)?;
561            if let Some(n) = &name {
562                validate_name("clone", n)?;
563            }
564            let ttl_secs = parse_duration(&ttl)?.as_secs();
565            let wait_secs = match wait.as_deref() {
566                None => None,
567                Some(w) => Some(parse_wait(w)?.as_secs()),
568            };
569            let args = CloneCreateArgs {
570                template: template.clone(),
571                name,
572                ttl_secs,
573                durable,
574                fresh,
575                protect,
576                // The CLI exits right after printing the URL, so watching it would destroy the
577                // clone at once: CLI clones live until their TTL or `mitosis destroy`.
578                parent_pid: None,
579                env: None,
580                wait_secs,
581                // `create_clone` resolves the branch and the repository root from this folder.
582                branch: match branch {
583                    Some(name) => Some(BranchArgs {
584                        name,
585                        repo_root: std::env::current_dir()?,
586                        path: None,
587                    }),
588                    None => None,
589                },
590            };
591            let info = envfile::create_clone(&home, args, env_file, env_var).await?;
592            for w in &info.warnings {
593                eprintln!("warning: {w}");
594            }
595            if info.existing {
596                eprintln!("note: clone {} was already running", info.id);
597            }
598            if info.rebuilt {
599                eprintln!(
600                    "note: template {} was rebuilt for this branch",
601                    info.template
602                );
603            }
604            // Warnings are best effort: the clone already exists.
605            if let Ok(Response::Templates { templates }) =
606                client::request(&home, Request::TemplateList).await
607            {
608                for w in templates
609                    .iter()
610                    .filter(|t| t.name == info.template)
611                    .flat_map(|t| &t.warnings)
612                {
613                    eprintln!("warning: {w}");
614                }
615            }
616            if json {
617                print_json(&info)?;
618            } else {
619                println!("{}", info.url);
620            }
621        }
622        Cmd::Env {
623            id_or_name,
624            var,
625            direct,
626        } => {
627            // The printed line goes into a .env file: the same names `--env-var` accepts.
628            envfile::check_var(&var)?;
629            let (url, warnings) = envfile::env_url(&home, &id_or_name, direct).await?;
630            for w in &warnings {
631                eprintln!("warning: {w}");
632            }
633            println!("{var}={url}");
634        }
635        Cmd::List { json } => {
636            let clones = match client::request(&home, Request::CloneList).await? {
637                Response::Clones { clones } => clones,
638                other => return Err(unexpected(other)),
639            };
640            for w in envfile::finish_pending(&home).await {
641                eprintln!("warning: {w}");
642            }
643            if json {
644                print_json(&clones)?;
645            } else {
646                print_clones(&clones);
647            }
648        }
649        Cmd::Top => top(&home).await?,
650        Cmd::Destroy {
651            id_or_name,
652            force,
653            forget,
654        } => {
655            let req = Request::CloneDestroy {
656                id_or_name,
657                force,
658                forget,
659            };
660            match client::request(&home, req).await? {
661                Response::Destroyed { id } => println!("{id}"),
662                other => return Err(unexpected(other)),
663            }
664            for w in envfile::finish_pending(&home).await {
665                eprintln!("warning: {w}");
666            }
667        }
668        Cmd::Wake { id_or_name } => {
669            let req = Request::CloneWake { id_or_name };
670            match client::request(&home, req).await? {
671                Response::Clone(info) => {
672                    for w in &info.warnings {
673                        eprintln!("warning: {w}");
674                    }
675                    println!("{}", info.name.as_deref().unwrap_or(&info.id));
676                }
677                other => return Err(unexpected(other)),
678            }
679        }
680        Cmd::Diff {
681            id_or_name,
682            table,
683            limit,
684            json,
685        } => {
686            let req = Request::CloneDiff {
687                id_or_name,
688                table,
689                limit,
690                parent_pid: None,
691            };
692            match client::request(&home, req).await? {
693                Response::Diff(report) if json => print_json(&report)?,
694                Response::Diff(report) => print!("{}", crate::diff::render(&report)),
695                other => return Err(unexpected(other)),
696            }
697        }
698        Cmd::Promote {
699            id_or_name,
700            template,
701        } => {
702            let req = Request::ClonePromote {
703                id_or_name: id_or_name.clone(),
704                template,
705            };
706            match client::request(&home, req).await? {
707                Response::TemplateBuilt(t) => println!(
708                    "promoted {id_or_name} to template {} generation {} (Postgres {}, {})",
709                    t.name,
710                    t.current_gen,
711                    t.pg_major,
712                    human_bytes(t.size_bytes)
713                ),
714                other => return Err(unexpected(other)),
715            }
716        }
717        Cmd::Zfs {
718            command: ZfsCmd::Setup { dataset },
719        } => return zfs_setup(&dataset),
720        Cmd::Doctor { fix, json } => {
721            let checks = doctor::run(&home, fix).await;
722            if json {
723                print_json(&checks)?;
724            } else {
725                print!("{}", doctor::render(&checks));
726            }
727            return Ok(ExitCode::from(doctor::exit_code(&checks)));
728        }
729        Cmd::Events {
730            follow,
731            clone,
732            template,
733            kind,
734            since,
735            limit,
736            json,
737        } => {
738            let since = match since {
739                Some(d) => Some(now().saturating_sub(parse_duration(&d)?.as_secs() as i64)),
740                None => None,
741            };
742            let filter = EventFilter {
743                clone,
744                template,
745                kind,
746                since,
747                limit,
748            };
749            let show = |e: &EventRow| -> Result<()> {
750                if json {
751                    println!("{}", serde_json::to_string(e)?);
752                } else {
753                    println!(
754                        "{}  {}  {}  {}",
755                        events::local_time(e.ts),
756                        e.kind,
757                        events::row_label(e),
758                        events::short_detail(&e.detail)
759                    );
760                }
761                Ok(())
762            };
763            if follow {
764                let mut failed = None;
765                client::follow_events(&home, filter, |e| {
766                    if failed.is_none() {
767                        failed = show(&e).err();
768                    }
769                })
770                .await?;
771                if let Some(e) = failed {
772                    return Err(e);
773                }
774            } else {
775                match client::request(
776                    &home,
777                    Request::Events {
778                        filter,
779                        follow: false,
780                    },
781                )
782                .await?
783                {
784                    Response::Events { events } if json => print_json(&events)?,
785                    Response::Events { events } => {
786                        for e in &events {
787                            show(e)?;
788                        }
789                        let cap = limit.unwrap_or(daemon::EVENTS_DEFAULT_LIMIT);
790                        if events.len() >= cap as usize {
791                            eprintln!(
792                                "note: showing only the newest {cap} events; use --limit to see more"
793                            );
794                        }
795                    }
796                    other => return Err(unexpected(other)),
797                }
798            }
799        }
800        Cmd::Gc => match client::request(&home, Request::Gc).await? {
801            Response::Gc(r) => {
802                println!(
803                    "adopted {}, destroyed {}, orphan folders removed {}, records dropped {}, failed {}",
804                    r.adopted.len(),
805                    r.destroyed.len(),
806                    r.orphan_folders_removed.len(),
807                    r.records_dropped.len(),
808                    r.failed.len()
809                );
810                for f in &r.failed {
811                    println!("failed: {}: {}", f.item, f.error);
812                }
813            }
814            other => return Err(unexpected(other)),
815        },
816        Cmd::Daemon {
817            command: Some(DaemonCmd::Status),
818        } => match client::request_if_running(&home, Request::Status).await? {
819            None => {
820                println!("daemon is not running");
821                return Ok(ExitCode::FAILURE);
822            }
823            Some(Response::Status(s)) => {
824                println!("daemon is running");
825                println!("pid       {}", s.pid);
826                println!("version   {}", s.version);
827                println!("service   {}", s.service);
828                println!("home      {}", s.home.display());
829                println!("clones    {}", s.clones);
830                println!("templates {}", s.templates);
831                println!(
832                    "uptime    {}",
833                    format_duration(Duration::from_secs(s.uptime_secs))
834                );
835                println!("router    {}", router_state(&s.router));
836                if s.router.as_ref().is_some_and(|r| !r.listening) {
837                    eprintln!(
838                        "warning: the router is not listening; it retries every minute (direct URLs still work: mitosis env <clone> --direct, which wakes a sleeping clone; or mitosis wake <clone>)"
839                    );
840                }
841            }
842            Some(other) => return Err(unexpected(other)),
843        },
844        Cmd::Daemon {
845            command: Some(DaemonCmd::Stop),
846        } => {
847            match client::request_if_running(&home, Request::Shutdown).await? {
848                None => println!("daemon is not running"),
849                Some(Response::Ok) => {
850                    // The daemon removes its socket right after replying; wait so callers see it gone.
851                    let deadline = Instant::now() + Duration::from_secs(5);
852                    while home.daemon_sock().exists() && Instant::now() < deadline {
853                        tokio::time::sleep(Duration::from_millis(50)).await;
854                    }
855                    println!("daemon stopped");
856                }
857                Some(other) => return Err(unexpected(other)),
858            }
859        }
860    }
861    Ok(ExitCode::SUCCESS)
862}
863
864/// `mitosis zfs setup <dataset>`: prints what an admin installs once, then the ZFS doctor checks.
865/// It never runs sudo or changes anything itself.
866#[cfg(target_os = "linux")]
867fn zfs_setup(dataset: &str) -> Result<ExitCode> {
868    use crate::clone_backend::zfs;
869    zfs::check_dataset_name(dataset)?;
870    let bin = zfs::which_zfs().unwrap_or_else(|| "/usr/sbin/zfs".into());
871    let mount_root = match zfs::ZfsBackend::new(dataset) {
872        Ok(b) => b.mount_root().to_path_buf(),
873        Err(e) => {
874            // ZFS's default mountpoint for a dataset.
875            let guess = std::path::PathBuf::from(format!("/{dataset}"));
876            eprintln!(
877                "note: could not read the mountpoint of {dataset} ({}); assuming {}",
878                e.message,
879                guess.display()
880            );
881            guess
882        }
883    };
884    let user = zfs::current_user();
885    print!("{}", zfs::setup_text(&user, dataset, &mount_root, &bin));
886    println!();
887    println!("Checks (run this again once the admin is done):");
888    let checks = doctor::zfs_checks(dataset);
889    print!("{}", doctor::render(&checks));
890    Ok(ExitCode::from(doctor::exit_code(&checks)))
891}
892
893#[cfg(not(target_os = "linux"))]
894fn zfs_setup(_dataset: &str) -> Result<ExitCode> {
895    Err(MitosisError::new(
896        ErrorCode::BackendUnsupported,
897        "the zfs back end only works on Linux",
898    )
899    .with_hint("set backend = \"auto\""))
900}
901
902fn print_config(entries: &[ConfigEntry]) {
903    let width = entries.iter().map(|e| e.key.len()).max().unwrap_or(0);
904    let vwidth = entries.iter().map(|e| e.value.len()).max().unwrap_or(0);
905    for e in entries {
906        println!(
907            "{:<width$} = {:<vwidth$}  ({})",
908            e.key,
909            e.value,
910            e.source.label()
911        );
912    }
913}
914
915/// Runs in this process, not the daemon; the blocking download and unpack go to a worker thread.
916async fn pg(home: &Home, cmd: PgCmd) -> Result<()> {
917    use crate::pg::managed;
918    let h = home.clone();
919    let blocking =
920        |e: tokio::task::JoinError| MitosisError::new(ErrorCode::Internal, e.to_string());
921    match cmd {
922        PgCmd::Install { major, version } => {
923            let src = managed::ReleaseSource::from_env();
924            let i = tokio::task::spawn_blocking(move || {
925                managed::install(&h, major, version.as_deref(), &src)
926            })
927            .await
928            .map_err(blocking)??;
929            println!("{}", i.dir.join("bin").display());
930        }
931        PgCmd::List { json } => {
932            let installs = tokio::task::spawn_blocking(move || managed::list(&h))
933                .await
934                .map_err(blocking)??;
935            if json {
936                print_json(&installs)?;
937            } else if installs.is_empty() {
938                println!("no managed Postgres builds (mitosis pg install <major>)");
939            } else {
940                let rows = installs
941                    .iter()
942                    .map(|i| {
943                        vec![
944                            i.version.clone(),
945                            if i.used { "yes" } else { "no" }.to_string(),
946                            i.dir.display().to_string(),
947                        ]
948                    })
949                    .collect();
950                print_table(&["VERSION", "USED", "PATH"], rows);
951            }
952        }
953        PgCmd::Remove { spec } => {
954            let r = tokio::task::spawn_blocking(move || {
955                managed::remove(&h, &spec, &|bin| managed::used_by_store(&h, bin))
956            })
957            .await
958            .map_err(blocking)??;
959            println!("removed Postgres {}", r.version);
960        }
961    }
962    Ok(())
963}
964
965async fn template(home: &Home, cmd: TemplateCmd) -> Result<()> {
966    match cmd {
967        TemplateCmd::Init {
968            name,
969            from_url,
970            from_running,
971            basebackup,
972            seed,
973            database,
974            migrations,
975            pg_major,
976            settings,
977        } => {
978            validate_name("template", &name)?;
979            let (max_age_secs, settings) = settings.parse()?;
980            let migrations = migrations
981                .map(|dir| {
982                    std::fs::canonicalize(&dir).map_err(|e| {
983                        MitosisError::new(
984                            ErrorCode::InvalidRequest,
985                            format!("migrations folder {}: {e}", dir.display()),
986                        )
987                    })
988                })
989                .transpose()?;
990            let source = match (from_running, from_url, basebackup, seed) {
991                (Some(dataset), _, _, _) => TemplateSource::Running { dataset },
992                (None, Some(url), _, _) => TemplateSource::Url { url },
993                (None, None, Some(url), _) => TemplateSource::Basebackup { url },
994                // The seed runs where and with the PATH it would in this shell.
995                (None, None, None, Some(command)) => TemplateSource::Seed {
996                    command,
997                    cwd: Some(std::env::current_dir()?),
998                    path: std::env::var("PATH").ok(),
999                },
1000                (None, None, None, None) => {
1001                    unreachable!("clap requires --from-url, --from-running, --basebackup or --seed")
1002                }
1003            };
1004            let args = TemplateInitArgs {
1005                name,
1006                source,
1007                database,
1008                migrations,
1009                max_age_secs,
1010                pg_major,
1011                settings,
1012            };
1013            match client::request(home, Request::TemplateInit(args)).await? {
1014                Response::TemplateBuilt(t) => print_built(&t),
1015                other => return Err(unexpected(other)),
1016            }
1017        }
1018        TemplateCmd::Refresh { name, layered } => {
1019            validate_name("template", &name)?;
1020            let path = std::env::var("PATH").ok();
1021            let req = Request::TemplateRefresh {
1022                name,
1023                path,
1024                layered,
1025            };
1026            match client::request(home, req).await? {
1027                Response::TemplateBuilt(t) => print_built(&t),
1028                other => return Err(unexpected(other)),
1029            }
1030        }
1031        TemplateCmd::Set {
1032            name,
1033            settings,
1034            resume,
1035        } => {
1036            validate_name("template", &name)?;
1037            let (max_age_secs, settings) = settings.parse()?;
1038            let args = TemplateSetArgs {
1039                name,
1040                max_age_secs,
1041                settings,
1042                resume,
1043            };
1044            match client::request(home, Request::TemplateSet(args)).await? {
1045                Response::TemplateBuilt(t) => {
1046                    println!("template {} settings saved", t.name);
1047                    if t.paused {
1048                        println!("{}", paused_line(&t.name));
1049                    }
1050                }
1051                other => return Err(unexpected(other)),
1052            }
1053        }
1054        TemplateCmd::List { json } => {
1055            let templates = match client::request(home, Request::TemplateList).await? {
1056                Response::Templates { templates } => templates,
1057                other => return Err(unexpected(other)),
1058            };
1059            for t in &templates {
1060                for w in &t.warnings {
1061                    eprintln!("warning: {}: {w}", t.name);
1062                }
1063                if let Some(e) = &t.last_error {
1064                    eprintln!("last error: {}: {e}", t.name);
1065                }
1066            }
1067            if json {
1068                print_json(&templates)?;
1069            } else {
1070                // Branch templates right under their base, indented; any whose base is gone last.
1071                let is_base = |t: &&TemplateInfo, b: &str| t.base.as_deref() == Some(b);
1072                let mut ordered: Vec<&TemplateInfo> = Vec::new();
1073                for t in templates.iter().filter(|t| t.base.is_none()) {
1074                    ordered.push(t);
1075                    ordered.extend(templates.iter().filter(|b| is_base(b, &t.name)));
1076                }
1077                ordered.extend(templates.iter().filter(|t| {
1078                    t.base
1079                        .as_deref()
1080                        .is_some_and(|b| !templates.iter().any(|x| x.name == b))
1081                }));
1082                print_table(
1083                    &[
1084                        "NAME", "GEN", "PAUSED", "PG", "DATABASE", "SIZE", "AGE", "SOURCE",
1085                    ],
1086                    ordered
1087                        .into_iter()
1088                        .map(|t| {
1089                            vec![
1090                                if t.base.is_some() {
1091                                    format!("  {}", t.name)
1092                                } else {
1093                                    t.name.clone()
1094                                },
1095                                format!("{}/{}", t.current_gen, t.generations),
1096                                if t.paused { "paused" } else { "-" }.to_string(),
1097                                t.pg_major.to_string(),
1098                                t.database.clone(),
1099                                human_bytes(t.size_bytes),
1100                                format_duration(Duration::from_secs(t.age_secs)),
1101                                source_cell(&t.source),
1102                            ]
1103                        })
1104                        .collect(),
1105                );
1106            }
1107        }
1108        TemplateCmd::History { name, json } => {
1109            crate::templates::validate_template_ref(&name)?;
1110            let (template, generations, paused) =
1111                match client::request(home, Request::TemplateHistory { name }).await? {
1112                    Response::Generations {
1113                        template,
1114                        generations,
1115                        paused,
1116                    } => (template, generations, paused),
1117                    other => return Err(unexpected(other)),
1118                };
1119            if json {
1120                print_json(&history_json(&template, paused, &generations))?;
1121            } else {
1122                if paused {
1123                    println!("{}", paused_line(&template));
1124                }
1125                let now = now();
1126                print_table(
1127                    &["GEN", "KIND", "BUILT", "PG", "SIZE", "CURRENT"],
1128                    generations
1129                        .iter()
1130                        .map(|g| {
1131                            let ago = u64::try_from(now - g.built_at).unwrap_or(0);
1132                            vec![
1133                                g.gen.to_string(),
1134                                serde_json::to_value(g.kind)
1135                                    .ok()
1136                                    .and_then(|v| v.as_str().map(String::from))
1137                                    .unwrap_or_default(),
1138                                format!("{} ago", format_duration(Duration::from_secs(ago))),
1139                                g.pg_major.to_string(),
1140                                human_bytes(g.size_bytes),
1141                                if g.current { "*" } else { "" }.to_string(),
1142                            ]
1143                        })
1144                        .collect(),
1145                );
1146            }
1147        }
1148        TemplateCmd::Rollback { name, to } => {
1149            validate_name("template", &name)?;
1150            match client::request(home, Request::TemplateRollback { name, to }).await? {
1151                Response::TemplateBuilt(t) => println!(
1152                    "template {} now uses generation {} (scheduled refreshes paused)",
1153                    t.name, t.current_gen
1154                ),
1155                other => return Err(unexpected(other)),
1156            }
1157        }
1158        TemplateCmd::Upgrade { name, to, as_name } => {
1159            validate_name("template", &name)?;
1160            if let Some(n) = &as_name {
1161                validate_name("template", n)?;
1162            }
1163            let req = Request::TemplateUpgrade {
1164                name: name.clone(),
1165                to,
1166                as_name,
1167            };
1168            match client::request(home, req).await? {
1169                Response::TemplateBuilt(t) => println!(
1170                    "upgraded {name} to Postgres {}: template {} generation {} ({})",
1171                    t.pg_major,
1172                    t.name,
1173                    t.current_gen,
1174                    human_bytes(t.size_bytes)
1175                ),
1176                other => return Err(unexpected(other)),
1177            }
1178        }
1179    }
1180    Ok(())
1181}
1182
1183/// `template history --json`.
1184pub fn history_json(
1185    template: &str,
1186    paused: bool,
1187    generations: &[crate::protocol::GenerationInfo],
1188) -> serde_json::Value {
1189    serde_json::json!({
1190        "template": template,
1191        "paused": paused,
1192        "generations": generations,
1193    })
1194}
1195
1196/// The command tree, for the contract tests.
1197pub fn command() -> clap::Command {
1198    <Cli as clap::CommandFactory>::command()
1199}
1200
1201/// The template's source for the table: the URL (the daemon already masked its password) or
1202/// the seed command, cut to one short line.
1203fn source_cell(source: &TemplateSource) -> String {
1204    const MAX: usize = 48;
1205    let text = match source {
1206        TemplateSource::Url { url } => url.clone(),
1207        TemplateSource::Basebackup { url } => format!("basebackup: {url}"),
1208        TemplateSource::Seed { command, .. } => {
1209            format!(
1210                "seed: {}",
1211                command.split_whitespace().collect::<Vec<_>>().join(" ")
1212            )
1213        }
1214        TemplateSource::Promoted { clone_id } => format!("promoted from {clone_id}"),
1215        TemplateSource::Running { dataset } => format!("running: {dataset}"),
1216    };
1217    if text.chars().count() <= MAX {
1218        return text;
1219    }
1220    let cut: String = text.chars().take(MAX - 1).collect();
1221    format!("{cut}…")
1222}
1223
1224fn paused_line(name: &str) -> String {
1225    format!(
1226        "paused: automatic builds stopped — run \"mitosis template refresh {name}\" or \"mitosis template set {name} --resume\""
1227    )
1228}
1229
1230fn print_built(t: &TemplateInfo) {
1231    println!(
1232        "template {} built (Postgres {}, {})",
1233        t.name,
1234        t.pg_major,
1235        human_bytes(t.size_bytes)
1236    );
1237}
1238
1239fn print_clones(clones: &[CloneInfo]) {
1240    let now = now();
1241    let secs = |s: i64| format_duration(Duration::from_secs(u64::try_from(s).unwrap_or(0)));
1242    let opt_bytes = |b: Option<u64>| b.map_or("-".to_string(), human_bytes);
1243    print_table(
1244        &[
1245            "ID",
1246            "NAME",
1247            "TEMPLATE",
1248            "PORT",
1249            "URL",
1250            "AGE",
1251            "TTL LEFT",
1252            "RAM",
1253            "EXTRA DISK",
1254            "FLAGS",
1255        ],
1256        clones
1257            .iter()
1258            .map(|c| {
1259                vec![
1260                    c.id.clone(),
1261                    c.name.clone().unwrap_or_else(|| "-".into()),
1262                    c.template.clone(),
1263                    c.port.to_string(),
1264                    // The password stays out of the table; `mitosis env` prints the full URL.
1265                    crate::templates::redact_url(&c.url),
1266                    secs(now - c.created_at),
1267                    if c.protected {
1268                        "never".into()
1269                    } else {
1270                        secs(c.expires_at - now)
1271                    },
1272                    opt_bytes(c.rss_bytes),
1273                    opt_bytes(c.own_disk_bytes),
1274                    flags(c),
1275                ]
1276            })
1277            .collect(),
1278    );
1279}
1280
1281/// `sleeping`, `!` for a clone past its disk warning, then `protected`.
1282fn flags(c: &CloneInfo) -> String {
1283    let mut f = Vec::new();
1284    if c.status == "sleeping" {
1285        f.push("sleeping");
1286    }
1287    if c.disk_warn {
1288        f.push("!");
1289    }
1290    if c.protected {
1291        f.push("protected");
1292    }
1293    f.join(" ")
1294}
1295
1296/// Puts the terminal in no-echo, no-line-buffer mode on the alternate screen, and puts it back on
1297/// drop, so every way out of `top` (q, Ctrl-C, an error, a panic) leaves a usable terminal.
1298struct RawTerminal {
1299    saved: nix::sys::termios::Termios,
1300}
1301
1302impl RawTerminal {
1303    fn enter() -> Result<RawTerminal> {
1304        use nix::sys::termios::{
1305            tcgetattr, tcsetattr, LocalFlags, SetArg, SpecialCharacterIndices as C,
1306        };
1307        let stdin = std::io::stdin();
1308        let saved = tcgetattr(&stdin).map_err(|_| {
1309            MitosisError::new(ErrorCode::InvalidRequest, "mitosis top needs a terminal")
1310                .with_hint("run it in a terminal, or use `mitosis list --json`")
1311        })?;
1312        let mut raw = saved.clone();
1313        // ISIG off: Ctrl-C arrives as a byte, so this process can clean up before it exits.
1314        raw.local_flags
1315            .remove(LocalFlags::ICANON | LocalFlags::ECHO | LocalFlags::ISIG);
1316        // A read returns after 2 s (in tenths of a second) or at the first key.
1317        raw.control_chars[C::VMIN as usize] = 0;
1318        raw.control_chars[C::VTIME as usize] = 20;
1319        tcsetattr(&stdin, SetArg::TCSANOW, &raw)
1320            .map_err(|e| MitosisError::new(ErrorCode::Internal, format!("terminal: {e}")))?;
1321        let term = RawTerminal { saved };
1322        print!("\x1b[?1049h\x1b[?25l");
1323        let _ = std::io::Write::flush(&mut std::io::stdout());
1324        Ok(term)
1325    }
1326}
1327
1328impl Drop for RawTerminal {
1329    fn drop(&mut self) {
1330        let _ = nix::sys::termios::tcsetattr(
1331            std::io::stdin(),
1332            nix::sys::termios::SetArg::TCSANOW,
1333            &self.saved,
1334        );
1335        print!("\x1b[?25h\x1b[?1049l");
1336        let _ = std::io::Write::flush(&mut std::io::stdout());
1337    }
1338}
1339
1340/// Waits up to 2 s for a key; true when it asks to quit (`q` or Ctrl-C). Reads everything
1341/// waiting at once, so a multi-byte key (an arrow) causes one redraw, not one per byte.
1342fn quit_key() -> bool {
1343    use std::io::Read;
1344    let mut b = [0u8; 64];
1345    matches!(std::io::stdin().read(&mut b), Ok(n) if asks_to_quit(&b[..n]))
1346}
1347
1348/// True when the bytes read from the terminal hold `q`, `Q` or Ctrl-C.
1349fn asks_to_quit(bytes: &[u8]) -> bool {
1350    bytes.iter().any(|b| matches!(b, b'q' | b'Q' | 3))
1351}
1352
1353/// `off`, `listening on 127.0.0.1:54300`, or why the router is not listening.
1354pub fn router_state(r: &Option<crate::protocol::RouterStatus>) -> String {
1355    match r {
1356        None => "off".into(),
1357        Some(r) => {
1358            let host = if r.listen.is_empty() {
1359                "127.0.0.1"
1360            } else {
1361                &r.listen
1362            };
1363            match &r.error {
1364                None if r.listening => format!("listening on {host}:{}", r.port),
1365                None => format!("not listening on {host}:{}", r.port),
1366                Some(e) => format!("not listening: {e}"),
1367            }
1368        }
1369    }
1370}
1371
1372fn draw_top(u: &budget::Usage, router: &Option<crate::protocol::RouterStatus>) {
1373    let now = now();
1374    let secs = |s: i64| format_duration(Duration::from_secs(u64::try_from(s).unwrap_or(0)));
1375    let opt_bytes = |b: Option<u64>| b.map_or("-".to_string(), human_bytes);
1376    let of = |used: String, cap: Option<u64>| match cap {
1377        Some(c) => format!("{used} / {}", human_bytes(c)),
1378        None => format!("{used} / no limit"),
1379    };
1380    print!("\x1b[H\x1b[2J");
1381    println!("mitosis top    q to quit, refreshes every 2s\n");
1382    print_table(
1383        &[
1384            "ID",
1385            "NAME",
1386            "TEMPLATE",
1387            "STATUS",
1388            "TTL LEFT",
1389            "RAM",
1390            "EXTRA DISK",
1391            "FLAGS",
1392        ],
1393        u.clones
1394            .iter()
1395            .map(|c| {
1396                vec![
1397                    c.id.clone(),
1398                    c.name.clone().unwrap_or_else(|| "-".into()),
1399                    c.template.clone(),
1400                    c.status.clone(),
1401                    if c.protected {
1402                        "never".into()
1403                    } else {
1404                        secs(c.expires_at - now)
1405                    },
1406                    // A sleeping clone has no server: it uses no memory.
1407                    if c.status == "sleeping" {
1408                        human_bytes(0)
1409                    } else {
1410                        opt_bytes(c.rss_bytes)
1411                    },
1412                    opt_bytes(c.own_disk_bytes),
1413                    [(c.disk_warn, "!"), (c.protected, "protected")]
1414                        .iter()
1415                        .filter(|(on, _)| *on)
1416                        .map(|(_, t)| *t)
1417                        .collect::<Vec<_>>()
1418                        .join(" "),
1419                ]
1420            })
1421            .collect(),
1422    );
1423    println!(
1424        "\nclones  {} / {}\nram     {}\ndisk    {}\nfree    {}\nqueued  {}\nrouter  {}",
1425        u.clones.len(),
1426        u.max_clones,
1427        of(human_bytes(u.ram_bytes), u.ram_budget),
1428        of(human_bytes(u.disk_bytes), u.disk_budget),
1429        human_bytes(u.free_disk_bytes),
1430        u.queued,
1431        router_state(router)
1432    );
1433    let _ = std::io::Write::flush(&mut std::io::stdout());
1434}
1435
1436async fn top(home: &Home) -> Result<()> {
1437    use tokio::signal::unix::{signal, SignalKind};
1438    let terminal = RawTerminal::enter()?;
1439    // ISIG is off, but `kill`, a closed terminal or a logout still send these. Without a handler
1440    // the process dies with the terminal left raw.
1441    let sig = |k: SignalKind| {
1442        signal(k).map_err(|e| MitosisError::new(ErrorCode::Internal, format!("signals: {e}")))
1443    };
1444    let mut term = sig(SignalKind::terminate())?;
1445    let mut hup = sig(SignalKind::hangup())?;
1446    let mut int = sig(SignalKind::interrupt())?;
1447    let signo = tokio::select! {
1448        r = top_loop(home) => return r,
1449        _ = term.recv() => libc::SIGTERM,
1450        _ = hup.recv() => libc::SIGHUP,
1451        _ = int.recv() => libc::SIGINT,
1452    };
1453    drop(terminal);
1454    // The usual exit status of a process ended by that signal. Exits at once: the key-reading
1455    // thread may still be blocked in a read.
1456    std::process::exit(128 + signo);
1457}
1458
1459/// `top`'s draw-and-wait loop; ends on `q` or Ctrl-C.
1460async fn top_loop(home: &Home) -> Result<()> {
1461    loop {
1462        let router = match client::request(home, Request::Status).await? {
1463            Response::Status(s) => s.router,
1464            other => return Err(unexpected(other)),
1465        };
1466        match client::request(home, Request::Usage).await? {
1467            Response::Usage(u) => draw_top(&u, &router),
1468            other => return Err(unexpected(other)),
1469        }
1470        let quit = tokio::task::spawn_blocking(quit_key)
1471            .await
1472            .map_err(|e| MitosisError::new(ErrorCode::Internal, format!("terminal: {e}")))?;
1473        if quit {
1474            return Ok(());
1475        }
1476    }
1477}
1478
1479/// Left-aligned columns separated by two spaces.
1480fn print_table(headers: &[&str], rows: Vec<Vec<String>>) {
1481    let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
1482    for row in &rows {
1483        for (w, cell) in widths.iter_mut().zip(row) {
1484            *w = (*w).max(cell.chars().count());
1485        }
1486    }
1487    let line = |cells: Vec<&str>| {
1488        let parts: Vec<String> = cells
1489            .iter()
1490            .zip(&widths)
1491            .map(|(c, w)| format!("{c:<w$}"))
1492            .collect();
1493        println!("{}", parts.join("  ").trim_end());
1494    };
1495    line(headers.to_vec());
1496    for row in &rows {
1497        line(row.iter().map(String::as_str).collect());
1498    }
1499}
1500
1501/// Decimal units: 512 B, 3.4 kB, 34 MB, 1.2 GB.
1502fn human_bytes(b: u64) -> String {
1503    if b < 1000 {
1504        return format!("{b} B");
1505    }
1506    let mut v = b as f64;
1507    let mut unit = "B";
1508    for u in ["kB", "MB", "GB", "TB", "PB"] {
1509        if v < 1000.0 {
1510            break;
1511        }
1512        v /= 1000.0;
1513        unit = u;
1514    }
1515    if v < 10.0 {
1516        format!("{v:.1} {unit}")
1517    } else {
1518        format!("{v:.0} {unit}")
1519    }
1520}
1521
1522#[cfg(test)]
1523mod tests {
1524    use super::*;
1525    use clap::CommandFactory;
1526
1527    #[test]
1528    fn clap_definition_is_valid() {
1529        Cli::command().debug_assert();
1530    }
1531
1532    #[test]
1533    fn sizes() {
1534        assert_eq!(human_bytes(0), "0 B");
1535        assert_eq!(human_bytes(999), "999 B");
1536        assert_eq!(human_bytes(3_400), "3.4 kB");
1537        assert_eq!(human_bytes(34_000_000), "34 MB");
1538        assert_eq!(human_bytes(1_200_000_000), "1.2 GB");
1539    }
1540
1541    #[test]
1542    fn source_cells_are_short() {
1543        let url = TemplateSource::Url {
1544            url: "postgres://u:***@h/db".into(),
1545        };
1546        assert_eq!(source_cell(&url), "postgres://u:***@h/db");
1547        let seed = TemplateSource::Seed {
1548            command: format!("psql \"$DATABASE_URL\"\n  -f {}", "x".repeat(80)),
1549            cwd: None,
1550            path: None,
1551        };
1552        let cell = source_cell(&seed);
1553        assert!(
1554            cell.starts_with("seed: psql \"$DATABASE_URL\" -f xx"),
1555            "{cell}"
1556        );
1557        assert_eq!(cell.chars().count(), 48);
1558        assert!(cell.ends_with('…') && !cell.contains('\n'));
1559    }
1560
1561    #[test]
1562    fn init_needs_exactly_one_source() {
1563        assert!(Cli::try_parse_from(["mitosis", "template", "init"]).is_err());
1564        assert!(Cli::try_parse_from([
1565            "mitosis",
1566            "template",
1567            "init",
1568            "--seed",
1569            "x",
1570            "--from-url",
1571            "y"
1572        ])
1573        .is_err());
1574        assert!(Cli::try_parse_from(["mitosis", "template", "init", "--seed", "x"]).is_ok());
1575        let basebackup = |extra: &[&str]| {
1576            let mut args = vec!["mitosis", "template", "init", "--basebackup", "u"];
1577            args.extend_from_slice(extra);
1578            Cli::try_parse_from(args)
1579        };
1580        assert!(basebackup(&[]).is_ok());
1581        assert!(basebackup(&["--database", "shop"]).is_ok());
1582        assert!(basebackup(&["--seed", "x"]).is_err());
1583        assert!(basebackup(&["--from-url", "y"]).is_err());
1584        let running = |extra: &[&str]| {
1585            let mut args = vec!["mitosis", "template", "init", "--from-running", "tank/pg"];
1586            args.extend_from_slice(extra);
1587            Cli::try_parse_from(args)
1588        };
1589        assert!(running(&[]).is_ok());
1590        assert!(running(&["--database", "shop"]).is_ok());
1591        for other in ["--seed", "--from-url", "--basebackup"] {
1592            assert!(running(&[other, "x"]).is_err(), "{other}");
1593        }
1594    }
1595
1596    #[test]
1597    fn settings_flags_parse() {
1598        let cli = Cli::try_parse_from([
1599            "mitosis",
1600            "template",
1601            "set",
1602            "main",
1603            "--keep",
1604            "2",
1605            "--every",
1606            "off",
1607            "--every-mode",
1608            "layered",
1609            "--migrate",
1610            "make migrate",
1611            "--auto-migrate",
1612            "on",
1613            "--on-stale",
1614            "refresh",
1615            "--max-age",
1616            "1d",
1617            "--resume",
1618        ])
1619        .unwrap();
1620        let Cmd::Template {
1621            command: TemplateCmd::Set {
1622                settings, resume, ..
1623            },
1624        } = cli.command
1625        else {
1626            panic!("wrong command")
1627        };
1628        assert!(resume);
1629        let (max_age, s) = settings.parse().unwrap();
1630        assert_eq!(max_age, Some(86_400));
1631        assert_eq!(s.keep, Some(2));
1632        assert!(s.every_off && s.every_secs.is_none());
1633        assert_eq!(s.every_mode, Some(crate::protocol::RefreshMode::Layered));
1634        assert_eq!(s.migrate.unwrap().command, "make migrate");
1635        assert_eq!(s.auto_migrate, Some(true));
1636        assert_eq!(s.on_stale, Some(crate::protocol::OnStale::Refresh));
1637        assert!(
1638            Cli::try_parse_from(["mitosis", "template", "set", "--every-mode", "fast"]).is_err()
1639        );
1640    }
1641
1642    #[test]
1643    fn top_reads_a_whole_key_at_once() {
1644        assert!(asks_to_quit(b"q") && asks_to_quit(b"Q") && asks_to_quit(&[3]));
1645        // An arrow key is three bytes: one read, no quit.
1646        assert!(!asks_to_quit(b"\x1b[A"));
1647        assert!(
1648            asks_to_quit(b"\x1b[Aq"),
1649            "a q behind other keys still quits"
1650        );
1651        assert!(!asks_to_quit(b""));
1652    }
1653}