Repository: wasmerio/wasmer (language: rust)

Issue:
CLI: Implement autobuild commands
Implement autobuild related commands:

* wasmer app build list
* wasmer app build view
* wasmer app build logs


Discussion / hints:

Fix patch:
diff --git a/lib/backend-api/schema.graphql b/lib/backend-api/schema.graphql
index e90fbafb14c..a8c67c869a6 100644
--- a/lib/backend-api/schema.graphql
+++ b/lib/backend-api/schema.graphql
@@ -72,6 +72,7 @@ type User implements Node & PackageOwner & Owner {
   loginMethods: [LoginMethod!]!
   githubUser: SocialAuth
   githubScopes: [String]!
+  githubRepositories: [GithubRepository]!
 }
 
 """Setup for backwards compatibility with existing frontends."""
@@ -949,12 +950,14 @@ type DeployApp implements Node & Owner {
   aggregateMetrics: AggregateMetrics!
   aliases(offset: Int, before: String, after: String, first: Int, last: Int): AppAliasConnection!
   secrets(offset: Int, before: String, after: String, first: Int, last: Int): SecretConnection!
+  databases(offset: Int, before: String, after: String, first: Int, last: Int): AppDatabaseConnection!
   usageMetrics(forRange: MetricRange!, variant: MetricType!): [UsageMetric]!
   s3Url: URL
   s3Credentials: S3Credentials
   deleted: Boolean!
   favicon: URL
   screenshot(viewportSize: AppScreenshotViewportSize, appearance: AppScreenshotAppearance): URL
+  deployments(offset: Int, before: String, after: String, first: Int, last: Int): AutobuildRepositoryConnection
 }
 
 enum DeployAppVersionsSortBy {
@@ -1042,6 +1045,39 @@ type Secret implements Node {
   id: ID!
 }
 
+type AppDatabaseConnection {
+  """Pagination data for this connection."""
+  pageInfo: PageInfo!
+
+  """Contains the nodes in this connection."""
+  edges: [AppDatabaseEdge]!
+
+  """Total number of items in the connection."""
+  totalCount: Int
+}
+
+"""A Relay edge containing a `AppDatabase` and its cursor."""
+type AppDatabaseEdge {
+  """The item at the end of the edge"""
+  node: AppDatabase
+
+  """A cursor for use in pagination"""
+  cursor: String!
+}
+
+type AppDatabase implements Node {
+  createdAt: DateTime!
+  updatedAt: DateTime!
+  deletedAt: DateTime
+  name: String!
+  username: String!
+  app: DeployApp!
+
+  """The ID of the object"""
+  id: ID!
+  host: String!
+}
+
 type UsageMetric {
   variant: MetricType!
   value: Float!
@@ -1108,6 +1144,59 @@ enum AppScreenshotAppearance {
   DARK
 }
 
+type AutobuildRepositoryConnection {
+  """Pagination data for this connection."""
+  pageInfo: PageInfo!
+
+  """Contains the nodes in this connection."""
+  edges: [AutobuildRepositoryEdge]!
+
+  """Total number of items in the connection."""
+  totalCount: Int
+}
+
+"""A Relay edge containing a `AutobuildRepository` and its cursor."""
+type AutobuildRepositoryEdge {
+  """The item at the end of the edge"""
+  node: AutobuildRepository
+
+  """A cursor for use in pagination"""
+  cursor: String!
+}
+
+type AutobuildRepository implements Node {
+  """The ID of the object"""
+  id: ID!
+  user: User!
+  name: String!
+  namespace: String!
+  app: DeployApp
+  createdAt: DateTime!
+  updatedAt: DateTime!
+  appName: String
+  buildId: UUID!
+  repoUrl: String!
+  status: StatusEnum!
+  appVersion: DeployAppVersion
+  logUrl: String
+}
+
+"""
+Leverages the internal Python implementation of UUID (uuid.UUID) to provide native UUID objects
+in fields, resolvers and input.
+"""
+scalar UUID
+
+enum StatusEnum {
+  SUCCESS
+  WORKING
+  FAILURE
+  QUEUED
+  TIMEOUT
+  INTERNAL_ERROR
+  CANCELLED
+}
+
 type LogConnection {
   """Pagination data for this connection."""
   pageInfo: PageInfo!
@@ -1136,7 +1225,7 @@ type AppVersionVolume {
   name: String!
   s3Url: String!
   mountPaths: [AppVersionVolumeMountPath]!
-  size: Int
+  size: BigInt
   usedSize: BigInt
 }
 
@@ -2066,6 +2155,12 @@ type SocialAuth implements Node {
   username: String!
 }
 
+type GithubRepository {
+  url: String!
+  name: String!
+  namespace: String!
+}
+
 type Signature {
   id: ID!
   publicKey: PublicKey!
@@ -2360,6 +2455,7 @@ type Query {
   getSecretValue(id: ID!): String
   getAppTemplate(slug: String!): AppTemplate
   getAppTemplateCategories(offset: Int, before: String, after: String, first: Int, last: Int): AppTemplateCategoryConnection
+  getBuildKinds(offset: Int, before: String, after: String, first: Int, last: Int): BuildKindConnection!
   viewer: User
   getUser(username: String!): User
   getPasswordResetToken(token: String!): GetPasswordResetToken
@@ -2596,6 +2692,36 @@ type AppTemplateCategoryEdge {
   cursor: String!
 }
 
+type BuildKindConnection {
+  """Pagination data for this connection."""
+  pageInfo: PageInfo!
+
+  """Contains the nodes in this connection."""
+  edges: [BuildKindEdge]!
+
+  """Total number of items in the connection."""
+  totalCount: Int
+}
+
+"""A Relay edge containing a `BuildKind` and its cursor."""
+type BuildKindEdge {
+  """The item at the end of the edge"""
+  node: BuildKind
+
+  """A cursor for use in pagination"""
+  cursor: String!
+}
+
+type BuildKind implements Node {
+  name: String!
+  setupDb: Boolean!
+  buildCmd: String!
+  installCmd: String!
+
+  """The ID of the object"""
+  id: ID!
+}
+
 type GetPasswordResetToken {
   valid: Boolean!
   user: User
@@ -3049,6 +3175,15 @@ type Mutation {
 
   """Redeploy the active version of an app."""
   rotateS3SecretsForApp(input: RotateS3SecretsForAppInput!): RotateS3SecretsForAppPayload
+
+  """Delete a database for an app."""
+  rotateCredentialsForAppDb(input: RotateCredentialsForAppDBInput!): RotateCredentialsForAppDBPayload
+
+  """Create a new database for an app."""
+  createAppDb(input: CreateAppDBInput!): CreateAppDBPayload
+
+  """Delete a database for an app."""
+  deleteAppDb(input: DeleteAppDBInput!): DeleteAppDBPayload
   tokenAuth(input: ObtainJSONWebTokenInput!): ObtainJSONWebTokenPayload
   generateDeployToken(input: GenerateDeployTokenInput!): GenerateDeployTokenPayload
   verifyAccessToken(token: String): Verify
@@ -3532,6 +3667,47 @@ input RotateS3SecretsForAppInput {
   clientMutationId: String
 }
 
+"""Delete a database for an app."""
+type RotateCredentialsForAppDBPayload {
+  database: AppDatabase!
+  password: String!
+  clientMutationId: String
+}
+
+input RotateCredentialsForAppDBInput {
+  """App Database ID"""
+  id: ID!
+  clientMutationId: String
+}
+
+"""Create a new database for an app."""
+type CreateAppDBPayload {
+  database: AppDatabase!
+  password: String!
+  clientMutationId: String
+}
+
+input CreateAppDBInput {
+  """App ID"""
+  id: ID!
+
+  """Database name"""
+  name: String!
+  clientMutationId: String
+}
+
+"""Delete a database for an app."""
+type DeleteAppDBPayload {
+  success: Boolean!
+  clientMutationId: String
+}
+
+input DeleteAppDBInput {
+  """App Database ID"""
+  id: ID!
+  clientMutationId: String
+}
+
 type ObtainJSONWebTokenPayload {
   payload: GenericScalar!
   refreshExpiresIn: Int!
@@ -4382,6 +4558,9 @@ type Subscription {
   ): Log!
   waitOnRepoCreation(repoId: ID!): Boolean!
   appIsPublishedFromRepo(repoId: ID!): DeployAppVersion!
+  publishAppFromRepoAutobuild(repoUrl: String!, appName: String!, owner: String, buildCmd: String, installCmd: String, databaseName: String, secrets: [SecretInput]): AutobuildLog
+  fetchBuildLogs(buildId: String!): String
+  autobuildConfigForRepo(repoUrl: String!): BuildConfig
   packageVersionCreated(publishedBy: ID, ownerId: ID): PackageVersion!
 
   """Subscribe to package version ready"""
@@ -4389,6 +4568,32 @@ type Subscription {
   userNotificationCreated(userId: ID!): UserNotificationCreated!
 }
 
+"""Log entry for Deploying app from github repo"""
+type AutobuildLog {
+  """Kind of log message."""
+  kind: AutoBuildDeployAppLogKind!
+
+  """Log message"""
+  message: String
+  appVersion: DeployAppVersion
+
+  """The database password, if DB was setup."""
+  dbPassword: String
+}
+
+enum AutoBuildDeployAppLogKind {
+  LOG
+  COMPLETE
+}
+
+"""Log entry for Deploying app from github repo"""
+type BuildConfig {
+  buildCmd: String!
+  installCmd: String!
+  setupDb: Boolean!
+  presetName: String!
+}
+
 type PackageVersionReadyResponse {
   state: PackageVersionState!
   packageVersion: PackageVersion!
diff --git a/lib/backend-api/src/query.rs b/lib/backend-api/src/query.rs
index fe888837882..9032c788b90 100644
--- a/lib/backend-api/src/query.rs
+++ b/lib/backend-api/src/query.rs
@@ -1009,6 +1009,41 @@ pub async fn get_deploy_app_versions(
     Ok(versions)
 }
 
+/// Get app deployments for an app.
+pub async fn app_deployments(
+    client: &WasmerClient,
+    vars: types::GetAppDeploymentsVariables,
+) -> Result<Vec<types::AutobuildRepository>, anyhow::Error> {
+    let res = client
+        .run_graphql_strict(types::GetAppDeployments::build(vars))
+        .await?;
+    let builds = res
+        .get_deploy_app
+        .and_then(|x| x.deployments)
+        .context("no data returned")?
+        .edges
+        .into_iter()
+        .flatten()
+        .filter_map(|x| x.node)
+        .collect();
+
+    Ok(builds)
+}
+
+/// Get an app deployment by ID.
+pub async fn app_deployment(
+    client: &WasmerClient,
+    id: String,
+) -> Result<types::AutobuildRepository, anyhow::Error> {
+    let node = get_node(client, id.clone())
+        .await?
+        .with_context(|| format!("app deployment with id '{}' not found", id))?;
+    match node {
+        types::Node::AutobuildRepository(x) => Ok(*x),
+        _ => anyhow::bail!("invalid node type returned"),
+    }
+}
+
 /// Load all versions of an app.
 ///
 /// Will paginate through all versions and return them in a single list.
diff --git a/lib/backend-api/src/types.rs b/lib/backend-api/src/types.rs
index 14bbf5f6ed2..92221d71ed2 100644
--- a/lib/backend-api/src/types.rs
+++ b/lib/backend-api/src/types.rs
@@ -844,7 +844,7 @@ mod queries {
     #[derive(serde::Serialize, cynic::QueryFragment, Debug)]
     pub struct AppVersionVolume {
         pub name: String,
-        pub size: Option<i32>,
+        pub size: Option<BigInt>,
         pub used_size: Option<BigInt>,
     }
 
@@ -1184,6 +1184,81 @@ mod queries {
         pub app: DeployApp,
     }
 
+    #[derive(cynic::QueryVariables, Debug)]
+    pub struct GetAppDeploymentsVariables {
+        pub after: Option<String>,
+        pub first: Option<i32>,
+        pub name: String,
+        pub offset: Option<i32>,
+        pub owner: String,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    #[cynic(graphql_type = "Query", variables = "GetAppDeploymentsVariables")]
+    pub struct GetAppDeployments {
+        #[arguments(owner: $owner, name: $name)]
+        pub get_deploy_app: Option<DeployAppDeployments>,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    #[cynic(graphql_type = "DeployApp", variables = "GetAppDeploymentsVariables")]
+    pub struct DeployAppDeployments {
+        // FIXME: add $offset, $after, currently causes an error from the backend
+        // #[arguments(first: $first, after: $after, offset: $offset)]
+        pub deployments: Option<AutobuildRepositoryConnection>,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    pub struct AutobuildRepositoryConnection {
+        pub page_info: PageInfo,
+        pub edges: Vec<Option<AutobuildRepositoryEdge>>,
+    }
+
+    #[derive(cynic::QueryFragment, Debug)]
+    pub struct AutobuildRepositoryEdge {
+        pub node: Option<AutobuildRepository>,
+    }
+
+    #[derive(cynic::QueryFragment, serde::Serialize, Debug)]
+    pub struct AutobuildRepository {
+        pub id: cynic::Id,
+        pub build_id: Uuid,
+        pub created_at: DateTime,
+        pub updated_at: DateTime,
+        pub status: StatusEnum,
+        pub log_url: Option<String>,
+        pub repo_url: String,
+    }
+
+    #[derive(cynic::Enum, Clone, Copy, Debug)]
+    pub enum StatusEnum {
+        Success,
+        Working,
+        Failure,
+        Queued,
+        Timeout,
+        InternalError,
+        Cancelled,
+    }
+
+    impl StatusEnum {
+        pub fn as_str(&self) -> &'static str {
+            match self {
+                Self::Success => "success",
+                Self::Working => "working",
+                Self::Failure => "failure",
+                Self::Queued => "queued",
+                Self::Timeout => "timeout",
+                Self::InternalError => "internal_error",
+                Self::Cancelled => "cancelled",
+            }
+        }
+    }
+
+    #[derive(cynic::Scalar, Debug, Clone)]
+    #[cynic(graphql_type = "UUID")]
+    pub struct Uuid(pub String);
+
     #[derive(cynic::QueryVariables, Debug)]
     pub struct PublishDeployAppVars {
         pub config: String,
@@ -2098,6 +2173,7 @@ mod queries {
     pub enum Node {
         DeployApp(Box<DeployApp>),
         DeployAppVersion(Box<DeployAppVersion>),
+        AutobuildRepository(Box<AutobuildRepository>),
         #[cynic(fallback)]
         Unknown,
     }
diff --git a/lib/cli/src/commands/app/deploy.rs b/lib/cli/src/commands/app/deploy.rs
index fda80cb8009..72c02a8ab6d 100644
--- a/lib/cli/src/commands/app/deploy.rs
+++ b/lib/cli/src/commands/app/deploy.rs
@@ -587,7 +587,7 @@ impl AsyncCliCommand for CmdAppDeploy {
 
         wait_app(&client, opts.clone(), app_version.clone(), self.quiet).await?;
 
-        if self.fmt.format == crate::utils::render::ItemFormat::Json {
+        if self.fmt.format == Some(crate::utils::render::ItemFormat::Json) {
             println!("{}", serde_json::to_string_pretty(&app_version)?);
         }
 
diff --git a/lib/cli/src/commands/app/deployments/get.rs b/lib/cli/src/commands/app/deployments/get.rs
new file mode 100644
index 00000000000..07ccb4150ea
--- /dev/null
+++ b/lib/cli/src/commands/app/deployments/get.rs
@@ -0,0 +1,29 @@
+//! Get deployments for an app.
+
+use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts};
+
+/// Get the volumes of an app.
+#[derive(clap::Parser, Debug)]
+pub struct CmdAppDeploymentGet {
+    #[clap(flatten)]
+    fmt: ItemFormatOpts,
+
+    #[clap(flatten)]
+    env: WasmerEnv,
+
+    /// ID of the deployment.
+    id: String,
+}
+
+#[async_trait::async_trait]
+impl AsyncCliCommand for CmdAppDeploymentGet {
+    type Output = ();
+
+    async fn run_async(mut self) -> Result<(), anyhow::Error> {
+        let client = self.env.client()?;
+        let item = wasmer_api::query::app_deployment(&client, self.id).await?;
+
+        println!("{}", self.fmt.get().render(&item));
+        Ok(())
+    }
+}
diff --git a/lib/cli/src/commands/app/deployments/list.rs b/lib/cli/src/commands/app/deployments/list.rs
new file mode 100644
index 00000000000..e8e118568c7
--- /dev/null
+++ b/lib/cli/src/commands/app/deployments/list.rs
@@ -0,0 +1,50 @@
+//! List volumes tied to an edge app.
+
+use super::super::util::AppIdentOpts;
+use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ListFormatOpts};
+
+/// List the volumes of an app.
+#[derive(clap::Parser, Debug)]
+pub struct CmdAppDeploymentList {
+    #[clap(flatten)]
+    fmt: ListFormatOpts,
+
+    #[clap(flatten)]
+    env: WasmerEnv,
+
+    #[clap(flatten)]
+    ident: AppIdentOpts,
+
+    #[clap(long)]
+    offset: Option<u32>,
+
+    #[clap(long)]
+    limit: Option<u32>,
+}
+
+#[async_trait::async_trait]
+impl AsyncCliCommand for CmdAppDeploymentList {
+    type Output = ();
+
+    async fn run_async(self) -> Result<(), anyhow::Error> {
+        let client = self.env.client()?;
+
+        let (_ident, app) = self.ident.load_app(&client).await?;
+        let vars = wasmer_api::types::GetAppDeploymentsVariables {
+            after: None,
+            first: self.limit.map(|x| x as i32),
+            name: app.name.clone(),
+            offset: self.offset.map(|x| x as i32),
+            owner: app.owner.global_name,
+        };
+        let items = wasmer_api::query::app_deployments(&client, vars).await?;
+
+        if items.is_empty() {
+            eprintln!("App {} has no deployments!", app.name);
+        } else {
+            println!("{}", self.fmt.format.render(&items));
+        }
+
+        Ok(())
+    }
+}
diff --git a/lib/cli/src/commands/app/deployments/logs.rs b/lib/cli/src/commands/app/deployments/logs.rs
new file mode 100644
index 00000000000..426cb3b71a9
--- /dev/null
+++ b/lib/cli/src/commands/app/deployments/logs.rs
@@ -0,0 +1,51 @@
+//! Get logs for an app deployment.
+
+use std::io::Write;
+
+use anyhow::Context;
+use futures::stream::TryStreamExt;
+
+use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts};
+
+/// Get logs for an app deployment.
+#[derive(clap::Parser, Debug)]
+pub struct CmdAppDeploymentLogs {
+    #[clap(flatten)]
+    fmt: ItemFormatOpts,
+
+    #[clap(flatten)]
+    env: WasmerEnv,
+
+    /// ID of the deployment.
+    id: String,
+}
+
+#[async_trait::async_trait]
+impl AsyncCliCommand for CmdAppDeploymentLogs {
+    type Output = ();
+
+    async fn run_async(mut self) -> Result<(), anyhow::Error> {
+        let client = self.env.client()?;
+        let item = wasmer_api::query::app_deployment(&client, self.id).await?;
+
+        let url = item
+            .log_url
+            .context("This deployment does not have logs available")?;
+
+        let mut writer = std::io::BufWriter::new(std::io::stdout());
+
+        let mut stream = reqwest::Client::new()
+            .get(url)
+            .send()
+            .await?
+            .error_for_status()?
+            .bytes_stream();
+
+        while let Some(chunk) = stream.try_next().await? {
+            writer.write_all(&chunk)?;
+            writer.flush()?;
+        }
+
+        Ok(())
+    }
+}
diff --git a/lib/cli/src/commands/app/deployments/mod.rs b/lib/cli/src/commands/app/deployments/mod.rs
new file mode 100644
index 00000000000..eeb8b7f651c
--- /dev/null
+++ b/lib/cli/src/commands/app/deployments/mod.rs
@@ -0,0 +1,26 @@
+use crate::commands::AsyncCliCommand;
+
+pub mod get;
+pub mod list;
+pub mod logs;
+
+/// App volume management.
+#[derive(Debug, clap::Parser)]
+pub enum CmdAppDeployment {
+    List(list::CmdAppDeploymentList),
+    Get(get::CmdAppDeploymentGet),
+    Logs(logs::CmdAppDeploymentLogs),
+}
+
+#[async_trait::async_trait]
+impl AsyncCliCommand for CmdAppDeployment {
+    type Output = ();
+
+    async fn run_async(self) -> Result<Self::Output, anyhow::Error> {
+        match self {
+            Self::List(c) => c.run_async().await,
+            Self::Get(c) => c.run_async().await,
+            Self::Logs(c) => c.run_async().await,
+        }
+    }
+}
diff --git a/lib/cli/src/commands/app/get.rs b/lib/cli/src/commands/app/get.rs
index 73b4d9de94f..12143139a31 100644
--- a/lib/cli/src/commands/app/get.rs
+++ b/lib/cli/src/commands/app/get.rs
@@ -4,7 +4,9 @@ use wasmer_api::types::DeployApp;
 
 use super::util::AppIdentOpts;
 
-use crate::{commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts};
+use crate::{
+    commands::AsyncCliCommand, config::WasmerEnv, opts::ItemFormatOpts, utils::render::ItemFormat,
+};
 
 /// Retrieve detailed informations about an app
 #[derive(clap::Parser, Debug)]
@@ -27,7 +29,10 @@ impl AsyncCliCommand for CmdAppGet {
         let client = self.env.client()?;
         let (_ident, app) = self.ident.load_app(&client).await?;
 
-        println!("{}", self.fmt.format.render(&app));
+        println!(
+            "{}",
+            self.fmt.get_with_default(ItemFormat::Yaml).render(&app)
+        );
 
         Ok(app)
     }
diff --git a/lib/cli/src/commands/app/mod.rs b/lib/cli/src/commands/app/mod.rs
index 7a461a06c83..0267c2edfa5 100644
--- a/lib/cli/src/commands/app/mod.rs
+++ b/lib/cli/src/commands/app/mod.rs
@@ -3,6 +3,7 @@
 pub mod create;
 pub mod delete;
 pub mod deploy;
+mod deployments;
 pub mod get;
 pub mod info;
 pub mod list;
@@ -36,6 +37,8 @@ pub enum CmdApp {
     Region(regions::CmdAppRegions),
     #[clap(subcommand, alias = "volumes")]
     Volume(volumes::CmdAppVolumes),
+    #[clap(subcommand, alias = "deployments")]
+    Deployment(deployments::CmdAppDeployment),
 }
 
 #[async_trait::async_trait]
@@ -65,6 +68,7 @@ impl AsyncCliCommand for CmdApp {
             Self::Secret(cmd) => cmd.run_async().await,
             Self::Region(cmd) => cmd.run_async().await,
             Self::Volume(cmd) => cmd.run_async().await,
+            Self::Deployment(cmd) => cmd.run_async().await,
         }
     }
 }
diff --git a/lib/cli/src/commands/app/version/get.rs b/lib/cli/src/commands/app/version/get.rs
index 83d80edffa2..aa199644c10 100644
--- a/lib/cli/src/commands/app/version/get.rs
+++ b/lib/cli/src/commands/app/version/get.rs
@@ -4,6 +4,7 @@ use crate::{
     commands::{app::util::AppIdentOpts, AsyncCliCommand},
     config::WasmerEnv,
     opts::ItemFormatOpts,
+    utils::render::ItemFormat,
 };
 
 /// Show information for a specific app version.
@@ -41,7 +42,10 @@ impl AsyncCliCommand for CmdAppVersionGet {
         .await?
         .with_context(|| format!("Could not find app version '{}'", self.name))?;
 
-        println!("{}", self.fmt.format.render(&version));
+        println!(
+            "{}",
+            self.fmt.get_with_default(ItemFormat::Yaml).render(&version)
+        );
 
         Ok(())
     }
diff --git a/lib/cli/src/commands/namespace/create.rs b/lib/cli/src/commands/namespace/create.rs
index adc6b3017f4..4770eaaf381 100644
--- a/lib/cli/src/commands/namespace/create.rs
+++ b/lib/cli/src/commands/namespace/create.rs
@@ -30,7 +30,7 @@ impl AsyncCliCommand for CmdNamespaceCreate {
         };
         let namespace = wasmer_api::query::create_namespace(&client, vars).await?;
 
-        println!("{}", self.fmt.format.render(&namespace));
+        println!("{}", self.fmt.get().render(&namespace));
 
         Ok(())
     }
diff --git a/lib/cli/src/commands/namespace/get.rs b/lib/cli/src/commands/namespace/get.rs
index 48138de8309..9ca3a0cfcc7 100644
--- a/lib/cli/src/commands/namespace/get.rs
+++ b/lib/cli/src/commands/namespace/get.rs
@@ -26,7 +26,7 @@ impl AsyncCliCommand for CmdNamespaceGet {
             .await?
             .context("namespace not found")?;
 
-        println!("{}", self.fmt.format.render(&namespace));
+        println!("{}", self.fmt.get().render(&namespace));
 
         Ok(())
     }
diff --git a/lib/cli/src/opts.rs b/lib/cli/src/opts.rs
index cc9844a2197..8b0a983c085 100644
--- a/lib/cli/src/opts.rs
+++ b/lib/cli/src/opts.rs
@@ -1,9 +1,30 @@
+use crate::utils::render::ItemFormat;
+
 /// Formatting options for a single item.
 #[derive(clap::Parser, Debug, Default)]
 pub struct ItemFormatOpts {
     /// Output format. (yaml, json, table)
-    #[clap(short = 'f', long, default_value = "yaml")]
-    pub format: crate::utils::render::ItemFormat,
+    ///
+    /// This value is optional instead of using a default value to allow code
+    /// to distinguish between the user not specifying a value and a generic
+    /// default.
+    ///
+    /// Code should usually use [`Self::get`] to use the same default format.
+    #[clap(short = 'f', long)]
+    pub format: Option<ItemFormat>,
+}
+
+impl ItemFormatOpts {
+    /// Get the output format, defaulting to `ItemFormat::Yaml`.
+    pub fn get(&self) -> crate::utils::render::ItemFormat {
+        self.format
+            .unwrap_or(crate::utils::render::ItemFormat::Table)
+    }
+
+    /// Get the output format, defaulting to the given value if not specified.
+    pub fn get_with_default(&self, default: ItemFormat) -> crate::utils::render::ItemFormat {
+        self.format.unwrap_or(default)
+    }
 }
 
 /// Formatting options for a single item.
diff --git a/lib/cli/src/types.rs b/lib/cli/src/types.rs
index 0b391d036d1..4e8046ddea0 100644
--- a/lib/cli/src/types.rs
+++ b/lib/cli/src/types.rs
@@ -196,3 +196,32 @@ fn format_disk_size_opt(value: Option<wasmer_api::types::BigInt>) -> String {
         "n/a".to_string()
     }
 }
+
+impl CliRender for wasmer_api::types::AutobuildRepository {
+    fn render_item_table(&self) -> String {
+        let mut table = Table::new();
+        table.add_rows([
+            vec!["Id".to_string(), self.id.clone().into_inner()],
+            vec!["Status".to_string(), self.status.as_str().to_string()],
+            vec!["Created at".to_string(), self.created_at.0.clone()],
+        ]);
+        table.to_string()
+    }
+
+    fn render_list_table(items: &[Self]) -> String {
+        let mut table = Table::new();
+        table.set_header(vec![
+            "Id".to_string(),
+            "Status".to_string(),
+            "Created at".to_string(),
+        ]);
+        table.add_rows(items.iter().map(|item| {
+            vec![
+                item.id.clone().into_inner(),
+                item.status.as_str().to_string(),
+                item.created_at.0.clone(),
+            ]
+        }));
+        table.to_string()
+    }
+}


Test patch: