From af62be0ef4c3bc63be131f0330b83e100929dd1c Mon Sep 17 00:00:00 2001
From: trilero <trile@riseup.net>
Date: Sun, 14 Apr 2024 02:52:30 +0200
Subject: [PATCH] [TCli] ShellExecutor is dirty, but quite some progress on
 obtaining a step by step install/update

+ Hides progress from inner commands, specially docker's case
+ Ok/Error full on semantics, thanks CE.
+ Cli code shows fatigue and clean up is coming one way or another, still, showing good progress
---
 ...FederationMemberUpdateInputVariables.scala |  6 +-
 .../InstallFederationControllerCommand.scala  | 27 ++++--
 .../InstallFederationMemberCommand.scala      | 32 +++++--
 .../program/commands/UpdateAllCommand.scala   | 35 +++++---
 .../UpdateFederationControllerCommand.scala   | 34 ++++---
 .../UpdateFederationMemberCommand.scala       | 37 +++++---
 .../FederationControllerService.scala         |  3 +
 .../InstallControllerCommandHandler.scala     | 75 ++++++++--------
 .../InstallMemberCommandHandler.scala         | 25 +++---
 .../UpdateControllerCommandHandler.scala      | 11 +--
 .../UpdateFederationCommandHandler.scala      | 40 +++++----
 .../update/UpdateMemberCommandHandler.scala   | 12 +--
 .../acab/devcon0/services/shell/Bash.scala    | 19 ++--
 .../acab/devcon0/services/shell/ChmodX.scala  | 21 +++--
 .../services/shell/DockerComposeUp.scala      | 22 +++--
 .../acab/devcon0/services/shell/EnvSust.scala | 18 +++-
 .../services/shell/IpfsGenerateSwarmKey.scala | 20 +++--
 .../acab/devcon0/services/shell/MkdirP.scala  | 21 +++--
 .../services/shell/ShellExecutor.scala        | 89 +++++++++++++++++++
 .../acab/devcon0/services/shell/SudoRm.scala  | 26 ++++--
 20 files changed, 381 insertions(+), 192 deletions(-)
 rename cli/src/main/scala/acab/devcon0/services/command/{installer => install}/InstallControllerCommandHandler.scala (83%)
 rename cli/src/main/scala/acab/devcon0/services/command/{installer => install}/InstallMemberCommandHandler.scala (81%)
 create mode 100644 cli/src/main/scala/acab/devcon0/services/shell/ShellExecutor.scala

diff --git a/cli/src/main/scala/acab/devcon0/dtos/FederationMemberUpdateInputVariables.scala b/cli/src/main/scala/acab/devcon0/dtos/FederationMemberUpdateInputVariables.scala
index 9c5f287..7580c21 100644
--- a/cli/src/main/scala/acab/devcon0/dtos/FederationMemberUpdateInputVariables.scala
+++ b/cli/src/main/scala/acab/devcon0/dtos/FederationMemberUpdateInputVariables.scala
@@ -1,7 +1,3 @@
-package acab.devcon0.dtosFederationMemberUpdateInputVariables
-
-import acab.devcon0.dtos.TrileCli.Environment
-
-import java.net.InetAddress
+package acab.devcon0.dtos
 
 case class FederationMemberUpdateInputVariables(federationName: String, nickname: String)
diff --git a/cli/src/main/scala/acab/devcon0/program/commands/InstallFederationControllerCommand.scala b/cli/src/main/scala/acab/devcon0/program/commands/InstallFederationControllerCommand.scala
index e4ffd99..625e993 100644
--- a/cli/src/main/scala/acab/devcon0/program/commands/InstallFederationControllerCommand.scala
+++ b/cli/src/main/scala/acab/devcon0/program/commands/InstallFederationControllerCommand.scala
@@ -3,9 +3,14 @@ package acab.devcon0.program.commands
 import acab.devcon0.dtos.FederationControllerInstallInputVariables
 import acab.devcon0.dtos.TrileCli.Environment
 import acab.devcon0.dtos.TrileCli.Environment.*
+import acab.devcon0.program.commands.InstallFederationMemberCommand.runtime
 import acab.devcon0.program.opts.{TrileOptsFederation, TrileOptsFederationController}
-import acab.devcon0.services.command.installer.InstallControllerCommandHandler
+import acab.devcon0.services.command.install.{InstallControllerCommandHandler, InstallMemberCommandHandler}
+import acab.devcon0.services.shell.ShellExecutor.attemptTapPrintProgressFinal
 import acab.devcon0.services.shell.{Clear, PrintLn}
+import cats.effect.IO
+import cats.effect.std.Supervisor
+import cats.effect.unsafe.IORuntime
 import cats.implicits.*
 import com.monovore.decline.*
 
@@ -13,6 +18,8 @@ import scala.io.StdIn
 
 object InstallFederationControllerCommand {
 
+  private implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global
+
   def apply(): Command[Unit] =
     Command(name = "controller", header = "Run the installation for a controller.") {
       (
@@ -44,7 +51,7 @@ object InstallFederationControllerCommand {
               environment,
               federationName,
               networkAddress,
-              generateSSL = certbotFolderAbsolutePathOptional.isEmpty,
+              generateSSL = environment.equals(Environment.Production) && certbotFolderAbsolutePathOptional.isEmpty,
               stagingSSL = if sslStaging then "1" else "0",
               certbotFolderAbsolutePathOptional = certbotFolderAbsolutePathOptional,
               ipfsClusterReplicaFactorMax,
@@ -65,11 +72,17 @@ object InstallFederationControllerCommand {
   private def triggerInstallation(
       federationControllerInstallInputVariables: FederationControllerInstallInputVariables
   ): Unit = {
-    println("")
-    println("Creating the configuration & boot the containers, good luck!")
-    InstallControllerCommandHandler(federationControllerInstallInputVariables)
-    println("")
-    PrintLn.green("Installation completed. Enjoy.")
+    Supervisor[IO](await = true)
+      .use(supervisor => {
+        for
+          _ <- IO(println("Up to install the controller, good luck!"))
+          _ <- IO(println())
+          _ <- InstallControllerCommandHandler(federationControllerInstallInputVariables)
+        yield ()
+      })
+      .attemptTap(attemptTapPrintProgressFinal("Controller installation", _))
+      .unsafeToFuture()(runtime)
+      .wait()
   }
 
   private def printInputVariables(inputVariables: FederationControllerInstallInputVariables): Unit = {
diff --git a/cli/src/main/scala/acab/devcon0/program/commands/InstallFederationMemberCommand.scala b/cli/src/main/scala/acab/devcon0/program/commands/InstallFederationMemberCommand.scala
index 2a1f82b..3d8729c 100644
--- a/cli/src/main/scala/acab/devcon0/program/commands/InstallFederationMemberCommand.scala
+++ b/cli/src/main/scala/acab/devcon0/program/commands/InstallFederationMemberCommand.scala
@@ -4,10 +4,16 @@ import acab.devcon0.dtos.FederationMemberInstallInputVariables
 import acab.devcon0.dtos.TrileCli.Environment
 import acab.devcon0.dtos.TrileCli.Environment.*
 import acab.devcon0.dtos.TrileCli.EnvironmentVariables.*
+import acab.devcon0.program.commands.UpdateAllCommand.runtime
 import acab.devcon0.program.opts.{TrileOptsFederation, TrileOptsFederationMember}
 import acab.devcon0.services.EnvVarsFileLoader
-import acab.devcon0.services.command.installer.InstallMemberCommandHandler
+import acab.devcon0.services.command.install.InstallMemberCommandHandler
+import acab.devcon0.services.command.update.UpdateFederationCommandHandler
+import acab.devcon0.services.shell.ShellExecutor.attemptTapPrintProgressFinal
 import acab.devcon0.services.shell.{Clear, PrintLn}
+import cats.effect.IO
+import cats.effect.std.Supervisor
+import cats.effect.unsafe.IORuntime
 import cats.implicits.*
 import com.monovore.decline.*
 
@@ -16,6 +22,8 @@ import scala.util.Try
 
 object InstallFederationMemberCommand {
 
+  private implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global
+
   def apply(): Command[Unit] =
     Command(name = "member", header = "Run the installation for a member.") {
       (
@@ -66,14 +74,20 @@ object InstallFederationMemberCommand {
       fedvarsMap: TrileFederationEnvVars,
       federationMemberInstallInputVariables: FederationMemberInstallInputVariables
   ): Unit = {
-    println("")
-    println("Create the configuration & boot the containers, good luck!")
-    InstallMemberCommandHandler(
-      inputVariables = federationMemberInstallInputVariables,
-      fedvarsAsMap = fedvarsMap
-    )
-    println("")
-    PrintLn.green("Installation completed. Enjoy.")
+    Supervisor[IO](await = true)
+      .use(supervisor => {
+        for
+          _ <- IO(println("Up to install the member, good luck!"))
+          _ <- IO(println())
+          _ <- InstallMemberCommandHandler(
+            inputVariables = federationMemberInstallInputVariables,
+            fedvarsAsMap = fedvarsMap
+          )
+        yield ()
+      })
+      .attemptTap(attemptTapPrintProgressFinal("Member installation", _))
+      .unsafeToFuture()(runtime)
+      .wait()
   }
 
   private def printInputVariables(inputVariables: FederationMemberInstallInputVariables): Unit = {
diff --git a/cli/src/main/scala/acab/devcon0/program/commands/UpdateAllCommand.scala b/cli/src/main/scala/acab/devcon0/program/commands/UpdateAllCommand.scala
index 03509a3..46ba344 100644
--- a/cli/src/main/scala/acab/devcon0/program/commands/UpdateAllCommand.scala
+++ b/cli/src/main/scala/acab/devcon0/program/commands/UpdateAllCommand.scala
@@ -2,15 +2,21 @@ package acab.devcon0.program.commands
 
 import acab.devcon0.dtos.FederationUpdateInputVariables
 import acab.devcon0.program.opts.{TrileOptsFederation, TrileOptsFederationController, TrileOptsFederationMember}
-import acab.devcon0.services.command.installer.UpdateFederationCommandHandler
-import acab.devcon0.services.shell.{Clear, PrintLn}
-import cats.implicits.catsSyntaxTuple2Semigroupal
+import acab.devcon0.services.command.update.UpdateFederationCommandHandler
+import acab.devcon0.services.shell.ShellExecutor.attemptTapPrintProgressFinal
+import acab.devcon0.services.shell.{Clear, PrintLn, ShellExecutor}
+import cats.effect.IO
+import cats.effect.std.Supervisor
+import cats.effect.unsafe.IORuntime
+import cats.implicits.{catsSyntaxMonadError, catsSyntaxTuple2Semigroupal}
 import com.monovore.decline.*
 
 import scala.io.StdIn
 
 object UpdateAllCommand {
 
+  private implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global
+
   def apply(): Command[Unit] =
     Command(name = "all", header = "Updates controllers & members present in the machine.") {
       (
@@ -37,12 +43,18 @@ object UpdateAllCommand {
     val inputVariables: FederationUpdateInputVariables = FederationUpdateInputVariables(
       federationName = federationName
     )
-    printInputVariables(inputVariables)
-    println("")
-    println("Up to update the controller and every member, good luck!")
-    UpdateFederationCommandHandler(inputVariables)
-    println("")
-    PrintLn.green("Update completed. Enjoy.")
+
+    Supervisor[IO](await = true)
+      .use(supervisor => {
+        for
+          _ <- IO(println("Up to update the controller and every member, good luck!"))
+          _ <- IO(println())
+          _ <- UpdateFederationCommandHandler(inputVariables)
+        yield ()
+      })
+      .attemptTap(attemptTapPrintProgressFinal("Federation update", _))
+      .unsafeToFuture()(runtime)
+      .wait()
   }
 
   private def printInputVariables(inputVariables: FederationUpdateInputVariables): Unit = {
@@ -55,10 +67,7 @@ object UpdateAllCommand {
   private def greet(): Unit = {
     Clear()
     PrintLn.cyan(s"Welcome to trile! You are up to update a federation.")
-    PrintLn.cyan(
-      s"You can pass your values as arguments. use 'update controller all --help' to see the list. " +
-        s"In order to complete the installation, all values need to be provided."
-    )
+    PrintLn.cyan("Check 'update controller --help' to see the list of options.")
   }
 
   private def readConfirmation(): Boolean = {
diff --git a/cli/src/main/scala/acab/devcon0/program/commands/UpdateFederationControllerCommand.scala b/cli/src/main/scala/acab/devcon0/program/commands/UpdateFederationControllerCommand.scala
index 5a2aa41..58c765c 100644
--- a/cli/src/main/scala/acab/devcon0/program/commands/UpdateFederationControllerCommand.scala
+++ b/cli/src/main/scala/acab/devcon0/program/commands/UpdateFederationControllerCommand.scala
@@ -1,16 +1,21 @@
 package acab.devcon0.program.commands
 
 import acab.devcon0.dtos.FederationControllerUpdateInputVariables
-import acab.devcon0.program.opts.{TrileOptsFederation, TrileOptsFederationController, TrileOptsFederationMember}
-import acab.devcon0.services.command.installer.UpdateControllerCommandHandler
-import acab.devcon0.services.shell.{Clear, PrintLn}
-import cats.implicits.catsSyntaxTuple2Semigroupal
+import acab.devcon0.program.opts.TrileOptsFederation
+import acab.devcon0.services.command.update.UpdateControllerCommandHandler
+import acab.devcon0.services.shell.{Clear, PrintLn, ShellExecutor}
+import cats.effect.IO
+import cats.effect.std.Supervisor
+import cats.effect.unsafe.IORuntime
+import cats.implicits.{catsSyntaxMonadError, catsSyntaxTuple2Semigroupal}
 import com.monovore.decline.*
 
 import scala.io.StdIn
 
 object UpdateFederationControllerCommand {
 
+  private implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global
+
   def apply(): Command[Unit] =
     Command(name = "controller", header = "Updates a controller installation.") {
       (
@@ -39,11 +44,17 @@ object UpdateFederationControllerCommand {
   }
 
   private def triggerUpdate(inputVariables: FederationControllerUpdateInputVariables): Unit = {
-    println("")
-    println("Updating the configuration & boot the containers, good luck!")
-    UpdateControllerCommandHandler(inputVariables)
-    println("")
-    PrintLn.green("Update completed. Enjoy.")
+    Supervisor[IO](await = true)
+      .use(supervisor => {
+        for
+          _ <- IO(println("Update is up to start. Good luck!"))
+          _ <- IO(println())
+          _ <- UpdateControllerCommandHandler(inputVariables)
+        yield ()
+      })
+      .attemptTap(ShellExecutor.attemptTapPrintProgressFinal("Controller update", _))
+      .unsafeToFuture()(runtime)
+      .wait()
   }
 
   private def printInputVariables(inputVariables: FederationControllerUpdateInputVariables): Unit = {
@@ -56,10 +67,7 @@ object UpdateFederationControllerCommand {
   private def greet(): Unit = {
     Clear()
     PrintLn.cyan(s"Welcome to trile! You are up to update a federation controller.")
-    PrintLn.cyan(
-      s"You can pass your values as arguments. use 'update controller --help' to see the list. " +
-        s"In order to complete the installation, all values need to be provided."
-    )
+    PrintLn.cyan("Check 'update controller --help' to see the list of options.")
   }
 
   private def readConfirmation(): Boolean = {
diff --git a/cli/src/main/scala/acab/devcon0/program/commands/UpdateFederationMemberCommand.scala b/cli/src/main/scala/acab/devcon0/program/commands/UpdateFederationMemberCommand.scala
index cc755ed..09fa9a5 100644
--- a/cli/src/main/scala/acab/devcon0/program/commands/UpdateFederationMemberCommand.scala
+++ b/cli/src/main/scala/acab/devcon0/program/commands/UpdateFederationMemberCommand.scala
@@ -1,10 +1,12 @@
 package acab.devcon0.program.commands
 
-import acab.devcon0.dtosFederationMemberUpdateInputVariables.FederationMemberUpdateInputVariables
-import acab.devcon0.program.commands.UpdateFederationControllerCommand.{readConfirmation, triggerUpdate}
+import acab.devcon0.dtos.FederationMemberUpdateInputVariables
 import acab.devcon0.program.opts.{TrileOptsFederation, TrileOptsFederationMember}
-import acab.devcon0.services.command.installer.UpdateMemberCommandHandler
-import acab.devcon0.services.shell.{Clear, PrintLn}
+import acab.devcon0.services.command.update.UpdateMemberCommandHandler
+import acab.devcon0.services.shell.{Clear, PrintLn, ShellExecutor}
+import cats.effect.IO
+import cats.effect.std.Supervisor
+import cats.effect.unsafe.IORuntime
 import cats.implicits.catsSyntaxTuple3Semigroupal
 import com.monovore.decline.*
 
@@ -12,6 +14,8 @@ import scala.io.StdIn
 
 object UpdateFederationMemberCommand {
 
+  private implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global
+
   def apply(): Command[Unit] =
     Command(name = "member", header = "Updates a member installation.") {
       (
@@ -43,16 +47,24 @@ object UpdateFederationMemberCommand {
   }
 
   private def triggerUpdate(inputVariables: FederationMemberUpdateInputVariables): Unit = {
-    println("")
-    println("Updating the configuration & boot the containers, good luck!")
-    UpdateMemberCommandHandler(inputVariables)
-    println("")
-    PrintLn.green("Update completed. Enjoy.")
+    Supervisor[IO](await = true)
+      .use(supervisor => {
+        for
+          _ <- IO(println("Update is up to start. Good luck!"))
+          _ <- IO(println())
+          _ <- UpdateMemberCommandHandler(inputVariables)
+          _ <- IO(println())
+          _ <- ShellExecutor.printProgressOk("Update")
+        yield ()
+      })
+      .handleErrorWith(_ => ShellExecutor.printProgressError("Member update"))
+      .unsafeToFuture()(runtime)
+      .wait()
   }
 
   private def printInputVariables(inputVariables: FederationMemberUpdateInputVariables): Unit = {
     println()
-    println(s"These are you input values. Please review them: ")
+    println(s"These are you input values")
     println(s"- Federation name: ${inputVariables.federationName}")
     println(s"- Nickname: ${inputVariables.nickname}")
     println()
@@ -61,10 +73,7 @@ object UpdateFederationMemberCommand {
   private def greet(): Unit = {
     Clear()
     PrintLn.cyan(s"Welcome to trile! You are up to update a federation member.")
-    PrintLn.cyan(
-      s"You can pass your values as arguments. use 'update controller --help' to see the list. " +
-        s"In order to complete the installation, all values need to be provided."
-    )
+    PrintLn.cyan("Check 'update controller --help' to see the list of options.")
   }
 
   private def readConfirmation(): Boolean = {
diff --git a/cli/src/main/scala/acab/devcon0/services/FederationControllerService.scala b/cli/src/main/scala/acab/devcon0/services/FederationControllerService.scala
index 00e005a..74d2072 100644
--- a/cli/src/main/scala/acab/devcon0/services/FederationControllerService.scala
+++ b/cli/src/main/scala/acab/devcon0/services/FederationControllerService.scala
@@ -3,7 +3,9 @@ package acab.devcon0.services
 import acab.devcon0.dtos.TrileCli
 import acab.devcon0.dtos.TrileCli.Environment
 import acab.devcon0.dtos.aliases.P2pPeerId
+import acab.devcon0.services.shell.ShellExecutor
 import cats.effect.IO
+import cats.implicits.catsSyntaxMonadError
 import sttp.client4.curl.CurlBackend
 import sttp.client4.{SyncBackend, UriContext, quickRequest}
 import sttp.model.{StatusCode, Uri}
@@ -17,6 +19,7 @@ object FederationControllerService {
   def getPeerId(environment: Environment, networkAddress: P2pPeerId): IO[P2pPeerId] = {
     val uri: Uri = getUri(environment, networkAddress)
     getP2pPeerIdInner(uri, 300)
+      .attemptTap(ShellExecutor.attemptTapPrintProgressFinal("Fetching P2P peer id", _))
   }
 
   private def getUri(environment: Environment, networkAddress: P2pPeerId): Uri = {
diff --git a/cli/src/main/scala/acab/devcon0/services/command/installer/InstallControllerCommandHandler.scala b/cli/src/main/scala/acab/devcon0/services/command/install/InstallControllerCommandHandler.scala
similarity index 83%
rename from cli/src/main/scala/acab/devcon0/services/command/installer/InstallControllerCommandHandler.scala
rename to cli/src/main/scala/acab/devcon0/services/command/install/InstallControllerCommandHandler.scala
index 72af480..1b0e0f9 100644
--- a/cli/src/main/scala/acab/devcon0/services/command/installer/InstallControllerCommandHandler.scala
+++ b/cli/src/main/scala/acab/devcon0/services/command/install/InstallControllerCommandHandler.scala
@@ -1,4 +1,4 @@
-package acab.devcon0.services.command.installer
+package acab.devcon0.services.command.install
 
 import acab.devcon0.dtos.*
 import acab.devcon0.dtos.TrileCli.Component.*
@@ -8,7 +8,7 @@ import acab.devcon0.dtos.TrileCli.{Component, Defaults, Environment, File}
 import acab.devcon0.services.shell.*
 import acab.devcon0.services.{FederationControllerEnvironmentVariables, FederationControllerService}
 import acab.devcon0.{files, services}
-import cats.effect.unsafe.IORuntime
+import cats.effect.IO
 import ujson.Value.Value
 
 import java.nio.charset.StandardCharsets
@@ -17,29 +17,30 @@ import scala.io.Source
 
 object InstallControllerCommandHandler {
 
-  private implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global
-
-  def apply(inputVariables: FederationControllerInstallInputVariables): Unit = {
+  def apply(inputVariables: FederationControllerInstallInputVariables): IO[Unit] = {
     val environmentVariables: TrileFederationEnvVars = FederationControllerEnvironmentVariables(inputVariables)
     val configurationFolderAbsolutePath              = getConfigurationFolderAbsolutePath(environmentVariables)
     val dockerComposeAbsolutePath                    = getDockerComposeAbsolutePath(configurationFolderAbsolutePath)
 
-    generateConfigurationFiles(inputVariables, environmentVariables)
-    DockerComposeUp(dockerComposeAbsolutePath)
-    if inputVariables.generateSSL then generateSslCertificate(environmentVariables)
-    generateConfigurationFilesPostSsl(inputVariables, environmentVariables)
-    DockerComposeUp(dockerComposeAbsolutePath)
-    generateFedVars(inputVariables, environmentVariables)
-    generateInstallationEnvVars(environmentVariables)
+    generateConfigurationFiles(inputVariables, environmentVariables) >>
+      DockerComposeUp(dockerComposeAbsolutePath) >>
+      IO.unit.flatMap(_ =>
+        if inputVariables.generateSSL then generateSslCertificate(environmentVariables)
+        else IO.unit
+      ) >>
+      generateConfigurationFilesPostSsl(inputVariables, environmentVariables) >>
+      DockerComposeUp(dockerComposeAbsolutePath) >>
+      generateFedVars(inputVariables, environmentVariables) >>
+      generateInstallationEnvVars(environmentVariables)
   }
 
   private def generateFedVars(
       inputVariables: FederationControllerInstallInputVariables,
       environmentVariablesMap: TrileFederationEnvVars
-  ): Unit = {
+  ): IO[Unit] = {
     val networkAddress: String   = environmentVariablesMap(TFC_NETWORK_ADDRESS)
     val environment: Environment = inputVariables.environment
-    (for peerId <- FederationControllerService.getPeerId(environment, networkAddress)
+    for peerId <- FederationControllerService.getPeerId(environment, networkAddress)
     yield {
       val configurationFolderAbsolutePath = getConfigurationFolderAbsolutePath(environmentVariablesMap)
       val fedvarsAbsolutePath             = s"$configurationFolderAbsolutePath/.fedvars"
@@ -59,10 +60,10 @@ object InstallControllerCommandHandler {
 
       val fedVarsContent: String = map.view.toList.map { case (key, value) => s"$key=$value" }.mkString("\n")
       Files.write(Paths.get(fedvarsAbsolutePath), fedVarsContent.getBytes(StandardCharsets.UTF_8))
-    }).unsafeToFuture()(runtime).wait()
+    }
   }
 
-  private def generateInstallationEnvVars(envVars: TrileFederationEnvVars): Unit = {
+  private def generateInstallationEnvVars(envVars: TrileFederationEnvVars): IO[Unit] = IO {
     val configurationFolderAbsolutePath = getConfigurationFolderAbsolutePath(envVars)
     val installationEnvVarsAbsolutePath = s"$configurationFolderAbsolutePath/.installationEnvVars"
 
@@ -117,7 +118,7 @@ object InstallControllerCommandHandler {
   private def generateConfigurationFiles(
       inputVariables: FederationControllerInstallInputVariables,
       envVars: TrileFederationEnvVars
-  ): Unit = {
+  ): IO[Unit] = {
     val environment                                = inputVariables.environment
     val configFolderAbsolutePath                   = getConfigurationFolderAbsolutePath(envVars)
     val ipfsConfigurationFolderAbsolutePath        = getIpfsConfigurationFolderAbsolutePath(envVars)
@@ -135,30 +136,30 @@ object InstallControllerCommandHandler {
     val nginxPreSslConfTemplateContent         = files.Loader.get(NginxPreSslConf, FederationController, environment)
     val sslCertificateGeneratorTemplateContent = files.Loader.get(SslCertificate, FederationController, environment)
 
-    SudoRm(configFolderAbsolutePath)
-    MkdirP(configFolderAbsolutePath)
-    MkdirP(certbotConfigurationFolderAbsolutePath)
-    MkdirP(nginxFolderAbsolutePath)
-    MkdirP(ipfsConfigurationFolderAbsolutePath)
-    MkdirP(ipfsClusterConfigurationFolderAbsolutePath)
-    IpfsGenerateSwarmKey(swarmKeyAbsolutePath)
-
-    val envVarsPreBoot = envVars
-      .updated(TFC_REVERSE_PROXY_CONFIGURATION, nginxPreSslConfigurationFileAbsolutePath)
-      .updated(TRILE_FEDERATION_IPFS_SWARM_KEY_VALUE, getIpfsSwarmKeyValue(envVars))
-
-    EnvSust(envVars, dockerComposeTemplateContent, dockerComposeAbsolutePath)
-    EnvSust(envVars, nginxConfTemplateContent, nginxConfigurationFileAbsolutePath)
-    EnvSust(envVars, nginxPreSslConfTemplateContent, nginxPreSslConfigurationFileAbsolutePath)
-    EnvSust(envVars, sslCertificateGeneratorTemplateContent, sslCertificateGeneratorFileAbsolutePath)
-    EnvSust(envVarsPreBoot, dockerComposeTemplateContent, dockerComposeAbsolutePath)
-    ChmodX(sslCertificateGeneratorFileAbsolutePath)
+    for
+      _ <- SudoRm(configFolderAbsolutePath)
+      _ <- MkdirP(configFolderAbsolutePath)
+      _ <- MkdirP(certbotConfigurationFolderAbsolutePath)
+      _ <- MkdirP(nginxFolderAbsolutePath)
+      _ <- MkdirP(ipfsConfigurationFolderAbsolutePath)
+      _ <- MkdirP(ipfsClusterConfigurationFolderAbsolutePath)
+      _ <- IpfsGenerateSwarmKey(swarmKeyAbsolutePath)
+      envVarsPreBoot = envVars
+        .updated(TFC_REVERSE_PROXY_CONFIGURATION, nginxPreSslConfigurationFileAbsolutePath)
+        .updated(TRILE_FEDERATION_IPFS_SWARM_KEY_VALUE, getIpfsSwarmKeyValue(envVars))
+      _ <- EnvSust(envVars, dockerComposeTemplateContent, dockerComposeAbsolutePath)
+      _ <- EnvSust(envVars, nginxConfTemplateContent, nginxConfigurationFileAbsolutePath)
+      _ <- EnvSust(envVars, nginxPreSslConfTemplateContent, nginxPreSslConfigurationFileAbsolutePath)
+      _ <- EnvSust(envVarsPreBoot, dockerComposeTemplateContent, dockerComposeAbsolutePath)
+      _ <- EnvSust(envVars, sslCertificateGeneratorTemplateContent, sslCertificateGeneratorFileAbsolutePath)
+      _ <- ChmodX(sslCertificateGeneratorFileAbsolutePath)
+    yield ()
   }
 
   private def generateConfigurationFilesPostSsl(
       inputVariables: FederationControllerInstallInputVariables,
       envVars: TrileFederationEnvVars
-  ): Unit = {
+  ): IO[Unit] = {
     val environment                     = inputVariables.environment
     val configurationFolderAbsolutePath = getConfigurationFolderAbsolutePath(envVars)
     val dockerComposeAbsolutePath       = getDockerComposeAbsolutePath(configurationFolderAbsolutePath)
@@ -168,7 +169,7 @@ object InstallControllerCommandHandler {
     EnvSust(envVarsPostSsl, dockerComposeTemplateContent, dockerComposeAbsolutePath)
   }
 
-  private def generateSslCertificate(envVars: TrileFederationEnvVars): Unit = {
+  private def generateSslCertificate(envVars: TrileFederationEnvVars): IO[Unit] = {
     val configurationFolderAbsolutePath         = getConfigurationFolderAbsolutePath(envVars)
     val sslCertificateGeneratorFileAbsolutePath = getSslCertificateAbsolutePath(configurationFolderAbsolutePath)
 
diff --git a/cli/src/main/scala/acab/devcon0/services/command/installer/InstallMemberCommandHandler.scala b/cli/src/main/scala/acab/devcon0/services/command/install/InstallMemberCommandHandler.scala
similarity index 81%
rename from cli/src/main/scala/acab/devcon0/services/command/installer/InstallMemberCommandHandler.scala
rename to cli/src/main/scala/acab/devcon0/services/command/install/InstallMemberCommandHandler.scala
index 81f8b2e..e9e55ed 100644
--- a/cli/src/main/scala/acab/devcon0/services/command/installer/InstallMemberCommandHandler.scala
+++ b/cli/src/main/scala/acab/devcon0/services/command/install/InstallMemberCommandHandler.scala
@@ -1,4 +1,4 @@
-package acab.devcon0.services.command.installer
+package acab.devcon0.services.command.install
 
 import acab.devcon0.dtos.TrileCli.Component.*
 import acab.devcon0.dtos.TrileCli.EnvironmentVariables.*
@@ -8,6 +8,7 @@ import acab.devcon0.dtos.{FederationMemberInstallInputVariables, TrileCli}
 import acab.devcon0.services.FederationMemberEnvironmentVariables
 import acab.devcon0.services.shell.{DockerComposeUp, EnvSust, MkdirP, SudoRm}
 import acab.devcon0.{files, services}
+import cats.effect.IO
 
 import java.nio.charset.StandardCharsets
 import java.nio.file.{Files, Paths}
@@ -17,7 +18,7 @@ object InstallMemberCommandHandler {
   def apply(
       inputVariables: FederationMemberInstallInputVariables,
       fedvarsAsMap: TrileFederationEnvVars
-  ): Unit = {
+  ): IO[Unit] = {
     val environmentVariables: TrileFederationEnvVars = FederationMemberEnvironmentVariables(
       inputVariables = inputVariables,
       fedvars = fedvarsAsMap
@@ -32,18 +33,18 @@ object InstallMemberCommandHandler {
     val swarmKeyTemplateContent                    = files.Loader.get(SwarmKey, FederationMember, environment)
     val dockerComposeTemplateContent               = files.Loader.get(DockerCompose, FederationMember, environment)
 
-    SudoRm(configurationFolderAbsolutePath)
-    MkdirP(configurationFolderAbsolutePath)
-    MkdirP(ipfsConfigurationFolderAbsolutePath)
-    MkdirP(ipfsClusterConfigurationFolderAbsolutePath)
-    MkdirP(federationMemberSharingFolder)
-    EnvSust(environmentVariables, swarmKeyTemplateContent, swarmKeyAbsolutePath)
-    EnvSust(environmentVariables, dockerComposeTemplateContent, dockerComposeAbsolutePath)
-    DockerComposeUp(dockerComposeAbsolutePath)
-    generateInstallationEnvVars(environmentVariables)
+    SudoRm(configurationFolderAbsolutePath) >>
+      MkdirP(configurationFolderAbsolutePath) >>
+      MkdirP(ipfsConfigurationFolderAbsolutePath) >>
+      MkdirP(ipfsClusterConfigurationFolderAbsolutePath) >>
+      MkdirP(federationMemberSharingFolder) >>
+      EnvSust(environmentVariables, swarmKeyTemplateContent, swarmKeyAbsolutePath) >>
+      EnvSust(environmentVariables, dockerComposeTemplateContent, dockerComposeAbsolutePath) >>
+      DockerComposeUp(dockerComposeAbsolutePath) >>
+      generateInstallationEnvVars(environmentVariables)
   }
 
-  private def generateInstallationEnvVars(envVars: TrileFederationEnvVars): Unit = {
+  private def generateInstallationEnvVars(envVars: TrileFederationEnvVars): IO[Unit] = IO {
     val configurationFolderAbsolutePath = getConfigurationFolderAbsolutePath(envVars)
     val installationEnvVarsAbsolutePath = s"$configurationFolderAbsolutePath/.installationEnvVars"
 
diff --git a/cli/src/main/scala/acab/devcon0/services/command/update/UpdateControllerCommandHandler.scala b/cli/src/main/scala/acab/devcon0/services/command/update/UpdateControllerCommandHandler.scala
index fa4ec14..a3d61e0 100644
--- a/cli/src/main/scala/acab/devcon0/services/command/update/UpdateControllerCommandHandler.scala
+++ b/cli/src/main/scala/acab/devcon0/services/command/update/UpdateControllerCommandHandler.scala
@@ -1,4 +1,4 @@
-package acab.devcon0.services.command.installer
+package acab.devcon0.services.command.update
 
 import acab.devcon0.dtos.*
 import acab.devcon0.dtos.TrileCli.Component.*
@@ -8,11 +8,12 @@ import acab.devcon0.dtos.TrileCli.{Component, Defaults, Environment, File}
 import acab.devcon0.services.{EnvVarsFileLoader, PathSanitizer}
 import acab.devcon0.services.shell.{DockerComposeUp, EnvSust}
 import acab.devcon0.{files, services}
+import cats.effect.IO
 import ujson.Value.Value
 
 object UpdateControllerCommandHandler {
 
-  def apply(inputVariables: FederationControllerUpdateInputVariables): Unit = {
+  def apply(inputVariables: FederationControllerUpdateInputVariables): IO[Unit] = {
     val userHomeAbsolutePath: String = PathSanitizer.expandHome(Defaults.userHomeAbsolutePath)
     val configurationPath: String    = s"$userHomeAbsolutePath/.config/trile"
     val installationPath: String     = s"$configurationPath/federations/${inputVariables.federationName}/controller"
@@ -21,11 +22,11 @@ object UpdateControllerCommandHandler {
     val finalEnvVars: TrileFederationEnvVars         = environmentVariables ++ fedVars
     val dockerComposeAbsolutePath                    = getDockerComposeAbsolutePath(installationPath)
 
-    updateConfigurationFiles(finalEnvVars, installationPath)
-    DockerComposeUp(dockerComposeAbsolutePath)
+    updateConfigurationFiles(finalEnvVars, installationPath) >>
+      DockerComposeUp(dockerComposeAbsolutePath)
   }
 
-  private def updateConfigurationFiles(envVars: TrileFederationEnvVars, installationPath: String): Unit = {
+  private def updateConfigurationFiles(envVars: TrileFederationEnvVars, installationPath: String): IO[Unit] = {
     val environment                  = Environment.valueOf(envVars(TRILE_ENVIRONMENT))
     val dockerComposeAbsolutePath    = getDockerComposeAbsolutePath(installationPath)
     val dockerComposeTemplateContent = files.Loader.get(DockerCompose, FederationController, environment)
diff --git a/cli/src/main/scala/acab/devcon0/services/command/update/UpdateFederationCommandHandler.scala b/cli/src/main/scala/acab/devcon0/services/command/update/UpdateFederationCommandHandler.scala
index 6dcb130..1799faf 100644
--- a/cli/src/main/scala/acab/devcon0/services/command/update/UpdateFederationCommandHandler.scala
+++ b/cli/src/main/scala/acab/devcon0/services/command/update/UpdateFederationCommandHandler.scala
@@ -1,13 +1,14 @@
-package acab.devcon0.services.command.installer
+package acab.devcon0.services.command.update
 
 import acab.devcon0.dtos.*
-import acab.devcon0.dtosFederationMemberUpdateInputVariables.FederationMemberUpdateInputVariables
 import acab.devcon0.services.FederationMemberNicknameLister
-import acab.devcon0.services.shell.PrintLn
+import acab.devcon0.services.shell.{PrintLn, ShellExecutor}
+import cats.Traverse.ops.toAllTraverseOps
+import cats.effect.IO
 
 object UpdateFederationCommandHandler {
 
-  def apply(inputVariables: FederationUpdateInputVariables): Unit = {
+  def apply(inputVariables: FederationUpdateInputVariables): IO[Unit] = {
 
     val federationName: String                  = inputVariables.federationName
     val federationMemberNicknames: List[String] = FederationMemberNicknameLister(federationName)
@@ -15,19 +16,24 @@ object UpdateFederationCommandHandler {
       federationName = federationName
     )
 
-    PrintLn.cyan("Updating the controller.")
-    UpdateControllerCommandHandler(controllerInputVariables)
-    PrintLn.green("Controller updated")
-
-    PrintLn.cyan(s"Updating the members ${federationMemberNicknames.mkString(" ")}")
-    federationMemberNicknames
-      .map(federationMemberNickname =>
-        FederationMemberUpdateInputVariables(
-          federationName = federationName,
-          nickname = federationMemberNickname
+    for
+      _ <- IO(PrintLn.cyan("Updating the controller."))
+      _ <- UpdateControllerCommandHandler(controllerInputVariables)
+      _ <- ShellExecutor.printProgressOk("Update controller")
+      _ <- IO(println())
+      _ <- federationMemberNicknames
+        .map(federationMemberNickname =>
+          FederationMemberUpdateInputVariables(
+            federationName = federationName,
+            nickname = federationMemberNickname
+          )
         )
-      )
-      .foreach(UpdateMemberCommandHandler(_))
-    PrintLn.green("Members updated")
+        .traverse(inputVariables => {
+          IO(PrintLn.cyan(s"Updating member ${inputVariables.nickname}")) >>
+            UpdateMemberCommandHandler(inputVariables) >>
+            ShellExecutor.printProgressOk(s"Update member ${inputVariables.nickname}") >>
+            IO(println())
+        })
+    yield ()
   }
 }
diff --git a/cli/src/main/scala/acab/devcon0/services/command/update/UpdateMemberCommandHandler.scala b/cli/src/main/scala/acab/devcon0/services/command/update/UpdateMemberCommandHandler.scala
index 9179d9a..e4dedb4 100644
--- a/cli/src/main/scala/acab/devcon0/services/command/update/UpdateMemberCommandHandler.scala
+++ b/cli/src/main/scala/acab/devcon0/services/command/update/UpdateMemberCommandHandler.scala
@@ -1,19 +1,19 @@
-package acab.devcon0.services.command.installer
+package acab.devcon0.services.command.update
 
 import acab.devcon0.dtos.*
 import acab.devcon0.dtos.TrileCli.Component.*
 import acab.devcon0.dtos.TrileCli.EnvironmentVariables.*
 import acab.devcon0.dtos.TrileCli.File.*
 import acab.devcon0.dtos.TrileCli.{Component, Defaults, Environment, File}
-import acab.devcon0.dtosFederationMemberUpdateInputVariables.FederationMemberUpdateInputVariables
 import acab.devcon0.services.{EnvVarsFileLoader, PathSanitizer}
 import acab.devcon0.services.shell.{DockerComposeUp, EnvSust}
 import acab.devcon0.{files, services}
+import cats.effect.IO
 import ujson.Value.Value
 
 object UpdateMemberCommandHandler {
 
-  def apply(inputVariables: FederationMemberUpdateInputVariables): Unit = {
+  def apply(inputVariables: FederationMemberUpdateInputVariables): IO[Unit] = {
     val userHomeAbsolutePath: String = PathSanitizer.expandHome(Defaults.userHomeAbsolutePath)
     val configurationPath: String    = s"$userHomeAbsolutePath/.config/trile"
     val federationName: String       = inputVariables.federationName
@@ -22,11 +22,11 @@ object UpdateMemberCommandHandler {
     val environmentVariables: TrileFederationEnvVars = EnvVarsFileLoader(installationPath + "/.installationEnvVars")
     val dockerComposeAbsolutePath: String            = getDockerComposeAbsolutePath(installationPath)
 
-    updateConfigurationFiles(environmentVariables, installationPath)
-    DockerComposeUp(dockerComposeAbsolutePath)
+    updateConfigurationFiles(environmentVariables, installationPath) >>
+      DockerComposeUp(dockerComposeAbsolutePath)
   }
 
-  private def updateConfigurationFiles(envVars: TrileFederationEnvVars, installationPath: String): Unit = {
+  private def updateConfigurationFiles(envVars: TrileFederationEnvVars, installationPath: String): IO[Unit] = {
     val environment                  = Environment.valueOf(envVars(TRILE_ENVIRONMENT))
     val dockerComposeAbsolutePath    = getDockerComposeAbsolutePath(installationPath)
     val dockerComposeTemplateContent = files.Loader.get(DockerCompose, FederationMember, environment)
diff --git a/cli/src/main/scala/acab/devcon0/services/shell/Bash.scala b/cli/src/main/scala/acab/devcon0/services/shell/Bash.scala
index 50dabd6..96624af 100644
--- a/cli/src/main/scala/acab/devcon0/services/shell/Bash.scala
+++ b/cli/src/main/scala/acab/devcon0/services/shell/Bash.scala
@@ -1,15 +1,16 @@
 package acab.devcon0.services.shell
 
-import scala.scalanative.libc.stdlib
-import scala.scalanative.unsafe.Zone
+import cats.effect.IO
 
 object Bash {
-  def apply(commandInput: String): Unit = {
-    Zone { implicit z =>
-      val commandStr = s"bash -c \"$commandInput\""
-      println(s"> $commandStr")
-      val command = scala.scalanative.unsafe.toCString(commandStr)
-      stdlib.system(command)
-    }
+  private val cmdLabel: String = "Executing shell command"
+
+  def apply(commandInput: String): IO[Unit] = {
+    val cmd: String = getCmd(commandInput)
+    ShellExecutor.withoutProgress(cmd, s"$cmdLabel: $commandInput", commandInput.length)
+  }
+
+  private def getCmd(commandInput: String): String = {
+    s"bash -c \"$commandInput\""
   }
 }
diff --git a/cli/src/main/scala/acab/devcon0/services/shell/ChmodX.scala b/cli/src/main/scala/acab/devcon0/services/shell/ChmodX.scala
index 907203d..8dc1bd5 100644
--- a/cli/src/main/scala/acab/devcon0/services/shell/ChmodX.scala
+++ b/cli/src/main/scala/acab/devcon0/services/shell/ChmodX.scala
@@ -1,15 +1,18 @@
 package acab.devcon0.services.shell
 
-import scala.scalanative.libc.stdlib
-import scala.scalanative.unsafe.Zone
+import cats.effect.IO
 
 object ChmodX {
-  def apply(path: String): Unit = {
-    Zone { implicit z =>
-      val commandStr = s"chmod +x $path"
-      println(s"> $commandStr")
-      val command = scala.scalanative.unsafe.toCString(commandStr)
-      stdlib.system(command)
-    }
+
+  private val cmdLabel: String = "Ensuring permissions"
+
+  def apply(path: String): IO[Unit] = {
+    val cmd: String = getCmd(path)
+    val label       = s"$cmdLabel: ${path.split('/').lastOption.getOrElse(path)}"
+    ShellExecutor.withoutProgress(cmd, label, label.length)
+  }
+
+  private def getCmd(path: String): String = {
+    s"chmod +x $path"
   }
 }
diff --git a/cli/src/main/scala/acab/devcon0/services/shell/DockerComposeUp.scala b/cli/src/main/scala/acab/devcon0/services/shell/DockerComposeUp.scala
index b5f07ed..1beaa40 100644
--- a/cli/src/main/scala/acab/devcon0/services/shell/DockerComposeUp.scala
+++ b/cli/src/main/scala/acab/devcon0/services/shell/DockerComposeUp.scala
@@ -1,15 +1,19 @@
 package acab.devcon0.services.shell
 
-import scala.scalanative.libc.stdlib
-import scala.scalanative.unsafe.{Zone, toCString}
+import cats.effect.IO
+
+import scala.scalanative.unsafe
 
 object DockerComposeUp {
-  def apply(dockerComposeAbsolutePath: String): Unit = {
-    Zone { implicit z =>
-      val pull: String                = s"docker compose -f $dockerComposeAbsolutePath pull"
-      val upForcedAndDetached: String = s"docker compose -f $dockerComposeAbsolutePath up --force-recreate -d"
-      stdlib.system(toCString(pull))
-      stdlib.system(toCString(upForcedAndDetached))
-    }
+
+  private val cmdLabel = "Booting containers"
+
+  def apply(dockerComposeAbsolutePath: String): IO[Unit] = {
+    val cmd: String = getCmd(dockerComposeAbsolutePath)
+    ShellExecutor.withProgress(cmd, cmdLabel)
+  }
+
+  private def getCmd(dockerComposeAbsolutePath: String): String = {
+    s"docker compose --progress=quiet -f $dockerComposeAbsolutePath up --pull always --quiet-pull --force-recreate --detach --wait"
   }
 }
diff --git a/cli/src/main/scala/acab/devcon0/services/shell/EnvSust.scala b/cli/src/main/scala/acab/devcon0/services/shell/EnvSust.scala
index dee5cd8..cc92b9e 100644
--- a/cli/src/main/scala/acab/devcon0/services/shell/EnvSust.scala
+++ b/cli/src/main/scala/acab/devcon0/services/shell/EnvSust.scala
@@ -1,16 +1,30 @@
 package acab.devcon0.services.shell
 
+import cats.effect.IO
+import cats.implicits.catsSyntaxMonadError
+
 import java.nio.charset.StandardCharsets
 import java.nio.file.{Files, Paths}
-import scala.io.Source
 
 object EnvSust {
   def apply(
       envVarsMap: Map[String, String],
       templateContent: String,
       absoluteOutputPath: String
+  ): IO[Unit] = {
+    val cmdLabel = s"File generation: ${absoluteOutputPath.split('/').lastOption.getOrElse(absoluteOutputPath)}"
+    IO(applyInner(envVarsMap, templateContent, absoluteOutputPath, true))
+      .attemptTap[Unit](either => ShellExecutor.attemptTapPrintProgressFinal(cmdLabel, either))
+  }
+
+  private def applyInner(
+      envVarsMap: Map[String, String],
+      templateContent: String,
+      absoluteOutputPath: String,
+      quiet: Boolean
   ): Unit = {
-    println(s"> envsust < .. > $absoluteOutputPath ")
+    if !quiet then println(s"> envsust < .. > $absoluteOutputPath ")
+
     val replacedContent = envVarsMap.foldLeft(templateContent) { (content, envVar) =>
       content.replace(
         s"$${${envVar._1}}",
diff --git a/cli/src/main/scala/acab/devcon0/services/shell/IpfsGenerateSwarmKey.scala b/cli/src/main/scala/acab/devcon0/services/shell/IpfsGenerateSwarmKey.scala
index 7bce267..de5ee91 100644
--- a/cli/src/main/scala/acab/devcon0/services/shell/IpfsGenerateSwarmKey.scala
+++ b/cli/src/main/scala/acab/devcon0/services/shell/IpfsGenerateSwarmKey.scala
@@ -1,15 +1,21 @@
 package acab.devcon0.services.shell
 
+import cats.effect.IO
+
 import scala.scalanative.libc.stdlib
 import scala.scalanative.unsafe.{Zone, toCString}
 
 object IpfsGenerateSwarmKey {
-  def apply(swarmKeyAbsolutePath: String): Unit = {
-    Zone { implicit z =>
-      val commandStr: String =
-        s"echo \"/key/swarm/psk/1.0.0/\n/base16/\n$$(hexdump -n 32 -e '16/1 \"%02x\"' /dev/urandom)\" > $swarmKeyAbsolutePath"
-      println(s"> $commandStr")
-      stdlib.system(toCString(commandStr))
-    }
+
+  private val cmdLabel: String = "Creating IPFS swarm key"
+
+  def apply(swarmKeyAbsolutePath: String): IO[Unit] = {
+    val cmd: String = getCmd(swarmKeyAbsolutePath)
+    val label       = s"$cmdLabel: ${swarmKeyAbsolutePath.split('/').lastOption.getOrElse(swarmKeyAbsolutePath)}"
+    ShellExecutor.withoutProgress(cmd, label, label.length)
+  }
+
+  private def getCmd(swarmKeyAbsolutePath: String): String = {
+    s"echo \"/key/swarm/psk/1.0.0/\n/base16/\n$$(hexdump -n 32 -e '16/1 \"%02x\"' /dev/urandom)\" > $swarmKeyAbsolutePath"
   }
 }
diff --git a/cli/src/main/scala/acab/devcon0/services/shell/MkdirP.scala b/cli/src/main/scala/acab/devcon0/services/shell/MkdirP.scala
index d299f4c..1ac1bdc 100644
--- a/cli/src/main/scala/acab/devcon0/services/shell/MkdirP.scala
+++ b/cli/src/main/scala/acab/devcon0/services/shell/MkdirP.scala
@@ -1,15 +1,18 @@
 package acab.devcon0.services.shell
 
-import scala.scalanative.libc.stdlib
-import scala.scalanative.unsafe.Zone
+import cats.effect.IO
 
 object MkdirP {
-  def apply(folderPath: String): Unit = {
-    Zone { implicit z =>
-      val commandStr = s"mkdir -p $folderPath"
-      println(s"> $commandStr")
-      val command = scala.scalanative.unsafe.toCString(commandStr)
-      stdlib.system(command)
-    }
+
+  private val cmdLabel: String = "Ensuring folder exists"
+
+  def apply(folderPath: String): IO[Unit] = {
+    val cmd: String = getCmd(folderPath)
+    val label       = s"$cmdLabel: ${folderPath.split('/').lastOption.getOrElse(folderPath)}"
+    ShellExecutor.withProgress(cmd, label)
+  }
+
+  private def getCmd(folderPath: String): String = {
+    s"mkdir -p $folderPath"
   }
 }
diff --git a/cli/src/main/scala/acab/devcon0/services/shell/ShellExecutor.scala b/cli/src/main/scala/acab/devcon0/services/shell/ShellExecutor.scala
new file mode 100644
index 0000000..fb0701a
--- /dev/null
+++ b/cli/src/main/scala/acab/devcon0/services/shell/ShellExecutor.scala
@@ -0,0 +1,89 @@
+package acab.devcon0.services.shell
+
+import cats.effect.IO
+
+import scala.scalanative.libc.stdlib
+import scala.scalanative.unsafe
+import scala.scalanative.unsafe.{CInt, Zone, toCString}
+
+object ShellExecutor {
+
+  def withoutProgress(cmd: String, label: String, wipingLineLength: Int): IO[Unit] = {
+    getCmdIO(cmd)
+      .flatMap(cInt => {
+        if cInt != 0 then replacePrintProgressError(label) >> IO.raiseError(NonZeroExitCode())
+        else replacePrintProgressOk(label, Some(wipingLineLength))
+      })
+  }
+
+  def withProgress(cmd: String, label: String): IO[Unit] = {
+    getCmdIO(appendProgress(cmd, label))
+      .flatMap(cInt => {
+        if cInt != 0 then replacePrintProgressError(label) >> IO.raiseError(NonZeroExitCode())
+        else replacePrintProgressOk(label)
+      })
+  }
+
+  def filterNonZeroExitCode(cInt: CInt): IO[Unit] = {
+    if cInt != 0 then IO.raiseError(NonZeroExitCode())
+    else IO.unit
+  }
+
+  def attemptTapPrintProgressFinal(label: String, either: Either[Throwable, ?]): IO[Unit] = {
+    either
+      .fold(_ => printProgressError(label), _ => printProgressOk(label))
+  }
+
+  def printProgressOk(label: String): IO[Unit] = {
+    getCmdIO(s"printf \"\\033[1;32m[OK]\\033[0;32m $label   \\033[0m\"")
+      .flatMap(_ => IO(println()))
+  }
+
+  def printProgressError(label: String): IO[Unit] = {
+    getCmdIO(s"printf \"\\033[1;31m[Error]\\033[0;31m $label   \\033[0m\"")
+      .flatMap(_ => IO(println()))
+  }
+
+  private def replacePrintProgressOk(label: String, wipingLineLength: Option[Int] = None): IO[Unit] = {
+    val whiteChars: String = " " * wipingLineLength.getOrElse(label.length)
+    getCmdIO(s"printf \"\\r\\033[1;32m[OK]\\033[0;32m $label $whiteChars\\033[0m\"")
+      .flatMap(_ => IO(println()))
+  }
+
+  private def replacePrintProgressError(label: String): IO[Unit] = {
+    getCmdIO(s"printf \"\\r\\033[1;31m[Error]\\033[0;31m $label   \\033[0m\"")
+      .flatMap(_ => IO(println()))
+  }
+
+  private def appendProgress(cmd: String, label: String): String = {
+    s"""$cmd &
+pid=$$! # Process Id of the previous running command
+
+Off='\\033[0m'
+BBlue='\\033[1;34m'
+
+i=0
+while kill -0 $$pid 2>/dev/null
+do
+  for s in / - \\\\ \\|; do
+    printf \"\\r \\033[1;34m [$$s] \\033[0m $label\"
+    sleep .1
+  done
+  i=$$((i+1))
+done
+wait $$pid
+exit $$?
+""".stripMargin
+  }
+
+  private def getCmdIO(cmd: String): IO[CInt] = {
+    IO {
+      Zone { implicit z =>
+        stdlib.system(toCString(cmd))
+      }
+    }
+  }
+
+}
+
+final case class NonZeroExitCode() extends Throwable
diff --git a/cli/src/main/scala/acab/devcon0/services/shell/SudoRm.scala b/cli/src/main/scala/acab/devcon0/services/shell/SudoRm.scala
index 6d3e040..767bfeb 100644
--- a/cli/src/main/scala/acab/devcon0/services/shell/SudoRm.scala
+++ b/cli/src/main/scala/acab/devcon0/services/shell/SudoRm.scala
@@ -1,15 +1,23 @@
 package acab.devcon0.services.shell
 
-import scala.scalanative.libc.stdlib
-import scala.scalanative.unsafe.Zone
+import cats.effect.IO
 
 object SudoRm {
-  def apply(folderPath: String): Unit = {
-    Zone { implicit z =>
-      val commandStr = s"sudo rm -rf $folderPath"
-      println(s"> $commandStr")
-      val command = scala.scalanative.unsafe.toCString(commandStr)
-      stdlib.system(command)
-    }
+
+  private val cmdLabel: String = "Deleting folder"
+
+  def apply(folderPath: String): IO[Unit] = {
+    val message     = s"Up to delete $folderPath folder as sudo"
+    val cmd: String = getCmd(folderPath, message)
+    ShellExecutor.withoutProgress(cmd, s"$cmdLabel: $folderPath", message.length)
+  }
+
+  private def getCmd(folderPath: String, message: String): String = {
+    s"""
+if [ -d "$folderPath" ]; then
+    printf \"$message\\n\"
+    sudo rm -rf "$folderPath"
+fi
+    """
   }
 }
-- 
GitLab