use hws::cmd;
use hws::cmd::GlobalOpts;
use anyhow::Result;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
use cmd::Ctx;
#[derive(Parser, Debug)]
#[command(
name = "heyctl",
version,
about = "Control an app-lb load balancer: deployments, scaling, VM pools and certificates",
long_about = "heyctl drives app-lb's admin API.\n\n\
By default it talks to http://127.0.0.1:9090 — app-lb's admin listener binds \
loopback and speaks plaintext HTTP, so reach a remote one over an SSH tunnel \
(ssh -L 9090:127.0.0.1:9090 host) or through app-lb's own TLS listener, and \
save it with `heyctl login`.",
propagate_version = true,
max_term_width = 100
)]
struct Cli {
#[command(flatten)]
globals: GlobalOpts,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
Login(cmd::auth::LoginArgs),
Logout(cmd::auth::LogoutArgs),
Whoami,
#[command(subcommand)]
Token(cmd::token::TokenCmd),
Feed(cmd::feed::FeedArgs),
#[command(subcommand, visible_alias = "plugin")]
Plugins(cmd::plugins::PluginsCmd),
Config {
#[command(subcommand)]
cmd: cmd::auth::ConfigCmd,
},
#[command(visible_alias = "list")]
Get(cmd::read::GetArgs),
Describe(cmd::read::DescribeArgs),
Create {
#[command(subcommand)]
cmd: CreateCmd,
},
Build(cmd::write::BuildArgs),
Pull(cmd::write::PullArgs),
#[command(subcommand)]
Mounts(MountsCmd),
#[command(visible_aliases = ["art", "registry"])]
Artifact {
#[command(flatten)]
opts: cmd::artifact::RegistryOpts,
#[command(subcommand)]
cmd: cmd::artifact::ArtifactCmd,
},
Update(cmd::write::UpdateArgs),
Apply(cmd::write::ApplyArgs),
Edit(cmd::write::EditArgs),
Set {
#[command(subcommand)]
cmd: SetCmd,
},
#[command(visible_alias = "autoscale")]
Scale(cmd::write::ScaleArgs),
Restart(cmd::write::RestartArgs),
Cordon(cmd::write::CordonArgs),
Drain(cmd::write::DrainArgs),
Uncordon(cmd::write::UncordonArgs),
Rollout {
#[command(subcommand)]
cmd: RolloutCmd,
},
Exec(cmd::session::ExecArgs),
#[command(visible_alias = "ssh")]
Shell(cmd::session::ShellArgs),
#[command(visible_alias = "rm")]
Delete(cmd::write::DeleteArgs),
Top(cmd::observe::TopArgs),
#[command(visible_alias = "cluster-info")]
Status,
Completion {
#[arg(value_name = "SHELL")]
shell: Shell,
},
}
#[derive(Subcommand, Debug)]
enum CreateCmd {
#[command(visible_aliases = ["deploy", "dep"])]
Deployment(Box<cmd::write::CreateDeploymentArgs>),
#[command(visible_alias = "sec")]
Secret(cmd::write::CreateSecretArgs),
#[command(visible_aliases = ["wf", "flow"])]
Workflow(cmd::write::CreateWorkflowArgs),
#[command(visible_alias = "ns")]
Namespace(cmd::write::CreateNamespaceArgs),
#[command(visible_aliases = ["provider", "idp"])]
AuthProvider(Box<cmd::write::CreateAuthProviderArgs>),
}
#[derive(Subcommand, Debug)]
enum SetCmd {
Image(cmd::write::SetImageArgs),
Env(cmd::write::SetEnvArgs),
Upstreams(cmd::write::SetUpstreamsArgs),
#[command(visible_alias = "routes")]
Route(cmd::write::SetRouteArgs),
Build(cmd::write::SetBuildArgs),
#[command(visible_alias = "art")]
Artifact(cmd::write::SetArtifactArgs),
Update(cmd::write::SetUpdateArgs),
Auth(cmd::write::SetAuthArgs),
#[command(visible_alias = "sec")]
Secret(cmd::write::SetSecretArgs),
}
#[derive(Subcommand, Debug)]
enum MountsCmd {
Pull(cmd::write::MountPullArgs),
}
#[derive(Subcommand, Debug)]
enum RolloutCmd {
Status(cmd::write::RolloutStatusArgs),
Restart(cmd::write::RestartArgs),
}
fn main() {
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let cli = Cli::parse();
if let Err(e) = run(&cli) {
eprintln!("error: {e:#}");
std::process::exit(1);
}
}
fn run(cli: &Cli) -> Result<()> {
let g = &cli.globals;
match &cli.command {
Command::Login(args) => cmd::auth::login(g, args),
Command::Logout(args) => cmd::auth::logout(g, args),
Command::Config { cmd } => cmd::auth::config(g, cmd),
Command::Whoami => cmd::auth::whoami(g),
Command::Token(cmd) => cmd::token::run(&Ctx::new(g)?, cmd),
Command::Feed(args) => cmd::feed::run(&Ctx::new(g)?, args),
Command::Plugins(cmd) => cmd::plugins::run(&Ctx::new(g)?, cmd),
Command::Completion { shell } => {
let mut command = Cli::command();
let name = command.get_name().to_string();
clap_complete::generate(*shell, &mut command, name, &mut std::io::stdout());
Ok(())
}
Command::Artifact { opts, cmd } => cmd::artifact::run(g, opts, cmd),
Command::Get(args) => cmd::read::get(&Ctx::new(g)?, args),
Command::Describe(args) => cmd::read::describe(&Ctx::new(g)?, args),
Command::Create { cmd } => match cmd {
CreateCmd::Deployment(args) => cmd::write::create(&Ctx::new(g)?, args),
CreateCmd::Secret(args) => cmd::write::create_secret(&Ctx::new(g)?, args),
CreateCmd::Workflow(args) => cmd::write::create_workflow(&Ctx::new(g)?, args),
CreateCmd::Namespace(args) => cmd::write::create_namespace(&Ctx::new(g)?, args),
CreateCmd::AuthProvider(args) => {
cmd::write::create_auth_provider(&Ctx::new(g)?, args)
}
},
Command::Build(args) => cmd::write::build(&Ctx::new(g)?, args),
Command::Pull(args) => cmd::write::pull(&Ctx::new(g)?, args),
Command::Mounts(MountsCmd::Pull(args)) => cmd::write::pull_mounts(&Ctx::new(g)?, args),
Command::Update(args) => cmd::write::update(&Ctx::new(g)?, args),
Command::Apply(args) => cmd::write::apply(&Ctx::new(g)?, args),
Command::Edit(args) => cmd::write::edit(&Ctx::new(g)?, args),
Command::Set { cmd } => {
let ctx = Ctx::new(g)?;
match cmd {
SetCmd::Image(args) => cmd::write::set_image(&ctx, args),
SetCmd::Env(args) => cmd::write::set_env(&ctx, args),
SetCmd::Upstreams(args) => cmd::write::set_upstreams(&ctx, args),
SetCmd::Route(args) => cmd::write::set_route(&ctx, args),
SetCmd::Build(args) => cmd::write::set_build(&ctx, args),
SetCmd::Artifact(args) => cmd::write::set_artifact(&ctx, args),
SetCmd::Update(args) => cmd::write::set_update(&ctx, args),
SetCmd::Auth(args) => cmd::write::set_auth(&ctx, args),
SetCmd::Secret(args) => cmd::write::set_secret(&ctx, args),
}
}
Command::Scale(args) => cmd::write::scale(&Ctx::new(g)?, args),
Command::Restart(args) => cmd::write::restart(&Ctx::new(g)?, args),
Command::Cordon(args) => cmd::write::cordon(&Ctx::new(g)?, args),
Command::Drain(args) => cmd::write::drain(&Ctx::new(g)?, args),
Command::Uncordon(args) => cmd::write::uncordon(&Ctx::new(g)?, args),
Command::Rollout { cmd } => {
let ctx = Ctx::new(g)?;
match cmd {
RolloutCmd::Status(args) => cmd::write::rollout_status(&ctx, args),
RolloutCmd::Restart(args) => cmd::write::restart(&ctx, args),
}
}
Command::Exec(args) => cmd::session::exec(&Ctx::new(g)?, args),
Command::Shell(args) => cmd::session::shell(&Ctx::new(g)?, args),
Command::Delete(args) => cmd::write::delete(&Ctx::new(g)?, args),
Command::Top(args) => cmd::observe::top(&Ctx::new(g)?, args),
Command::Status => cmd::observe::status(&Ctx::new(g)?),
}
}
#[cfg(test)]
mod tests {
use hws::output::OutputFormat;
use super::*;
#[test]
fn the_cli_definition_is_well_formed() {
Cli::command().debug_assert();
}
#[test]
fn global_flags_are_accepted_after_the_subcommand() {
let cli = Cli::try_parse_from([
"heyctl",
"get",
"deployments",
"-o",
"json",
"--server",
"http://lb:9090",
])
.unwrap();
assert_eq!(cli.globals.output, OutputFormat::Json);
assert_eq!(cli.globals.server.as_deref(), Some("http://lb:9090"));
}
#[test]
fn scale_rejects_replicas_together_with_a_band() {
assert!(
Cli::try_parse_from(["heyctl", "scale", "web", "--replicas", "2", "--min", "1"])
.is_err()
);
assert!(Cli::try_parse_from(["heyctl", "scale", "web", "--replicas", "2"]).is_ok());
}
#[test]
fn creating_an_auth_provider_needs_exactly_one_identity_shape() {
assert!(
Cli::try_parse_from(["heyctl", "create", "auth-provider", "heyo"]).is_err(),
"no identity given"
);
assert!(
Cli::try_parse_from([
"heyctl", "create", "auth-provider", "heyo",
"--preset", "heyo", "--issuer", "auth-service",
])
.is_err(),
"a preset and a hand-written issuer are two answers to one question"
);
assert!(
Cli::try_parse_from([
"heyctl", "create", "auth-provider", "heyo", "-n", "team-a",
"--preset", "heyo", "--secret", "heyo-auth/jwt_secret",
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"heyctl", "create", "provider", "okta",
"--issuer", "https://example.okta.com",
"--jwks-url", "https://example.okta.com/oauth2/v1/keys",
"--alg", "RS256",
"--require", "groups=engineering,ops",
])
.is_ok()
);
}
#[test]
fn a_gate_either_inherits_an_identity_or_writes_one() {
assert!(
Cli::try_parse_from([
"heyctl", "set", "auth", "web", "--provider-ref", "heyo",
"--client-id", "1234.apps.googleusercontent.com",
])
.is_err()
);
assert!(
Cli::try_parse_from([
"heyctl", "set", "auth", "web", "--provider-ref", "heyo",
"--public-path", "/healthz",
])
.is_ok()
);
}
#[test]
fn create_takes_the_shorthand_and_long_route_forms() {
let cli = Cli::try_parse_from([
"heyctl",
"create",
"deployment",
"web",
"--host",
"web.local",
"--port",
"8080",
"--route",
"path=/api",
"-e",
"RUST_LOG=info",
])
.unwrap();
let Command::Create {
cmd: CreateCmd::Deployment(args),
} = &cli.command
else {
panic!("expected create deployment");
};
assert_eq!(args.host.as_deref(), Some("web.local"));
assert_eq!(args.port, Some(8080));
assert_eq!(args.routes, vec!["path=/api".to_string()]);
assert_eq!(args.env, vec!["RUST_LOG=info".to_string()]);
}
#[test]
fn create_accepts_an_orchestrator_discovery_service() {
let cli = Cli::try_parse_from([
"heyctl",
"create",
"deployment",
"cloud",
"--host",
"cloud.example.com",
"--discovery-service",
"cloud",
])
.unwrap();
let Command::Create {
cmd: CreateCmd::Deployment(args),
} = &cli.command
else {
panic!("expected create deployment");
};
assert_eq!(args.discovery_service.as_deref(), Some("cloud"));
assert_eq!(args.port, None);
}
#[test]
fn create_takes_a_build_source() {
let cli = Cli::try_parse_from([
"heyctl",
"create",
"deployment",
"web",
"--host",
"web.local",
"--port",
"8080",
"--repo",
"https://github.com/acme/web.git",
"--ref",
"main",
"--secret",
"github/token",
])
.unwrap();
let Command::Create {
cmd: CreateCmd::Deployment(args),
} = &cli.command
else {
panic!("expected create deployment");
};
assert_eq!(args.repo.as_deref(), Some("https://github.com/acme/web.git"));
assert_eq!(args.git_ref.as_deref(), Some("main"));
assert_eq!(args.secret.as_deref(), Some("github/token"));
assert!(
Cli::try_parse_from([
"heyctl", "create", "deployment", "web", "--host", "w.local", "--port", "80",
"--ref", "main",
])
.is_err(),
"--ref without --repo should be refused"
);
}
#[test]
fn a_secret_can_be_created_without_putting_the_value_on_the_command_line() {
let cli = Cli::try_parse_from([
"heyctl",
"create",
"secret",
"github",
"--from-stdin",
"token",
"--description",
"CI PAT",
])
.unwrap();
let Command::Create {
cmd: CreateCmd::Secret(args),
} = &cli.command
else {
panic!("expected create secret");
};
assert_eq!(args.name, "github");
assert_eq!(args.sources.from_stdin.as_deref(), Some("token"));
assert!(args.literals.is_empty());
assert_eq!(args.description.as_deref(), Some("CI PAT"));
}
#[test]
fn build_takes_a_one_off_ref_and_can_wait() {
let cli =
Cli::try_parse_from(["heyctl", "build", "web", "--ref", "v2.1", "--wait"]).unwrap();
let Command::Build(args) = &cli.command else {
panic!("expected build");
};
assert_eq!(args.resource, "web");
assert_eq!(args.git_ref.as_deref(), Some("v2.1"));
assert!(args.wait);
}
#[test]
fn update_takes_a_working_directory_and_commands() {
let cli = Cli::try_parse_from([
"heyctl",
"set",
"update",
"app-obs",
"--workdir",
"/srv/app-obs",
"-c",
"git pull --ff-only",
"-c",
"cargo build --release",
"-c",
"supervisorctl restart app-obs",
"--secret-env",
"APP_OBS_INGEST_TOKEN=obs/ingest_token",
"--verify-timeout",
"90",
])
.unwrap();
let Command::Set {
cmd: SetCmd::Update(args),
} = &cli.command
else {
panic!("expected set update");
};
assert_eq!(args.working_dir.as_deref(), Some("/srv/app-obs"));
assert_eq!(args.commands.len(), 3);
assert_eq!(args.commands[0], "git pull --ff-only");
assert_eq!(args.secret_env, vec!["APP_OBS_INGEST_TOKEN=obs/ingest_token"]);
assert_eq!(args.verify_timeout_secs, Some(90));
}
#[test]
fn running_an_update_can_wait_and_stream() {
let cli = Cli::try_parse_from(["heyctl", "update", "app-obs", "--logs"]).unwrap();
let Command::Update(args) = &cli.command else {
panic!("expected update");
};
assert_eq!(args.resource, "app-obs");
assert!(args.logs);
assert!(!args.wait, "--logs implies waiting without setting the flag");
}
#[test]
fn a_gate_takes_a_client_id_a_secret_and_an_allow_list() {
let cli = Cli::try_parse_from([
"heyctl",
"set",
"auth",
"web",
"--client-id",
"cid.apps.googleusercontent.com",
"--secret",
"google/client_secret",
"--allow-domain",
"example.com",
"--allow-email",
"contractor@gmail.com",
"--public-path",
"/healthz",
])
.unwrap();
let Command::Set {
cmd: SetCmd::Auth(args),
} = &cli.command
else {
panic!("expected set auth");
};
assert_eq!(args.client_id.as_deref(), Some("cid.apps.googleusercontent.com"));
assert_eq!(args.secret.as_deref(), Some("google/client_secret"));
assert_eq!(args.allow_domains, vec!["example.com".to_string()]);
assert_eq!(args.allow_emails, vec!["contractor@gmail.com".to_string()]);
assert_eq!(args.public_paths, vec!["/healthz".to_string()]);
assert!(!args.clear);
}
#[test]
fn clearing_a_gate_excludes_editing_it() {
assert!(Cli::try_parse_from(["heyctl", "set", "auth", "web", "--clear"]).is_ok());
assert!(
Cli::try_parse_from([
"heyctl", "set", "auth", "web", "--clear", "--allow-domain", "example.com",
])
.is_err(),
"--clear and --allow-domain say opposite things"
);
}
#[test]
fn clearing_an_update_excludes_editing_it() {
assert!(Cli::try_parse_from(["heyctl", "set", "update", "obs", "--clear"]).is_ok());
assert!(
Cli::try_parse_from([
"heyctl", "set", "update", "obs", "--clear", "-c", "make deploy",
])
.is_err(),
"--clear and --command say opposite things"
);
}
#[test]
fn a_pull_takes_a_one_off_ref_and_can_wait() {
let cli = Cli::try_parse_from([
"heyctl", "pull", "web", "--ref", "debian-hermes", "--wait",
])
.unwrap();
let Command::Pull(args) = &cli.command else {
panic!("expected pull");
};
assert_eq!(args.resource, "web");
assert_eq!(args.artifact_ref.as_deref(), Some("debian-hermes"));
assert!(args.wait);
assert!(!args.force);
}
#[test]
fn an_artifact_source_takes_a_store_and_a_ref() {
let cli = Cli::try_parse_from([
"heyctl",
"set",
"artifact",
"web",
"--store",
"http://127.0.0.1:8080",
"--ref",
"debian-hermes",
"--grow-gb",
"8",
"--secret",
"art/api_key",
])
.unwrap();
let Command::Set {
cmd: SetCmd::Artifact(args),
} = &cli.command
else {
panic!("expected set artifact");
};
assert_eq!(args.store.as_deref(), Some("http://127.0.0.1:8080"));
assert_eq!(args.artifact_ref.as_deref(), Some("debian-hermes"));
assert_eq!(args.grow_gb, Some(8));
assert_eq!(args.secret.as_deref(), Some("art/api_key"));
}
#[test]
fn clearing_an_artifact_source_excludes_editing_it() {
assert!(Cli::try_parse_from(["heyctl", "set", "artifact", "web", "--clear"]).is_ok());
assert!(
Cli::try_parse_from([
"heyctl", "set", "artifact", "web", "--clear", "--ref", "debian-hermes",
])
.is_err(),
"--clear and --ref say opposite things"
);
}
#[test]
fn a_push_takes_a_file_or_an_image_but_not_both() {
let cli = Cli::try_parse_from([
"heyctl", "artifact", "push", "/tmp/rootfs.ext4", "--tag", "web-v2",
])
.unwrap();
let Command::Artifact {
cmd: cmd::artifact::ArtifactCmd::Push(args),
..
} = &cli.command
else {
panic!("expected artifact push");
};
assert_eq!(args.file.as_deref(), Some(std::path::Path::new("/tmp/rootfs.ext4")));
assert_eq!(args.tag.as_deref(), Some("web-v2"));
assert!(
Cli::try_parse_from(["heyctl", "artifact", "push", "--image", "artifacts"]).is_ok(),
"--image is the other way to name the source"
);
assert!(
Cli::try_parse_from([
"heyctl", "artifact", "push", "/tmp/a.ext4", "--image", "artifacts",
])
.is_err(),
"a path and an image name are two answers to one question"
);
assert!(Cli::try_parse_from(["heyctl", "artifact", "push"]).is_err());
assert!(
Cli::try_parse_from([
"heyctl", "artifact", "push", "/tmp/a.ext4", "--tag", "x", "--no-tag",
])
.is_err()
);
}
#[test]
fn login_takes_a_token_instead_of_a_pair() {
let cli = Cli::try_parse_from([
"heyctl",
"login",
"--server",
"https://server.heyo.computer/namespaces/team-a/lb",
"--token-stdin",
"--name",
"team-a",
])
.unwrap();
let Command::Login(args) = &cli.command else {
panic!("expected login");
};
assert!(args.token_stdin);
assert_eq!(args.server.as_deref(), Some("https://server.heyo.computer/namespaces/team-a/lb"));
assert_eq!(args.name.as_deref(), Some("team-a"));
assert!(Cli::try_parse_from(["heyctl", "login", "--token", "heyo_api_x", "--user", "admin"]).is_err());
assert!(Cli::try_parse_from(["heyctl", "login", "--token", "heyo_api_x", "--password-stdin"]).is_err());
let cli = Cli::try_parse_from(["heyctl", "--token", "heyo_api_x", "get", "deployments"]).unwrap();
assert_eq!(cli.globals.token.as_deref(), Some("heyo_api_x"));
}
#[test]
fn artifact_login_takes_a_url_and_a_key_out_of_band() {
let cli = Cli::try_parse_from([
"heyctl",
"artifact",
"login",
"http://art.example.com:8080",
"--api-key-stdin",
"--name",
"prod-store",
])
.unwrap();
let Command::Artifact {
cmd: cmd::artifact::ArtifactCmd::Login(args),
..
} = &cli.command
else {
panic!("expected artifact login");
};
assert_eq!(args.url, "http://art.example.com:8080");
assert!(args.api_key_stdin);
assert_eq!(args.name.as_deref(), Some("prod-store"));
assert!(
Cli::try_parse_from([
"heyctl", "artifact", "login", "http://art:8080", "--api-key", "k",
"--api-key-stdin",
])
.is_err()
);
}
#[test]
fn a_build_source_is_a_repo_or_a_store_and_never_both() {
assert!(
Cli::try_parse_from([
"heyctl", "set", "build", "web", "--store", "http://art:8080", "--ref",
"web-rootfs",
])
.is_ok()
);
for conflicting in [
vec!["--store", "http://art:8080", "--repo", "https://x/y.git"],
vec!["--store", "http://art:8080", "--dockerfile", "deploy/Dockerfile"],
vec!["--store", "http://art:8080", "--build-context", "."],
] {
let mut argv = vec!["heyctl", "set", "build", "web"];
argv.extend(conflicting.iter().copied());
assert!(
Cli::try_parse_from(&argv).is_err(),
"{conflicting:?} should be refused"
);
}
}
#[test]
fn create_takes_a_dockerfile_manifest_build_source() {
let cli = Cli::try_parse_from([
"heyctl",
"create",
"deployment",
"web",
"--host",
"web.local",
"--port",
"8080",
"--build-store",
"http://art:8080",
"--ref",
"web-rootfs",
])
.unwrap();
let Command::Create {
cmd: CreateCmd::Deployment(args),
} = &cli.command
else {
panic!("expected create deployment");
};
assert_eq!(args.build_store.as_deref(), Some("http://art:8080"));
assert_eq!(args.git_ref.as_deref(), Some("web-rootfs"));
assert_eq!(args.repo, None);
assert!(
Cli::try_parse_from([
"heyctl", "create", "deployment", "web", "--host", "w.local", "--port", "80",
"--build-store", "http://art:8080",
])
.is_err(),
"--build-store without --ref should be refused"
);
assert!(
Cli::try_parse_from([
"heyctl", "create", "deployment", "web", "--host", "w.local", "--port", "80",
"--build-store", "http://art:8080", "--repo", "https://x/y.git", "--ref", "main",
])
.is_err(),
"--build-store and --repo say opposite things"
);
}
#[test]
fn pushing_a_dockerfile_takes_a_context_and_a_tag() {
let cli = Cli::try_parse_from([
"heyctl",
"artifact",
"push-dockerfile",
"./Dockerfile",
"--build-context",
"./app",
"--tag",
"web-rootfs",
"--size-mb",
"4096",
])
.unwrap();
let Command::Artifact {
cmd: cmd::artifact::ArtifactCmd::PushDockerfile(args),
..
} = &cli.command
else {
panic!("expected artifact push-dockerfile");
};
assert_eq!(args.file, std::path::PathBuf::from("./Dockerfile"));
assert_eq!(args.build_context.as_deref(), Some(std::path::Path::new("./app")));
assert_eq!(args.tag.as_deref(), Some("web-rootfs"));
assert_eq!(args.image_size_mb, Some(4096));
assert!(
Cli::try_parse_from([
"heyctl", "artifact", "push-dockerfile", "./Dockerfile", "--tag", "x",
"--no-tag",
])
.is_err()
);
}
#[test]
fn clearing_a_build_source_excludes_editing_it() {
assert!(Cli::try_parse_from(["heyctl", "set", "build", "web", "--clear"]).is_ok());
assert!(
Cli::try_parse_from([
"heyctl", "set", "build", "web", "--clear", "--repo", "https://x/y.git",
])
.is_err(),
"--clear and --repo say opposite things"
);
assert!(
Cli::try_parse_from(["heyctl", "set", "build", "web", "--username", "bot"]).is_err()
);
}
}