Repository: bacalhau-project/bacalhau (language: python)

Issue:
Automatically perform update checks on every command
In v1.1.1 we added an update check feature to `bacalhau version` command. This is useful but users don't run that command very often.

Now we want to run the check for every command. As ever, the update check should:

- print out a message at the end of the command if an update is required
- fail silently, no error message should be printed if the update check fails
- terminate silently if the command completes before the update check is finished


Discussion / hints:

Fix patch:
diff --git a/cmd/cli/cancel/cancel.go b/cmd/cli/cancel/cancel.go
index 4f88b46546..24f9e02992 100644
--- a/cmd/cli/cancel/cancel.go
+++ b/cmd/cli/cancel/cancel.go
@@ -53,12 +53,13 @@ func NewCmd() *cobra.Command {
 	cancelOptions := NewCancelOptions()
 
 	cancelCmd := &cobra.Command{
-		Use:     "cancel [id]",
-		Short:   "Cancel a previously submitted job",
-		Long:    cancelLong,
-		Example: cancelExample,
-		Args:    cobra.ExactArgs(1),
-		PreRun:  util.ApplyPorcelainLogLevel,
+		Use:      "cancel [id]",
+		Short:    "Cancel a previously submitted job",
+		Long:     cancelLong,
+		Example:  cancelExample,
+		Args:     cobra.ExactArgs(1),
+		PreRunE:  util.ClientPreRunHooks,
+		PostRunE: util.ClientPostRunHooks,
 		RunE: func(cmd *cobra.Command, cmdArgs []string) error {
 			return cancel(cmd, cmdArgs, cancelOptions)
 		},
diff --git a/cmd/cli/create/create.go b/cmd/cli/create/create.go
index b07266aefd..341c725149 100644
--- a/cmd/cli/create/create.go
+++ b/cmd/cli/create/create.go
@@ -59,12 +59,13 @@ func NewCmd() *cobra.Command {
 	OC := NewCreateOptions()
 
 	createCmd := &cobra.Command{
-		Use:     "create",
-		Short:   "Create a job using a json or yaml file.",
-		Long:    createLong,
-		Example: createExample,
-		Args:    cobra.MinimumNArgs(0),
-		PreRun:  util.ApplyPorcelainLogLevel,
+		Use:      "create",
+		Short:    "Create a job using a json or yaml file.",
+		Long:     createLong,
+		Example:  createExample,
+		Args:     cobra.MinimumNArgs(0),
+		PreRunE:  util.ClientPreRunHooks,
+		PostRunE: util.ClientPostRunHooks,
 		Run: func(cmd *cobra.Command, cmdArgs []string) {
 			if err := create(cmd, cmdArgs, OC); err != nil {
 				util.Fatal(cmd, err, 1)
diff --git a/cmd/cli/describe/describe.go b/cmd/cli/describe/describe.go
index 009d9356c2..a6d1980a2d 100644
--- a/cmd/cli/describe/describe.go
+++ b/cmd/cli/describe/describe.go
@@ -51,12 +51,13 @@ func NewCmd() *cobra.Command {
 	OD := NewDescribeOptions()
 
 	describeCmd := &cobra.Command{
-		Use:     "describe [id]",
-		Short:   "Describe a job on the network",
-		Long:    describeLong,
-		Example: describeExample,
-		Args:    cobra.ExactArgs(1),
-		PreRun:  util.ApplyPorcelainLogLevel,
+		Use:      "describe [id]",
+		Short:    "Describe a job on the network",
+		Long:     describeLong,
+		Example:  describeExample,
+		Args:     cobra.ExactArgs(1),
+		PreRunE:  util.ClientPreRunHooks,
+		PostRunE: util.ClientPostRunHooks,
 		Run: func(cmd *cobra.Command, cmdArgs []string) { // nolintunparam // incorrectly suggesting unused
 			if err := describe(cmd, cmdArgs, OD); err != nil {
 				util.Fatal(cmd, err, 1)
diff --git a/cmd/cli/docker/docker_run.go b/cmd/cli/docker/docker_run.go
index a6dd3a7189..5112396681 100644
--- a/cmd/cli/docker/docker_run.go
+++ b/cmd/cli/docker/docker_run.go
@@ -83,7 +83,7 @@ func NewCmd() *cobra.Command {
 	dockerCmd := &cobra.Command{
 		Use:               "docker",
 		Short:             "Run a docker job on the network (see run subcommand)",
-		PersistentPreRunE: util.CheckVersion,
+		PersistentPreRunE: util.AfterParentPreRunHook(util.CheckVersion),
 	}
 
 	dockerCmd.AddCommand(newDockerRunCmd())
@@ -98,19 +98,13 @@ func newDockerRunCmd() *cobra.Command { //nolint:funlen
 	}
 
 	dockerRunCmd := &cobra.Command{
-		Use:     "run [flags] IMAGE[:TAG|@DIGEST] [COMMAND] [ARG...]",
-		Short:   "Run a docker job on the network",
-		Long:    runLong,
-		Example: runExample,
-		Args:    cobra.MinimumNArgs(1),
-		PreRun:  util.ApplyPorcelainLogLevel,
-		PreRunE: func(cmd *cobra.Command, args []string) error {
-			err := configflags.BindFlags(cmd, dockerRunFlags)
-			if err != nil {
-				util.Fatal(cmd, err, 1)
-			}
-			return err
-		},
+		Use:      "run [flags] IMAGE[:TAG|@DIGEST] [COMMAND] [ARG...]",
+		Short:    "Run a docker job on the network",
+		Long:     runLong,
+		Example:  runExample,
+		Args:     cobra.MinimumNArgs(1),
+		PreRunE:  util.Chain(util.ClientPreRunHooks, configflags.PreRun(dockerRunFlags)),
+		PostRunE: util.ClientPostRunHooks,
 		Run: func(cmd *cobra.Command, cmdArgs []string) {
 			if err := dockerRun(cmd, cmdArgs, opts); err != nil {
 				util.Fatal(cmd, err, 1)
diff --git a/cmd/cli/get/get.go b/cmd/cli/get/get.go
index b305478caa..05ac07677c 100644
--- a/cmd/cli/get/get.go
+++ b/cmd/cli/get/get.go
@@ -47,18 +47,13 @@ func NewCmd() *cobra.Command {
 	}
 
 	getCmd := &cobra.Command{
-		Use:     "get [id]",
-		Short:   "Get the results of a job",
-		Long:    getLong,
-		Example: getExample,
-		Args:    cobra.ExactArgs(1),
-		PreRun: func(cmd *cobra.Command, args []string) {
-			if err := configflags.BindFlags(cmd, getFlags); err != nil {
-				util.Fatal(cmd, err, 1)
-			}
-
-			util.ApplyPorcelainLogLevel(cmd, args)
-		},
+		Use:      "get [id]",
+		Short:    "Get the results of a job",
+		Long:     getLong,
+		Example:  getExample,
+		Args:     cobra.ExactArgs(1),
+		PreRunE:  util.Chain(util.ClientPreRunHooks, configflags.PreRun(getFlags)),
+		PostRunE: util.ClientPostRunHooks,
 		Run: func(cmd *cobra.Command, cmdArgs []string) {
 			if err := get(cmd, cmdArgs, OG); err != nil {
 				util.Fatal(cmd, err, 1)
diff --git a/cmd/cli/job/describe.go b/cmd/cli/job/describe.go
index 1864f7a055..74b7cce102 100644
--- a/cmd/cli/job/describe.go
+++ b/cmd/cli/job/describe.go
@@ -14,7 +14,7 @@ import (
 
 var (
 	describeLong = templates.LongDesc(i18n.T(`
-		Full description of a job, in yaml format. 
+		Full description of a job, in yaml format.
 		Use 'bacalhau job list' to get a list of jobs.
 `))
 	describeExample = templates.Examples(i18n.T(`
@@ -49,7 +49,6 @@ func NewDescribeCmd() *cobra.Command {
 		Long:    describeLong,
 		Example: describeExample,
 		Args:    cobra.ExactArgs(1),
-		PreRun:  util.ApplyPorcelainLogLevel,
 		Run:     o.run,
 	}
 	jobCmd.Flags().AddFlagSet(cliflags.OutputNonTabularFormatFlags(&o.OutputOpts))
diff --git a/cmd/cli/job/executions.go b/cmd/cli/job/executions.go
index dcde46fa6d..c812cdb277 100644
--- a/cmd/cli/job/executions.go
+++ b/cmd/cli/job/executions.go
@@ -29,7 +29,7 @@ var (
 
 	executionExample = templates.Examples(i18n.T(`
 		# All executions for a given job.
-		bacalhau job executions j-e3f8c209-d683-4a41-b840-f09b88d087b9	
+		bacalhau job executions j-e3f8c209-d683-4a41-b840-f09b88d087b9
 `))
 )
 
@@ -58,7 +58,6 @@ func NewExecutionCmd() *cobra.Command {
 		Long:    executionLong,
 		Example: executionExample,
 		Args:    cobra.ExactArgs(1),
-		PreRun:  util.ApplyPorcelainLogLevel,
 		Run:     o.run,
 	}
 
diff --git a/cmd/cli/job/history.go b/cmd/cli/job/history.go
index 60f5980c98..67b9fdb845 100644
--- a/cmd/cli/job/history.go
+++ b/cmd/cli/job/history.go
@@ -32,7 +32,7 @@ var (
 		# Job level events
 		bacalhau job history --type job e3f8c209
 
-		# Execution level events 
+		# Execution level events
 		bacalhau job history --type execution e3f8c209
 `))
 )
@@ -62,7 +62,6 @@ func NewHistoryCmd() *cobra.Command {
 		Long:    historyLong,
 		Example: historyExample,
 		Args:    cobra.ExactArgs(1),
-		PreRun:  util.ApplyPorcelainLogLevel,
 		Run:     o.run,
 	}
 
diff --git a/cmd/cli/job/list.go b/cmd/cli/job/list.go
index 16647035cd..19f99db5ab 100644
--- a/cmd/cli/job/list.go
+++ b/cmd/cli/job/list.go
@@ -66,7 +66,6 @@ func NewListCmd() *cobra.Command {
 		Short:   listShort,
 		Long:    listLong,
 		Example: listExample,
-		PreRun:  util.ApplyPorcelainLogLevel,
 		Args:    cobra.NoArgs,
 		Run:     o.run,
 	}
diff --git a/cmd/cli/job/logs.go b/cmd/cli/job/logs.go
index 5d808af810..f1afb49142 100644
--- a/cmd/cli/job/logs.go
+++ b/cmd/cli/job/logs.go
@@ -17,7 +17,7 @@ var (
 		# Follow logs for a previously submitted job
 		bacalhau logs j-51225160-807e-48b8-88c9-28311c7899e1
 
-		# Follow output with a short ID 
+		# Follow output with a short ID
 		bacalhau logs j-ebd9bf2f
 `))
 )
@@ -35,7 +35,6 @@ func NewLogCmd() *cobra.Command {
 		Short:   logsShortDesc,
 		Example: logsExample,
 		Args:    cobra.ExactArgs(1),
-		PreRun:  util.ApplyPorcelainLogLevel,
 		Run: func(cmd *cobra.Command, cmdArgs []string) {
 			if err := util.Logs(cmd, cmdArgs[0], options.Follow, options.WithHistory); err != nil {
 				util.Fatal(cmd, err, 1)
diff --git a/cmd/cli/job/root.go b/cmd/cli/job/root.go
index 661a38d984..4d52761c18 100644
--- a/cmd/cli/job/root.go
+++ b/cmd/cli/job/root.go
@@ -1,11 +1,16 @@
 package job
 
-import "github.com/spf13/cobra"
+import (
+	"github.com/bacalhau-project/bacalhau/cmd/util"
+	"github.com/spf13/cobra"
+)
 
 func NewCmd() *cobra.Command {
 	cmd := &cobra.Command{
-		Use:   "job",
-		Short: "Commands to submit, query and update jobs.",
+		Use:                "job",
+		Short:              "Commands to submit, query and update jobs.",
+		PersistentPreRunE:  util.AfterParentPreRunHook(util.ClientPreRunHooks),
+		PersistentPostRunE: util.AfterParentPostRunHook(util.ClientPostRunHooks),
 	}
 
 	cmd.AddCommand(NewDescribeCmd())
diff --git a/cmd/cli/job/run.go b/cmd/cli/job/run.go
index 199c295c14..d7328ea747 100644
--- a/cmd/cli/job/run.go
+++ b/cmd/cli/job/run.go
@@ -31,7 +31,7 @@ var (
 		bacalhau job run ./job.yaml
 
 		# Run a new job from an already executed job
-		bacalhau job describe 6e51df50 | bacalhau job run 
+		bacalhau job describe 6e51df50 | bacalhau job run
 		`))
 )
 
@@ -55,7 +55,6 @@ func NewRunCmd() *cobra.Command {
 		Long:    runLong,
 		Example: runExample,
 		Args:    cobra.MinimumNArgs(0),
-		PreRun:  util.ApplyPorcelainLogLevel,
 		Run:     o.run,
 	}
 
diff --git a/cmd/cli/job/stop.go b/cmd/cli/job/stop.go
index 2778c52494..bbdc56e58a 100644
--- a/cmd/cli/job/stop.go
+++ b/cmd/cli/job/stop.go
@@ -59,7 +59,6 @@ func NewStopCmd() *cobra.Command {
 		Long:    stopLong,
 		Example: stopExample,
 		Args:    cobra.ExactArgs(1),
-		PreRun:  util.ApplyPorcelainLogLevel,
 		RunE:    o.run,
 	}
 
diff --git a/cmd/cli/list/list.go b/cmd/cli/list/list.go
index 421f07248f..4732ed6be1 100644
--- a/cmd/cli/list/list.go
+++ b/cmd/cli/list/list.go
@@ -68,11 +68,12 @@ func NewCmd() *cobra.Command {
 	OL := NewListOptions()
 
 	listCmd := &cobra.Command{
-		Use:     "list",
-		Short:   "List jobs on the network",
-		Long:    listLong,
-		Example: listExample,
-		PreRun:  util.ApplyPorcelainLogLevel,
+		Use:      "list",
+		Short:    "List jobs on the network",
+		Long:     listLong,
+		Example:  listExample,
+		PreRunE:  util.ClientPreRunHooks,
+		PostRunE: util.ClientPostRunHooks,
 		Run: func(cmd *cobra.Command, _ []string) {
 			if err := list(cmd, OL); err != nil {
 				util.Fatal(cmd, err, 1)
diff --git a/cmd/cli/node/list.go b/cmd/cli/node/list.go
index 1142f1c5cc..a9ffb4cebe 100644
--- a/cmd/cli/node/list.go
+++ b/cmd/cli/node/list.go
@@ -35,11 +35,10 @@ func NewListOptions() *ListOptions {
 func NewListCmd() *cobra.Command {
 	o := NewListOptions()
 	nodeCmd := &cobra.Command{
-		Use:    "list",
-		Short:  "List info of network nodes. ",
-		PreRun: util.ApplyPorcelainLogLevel,
-		Args:   cobra.NoArgs,
-		Run:    o.run,
+		Use:   "list",
+		Short: "List info of network nodes. ",
+		Args:  cobra.NoArgs,
+		Run:   o.run,
 	}
 	nodeCmd.Flags().StringSliceVar(&o.ColumnGroups, "show", o.ColumnGroups,
 		fmt.Sprintf("What column groups to show. Zero or more of: %q", maps.Keys(toggleColumns)))
diff --git a/cmd/cli/node/root.go b/cmd/cli/node/root.go
index b83a953cd4..b03074fabb 100644
--- a/cmd/cli/node/root.go
+++ b/cmd/cli/node/root.go
@@ -1,11 +1,16 @@
 package node
 
-import "github.com/spf13/cobra"
+import (
+	"github.com/bacalhau-project/bacalhau/cmd/util"
+	"github.com/spf13/cobra"
+)
 
 func NewCmd() *cobra.Command {
 	cmd := &cobra.Command{
-		Use:   "node",
-		Short: "Commands to query and update nodes information.",
+		Use:                "node",
+		Short:              "Commands to query and update nodes information.",
+		PersistentPreRunE:  util.AfterParentPreRunHook(util.ClientPreRunHooks),
+		PersistentPostRunE: util.AfterParentPostRunHook(util.ClientPostRunHooks),
 	}
 
 	cmd.AddCommand(NewDescribeCmd())
diff --git a/cmd/cli/root.go b/cmd/cli/root.go
index c5159b45e8..edafdfb208 100644
--- a/cmd/cli/root.go
+++ b/cmd/cli/root.go
@@ -43,10 +43,12 @@ func NewRootCmd() *cobra.Command {
 		"logging": configflags.LogFlags,
 	}
 	RootCmd := &cobra.Command{
-		Use:   os.Args[0],
-		Short: "Compute over data",
-		Long:  `Compute over data`,
-		PersistentPreRun: func(cmd *cobra.Command, args []string) {
+		Use:     os.Args[0],
+		Short:   "Compute over data",
+		Long:    `Compute over data`,
+		PreRun:  util.StartUpdateCheck,
+		PostRun: util.PrintUpdateCheck,
+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
 			repoDir, err := config.Get[string]("repo")
 			if err != nil {
 				util.Fatal(cmd, fmt.Errorf("failed to read --repo value: %w", err), 1)
@@ -82,11 +84,13 @@ func NewRootCmd() *cobra.Command {
 			ctx = context.WithValue(ctx, spanKey, span)
 
 			cmd.SetContext(ctx)
+			return nil
 		},
-		PersistentPostRun: func(cmd *cobra.Command, args []string) {
+		PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
 			ctx.Value(spanKey).(trace.Span).End()
 			ctx.Value(util.SystemManagerKey).(*system.CleanupManager).Cleanup(ctx)
+			return nil
 		},
 	}
 	// ensure the `repo` key always gets a usable default value, warn if it's not.
diff --git a/cmd/cli/version/version.go b/cmd/cli/version/version.go
index 27e5c1de99..057798117a 100644
--- a/cmd/cli/version/version.go
+++ b/cmd/cli/version/version.go
@@ -17,34 +17,19 @@ package version
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
-	"io"
-	"net/http"
-	"net/url"
 
 	"github.com/jedib0t/go-pretty/v6/table"
-	"github.com/pkg/errors"
 	"github.com/rs/zerolog/log"
 	"github.com/spf13/cobra"
 
-	"github.com/bacalhau-project/bacalhau/pkg/models"
 	"github.com/bacalhau-project/bacalhau/pkg/version"
 
 	"github.com/bacalhau-project/bacalhau/cmd/util"
 	"github.com/bacalhau-project/bacalhau/cmd/util/flags/cliflags"
 	"github.com/bacalhau-project/bacalhau/cmd/util/output"
-
-	"github.com/bacalhau-project/bacalhau/pkg/config"
 )
 
-type Versions struct {
-	ClientVersion *models.BuildVersionInfo `json:"clientVersion,omitempty"`
-	ServerVersion *models.BuildVersionInfo `json:"serverVersion,omitempty"`
-	LatestVersion *models.BuildVersionInfo `json:"latestVersion,omitempty"`
-	UpdateMessage string                   `json:"updateMessage,omitempty"`
-}
-
 type VersionOptions struct {
 	ClientOnly bool
 	OutputOpts output.OutputOptions
@@ -87,116 +72,59 @@ func runVersion(cmd *cobra.Command, oV *VersionOptions) error {
 	return err
 }
 
-var clientVersionColumn = output.TableColumn[Versions]{
+var clientVersionColumn = output.TableColumn[util.Versions]{
 	ColumnConfig: table.ColumnConfig{Name: "client"},
-	Value:        func(v Versions) string { return v.ClientVersion.GitVersion },
+	Value:        func(v util.Versions) string { return v.ClientVersion.GitVersion },
 }
 
-var serverVersionColumn = output.TableColumn[Versions]{
+var serverVersionColumn = output.TableColumn[util.Versions]{
 	ColumnConfig: table.ColumnConfig{Name: "server"},
-	Value:        func(v Versions) string { return v.ServerVersion.GitVersion },
+	Value:        func(v util.Versions) string { return v.ServerVersion.GitVersion },
 }
 
-var latestVersionColumn = output.TableColumn[Versions]{
+var latestVersionColumn = output.TableColumn[util.Versions]{
 	ColumnConfig: table.ColumnConfig{Name: "latest"},
-	Value:        func(v Versions) string { return v.LatestVersion.GitVersion },
+	Value:        func(v util.Versions) string { return v.LatestVersion.GitVersion },
 }
 
-var updateMessageColumn = output.TableColumn[Versions]{
+var updateMessageColumn = output.TableColumn[util.Versions]{
 	ColumnConfig: table.ColumnConfig{Name: "Update Message"},
-	Value:        func(v Versions) string { return v.UpdateMessage },
+	Value:        func(v util.Versions) string { return v.UpdateMessage },
 }
 
 func (oV *VersionOptions) Run(ctx context.Context, cmd *cobra.Command) error {
 	var (
-		versions Versions
-		columns  []output.TableColumn[Versions]
+		versions util.Versions
+		columns  []output.TableColumn[util.Versions]
 	)
 
-	versions.ClientVersion = version.Get()
-	columns = append(columns, clientVersionColumn)
-
-	if !oV.ClientOnly {
-		serverVersion, err := util.GetAPIClient(ctx).Version(ctx)
-		if err != nil {
-			return fmt.Errorf("error running version command: %w", err)
-		}
-
-		versions.ServerVersion = serverVersion
-		columns = append(columns, serverVersionColumn)
-
-		clientID, err := config.GetClientID()
-		if err != nil {
-			return fmt.Errorf("error getting client ID: %w", err)
-		}
-
-		updateCheck, err := checkForUpdates(ctx, versions.ClientVersion, versions.ServerVersion, clientID)
+	if oV.ClientOnly {
+		versions.ClientVersion = version.Get()
+	} else {
+		var err error
+		versions, err = util.GetAllVersions(ctx)
 		if err != nil {
-			log.Ctx(ctx).Warn().Err(err).Msg("Failed to perform update check")
-		} else {
-			versions.UpdateMessage = updateCheck.Message
-			versions.LatestVersion = updateCheck.Version
-			columns = append(columns, latestVersionColumn)
-
-			// Print the update message only if --output flag is not used
-			if oV.OutputOpts.Format == output.TableFormat {
-				cmd.Println(updateCheck.Message)
-			} else {
-				columns = append(columns, updateMessageColumn)
-			}
+			// No error on fail of version check. Just print as much as we can.
+			log.Ctx(ctx).Warn().Err(err).Msg("failed to get updated versions")
 		}
 	}
 
-	return output.OutputOne(cmd, columns, oV.OutputOpts, versions)
-}
-
-type serverResponse struct {
-	Version *models.BuildVersionInfo `json:"version"`
-	Message string                   `json:"message"`
-}
-
-func checkForUpdates(
-	ctx context.Context,
-	currentClientVersion, currentServerVersion *models.BuildVersionInfo,
-	clientID string,
-) (*serverResponse, error) {
-	u, err := url.Parse("http://update.bacalhau.org/version")
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to parse URL for update host")
-	}
-
-	q := u.Query()
-	q.Set("clientVersion", currentClientVersion.GitVersion)
-	if currentServerVersion.GitVersion != "" {
-		q.Set("serverVersion", currentServerVersion.GitVersion)
-	}
-	q.Set("operatingSystem", currentClientVersion.GOOS)
-	q.Set("architecture", currentClientVersion.GOARCH)
-	q.Set("userID", clientID)
-
-	u.RawQuery = q.Encode()
-
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to build HTTP request for update check")
+	if versions.ClientVersion != nil {
+		columns = append(columns, clientVersionColumn)
 	}
-
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to fetch the latest version from the server")
+	if versions.ServerVersion != nil {
+		columns = append(columns, serverVersionColumn)
 	}
-	defer resp.Body.Close()
-
-	body, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to read response body")
+	if versions.LatestVersion != nil {
+		columns = append(columns, latestVersionColumn)
 	}
 
-	var updateCheck serverResponse
-	err = json.Unmarshal(body, &updateCheck)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to unmarshal the server response when checking for updates")
+	// Print the update message only if --output flag is not used
+	if oV.OutputOpts.Format == output.TableFormat && versions.UpdateMessage != "" {
+		cmd.Println(versions.UpdateMessage)
+	} else {
+		columns = append(columns, updateMessageColumn)
 	}
 
-	return &updateCheck, nil
+	return output.OutputOne(cmd, columns, oV.OutputOpts, versions)
 }
diff --git a/cmd/cli/wasm/wasm_run.go b/cmd/cli/wasm/wasm_run.go
index 1c29230ebf..2aa5651ed4 100644
--- a/cmd/cli/wasm/wasm_run.go
+++ b/cmd/cli/wasm/wasm_run.go
@@ -75,7 +75,7 @@ func NewCmd() *cobra.Command {
 	wasmCmd := &cobra.Command{
 		Use:               "wasm",
 		Short:             "Run and prepare WASM jobs on the network",
-		PersistentPreRunE: util.CheckVersion,
+		PersistentPreRunE: util.AfterParentPreRunHook(util.CheckVersion),
 	}
 
 	wasmCmd.AddCommand(
@@ -94,19 +94,13 @@ func newRunCmd() *cobra.Command {
 	}
 
 	wasmRunCmd := &cobra.Command{
-		Use:     "run {cid-of-wasm | <local.wasm>} [--entry-point <string>] [wasm-args ...]",
-		Short:   "Run a WASM job on the network",
-		Long:    wasmRunLong,
-		Example: wasmRunExample,
-		Args:    cobra.MinimumNArgs(1),
-		PreRun:  util.ApplyPorcelainLogLevel,
-		PreRunE: func(cmd *cobra.Command, args []string) error {
-			err := configflags.BindFlags(cmd, wasmRunFlags)
-			if err != nil {
-				util.Fatal(cmd, err, 1)
-			}
-			return err
-		},
+		Use:      "run {cid-of-wasm | <local.wasm>} [--entry-point <string>] [wasm-args ...]",
+		Short:    "Run a WASM job on the network",
+		Long:     wasmRunLong,
+		Example:  wasmRunExample,
+		Args:     cobra.MinimumNArgs(1),
+		PreRunE:  util.Chain(util.ClientPreRunHooks, configflags.PreRun(wasmRunFlags)),
+		PostRunE: util.ClientPostRunHooks,
 		Run: func(cmd *cobra.Command, args []string) {
 			if err := runWasm(cmd, args, opts); err != nil {
 				util.Fatal(cmd, err, 1)
diff --git a/cmd/util/flags/configflags/register.go b/cmd/util/flags/configflags/register.go
index 48ec373015..07181dab2a 100644
--- a/cmd/util/flags/configflags/register.go
+++ b/cmd/util/flags/configflags/register.go
@@ -8,6 +8,7 @@ import (
 	"github.com/spf13/pflag"
 	"github.com/spf13/viper"
 
+	"github.com/bacalhau-project/bacalhau/cmd/util"
 	"github.com/bacalhau-project/bacalhau/cmd/util/flags"
 	"github.com/bacalhau-project/bacalhau/pkg/bidstrategy/semantic"
 	"github.com/bacalhau-project/bacalhau/pkg/config/types"
@@ -62,6 +63,17 @@ func BindFlags(cmd *cobra.Command, register map[string][]Definition) error {
 	return nil
 }
 
+// PreRun returns a run hook that binds the passed flag sets onto the command.
+func PreRun(flags map[string][]Definition) func(*cobra.Command, []string) error {
+	return func(cmd *cobra.Command, args []string) error {
+		err := BindFlags(cmd, flags)
+		if err != nil {
+			util.Fatal(cmd, err, 1)
+		}
+		return err
+	}
+}
+
 // RegisterFlags adds flags to the command based on provided definitions.
 // This method should be called before the command runs to register flags accordingly.
 func RegisterFlags(cmd *cobra.Command, register map[string][]Definition) error {
diff --git a/cmd/util/hooks.go b/cmd/util/hooks.go
new file mode 100644
index 0000000000..280492fe8a
--- /dev/null
+++ b/cmd/util/hooks.go
@@ -0,0 +1,78 @@
+package util
+
+import (
+	"github.com/spf13/cobra"
+)
+
+// A Cobra run hook that cannot return an error.
+type runHook = func(*cobra.Command, []string)
+
+// A Cobra run hook that may return an error.
+type runHookE = func(*cobra.Command, []string) error
+
+// Run all of the passed hooks in order, stopping on and returning the first
+// error.
+func Chain(hooks ...runHookE) runHookE {
+	return func(c *cobra.Command, s []string) error {
+		for _, hook := range hooks {
+			if err := hook(c, s); err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+}
+
+// Adapt turns a run hook that does not return an error into one that does.
+func Adapt(hook runHook) runHookE {
+	return func(c *cobra.Command, s []string) error {
+		hook(c, s)
+		return nil
+	}
+}
+
+// Because cobra doesn't do PersistentPreRun{,E} chaining yet
+// (https://github.com/spf13/cobra/issues/252), this function walks upwards and
+// finds any parent hook that should be run if the hooks were properly
+// persistent. The complexity here is that if we are running on a child command
+// we will first find the parent hook, and need to skip that otherwise we'll end
+// up in an infinite loop.
+func AfterParentHook(hook runHookE, getHook func(*cobra.Command) runHookE) runHookE {
+	return func(c *cobra.Command, s []string) error {
+		seenThis := false
+		for cmd := c; cmd != nil; cmd = cmd.Parent() {
+			possibleParentHook := getHook(cmd)
+			if possibleParentHook != nil && seenThis {
+				err := possibleParentHook(c, s)
+				if err != nil {
+					return err
+				}
+				break
+			} else if possibleParentHook != nil && !seenThis {
+				seenThis = true
+			}
+		}
+		return hook(c, s)
+	}
+}
+
+func AfterParentPreRunHook(hook runHookE) runHookE {
+	return AfterParentHook(hook, func(c *cobra.Command) runHookE { return c.PersistentPreRunE })
+}
+
+func AfterParentPostRunHook(hook runHookE) runHookE {
+	return AfterParentHook(hook, func(c *cobra.Command) runHookE { return c.PersistentPostRunE })
+}
+
+// ClientPreRunHooks is the set of pre-run hooks that all client commands
+// should have applied.
+var ClientPreRunHooks runHookE = Chain(
+	Adapt(ApplyPorcelainLogLevel),
+	Adapt(StartUpdateCheck),
+)
+
+// ClientPostRunHooks is the set of post-run hooks that all client commands
+// should have applied.
+var ClientPostRunHooks runHookE = Chain(
+	Adapt(PrintUpdateCheck),
+)
diff --git a/cmd/util/version.go b/cmd/util/version.go
index af1a3e8fcc..720f4848ff 100644
--- a/cmd/util/version.go
+++ b/cmd/util/version.go
@@ -5,22 +5,26 @@ import (
 	"fmt"
 
 	"github.com/Masterminds/semver"
+	"github.com/pkg/errors"
 	"github.com/rs/zerolog/log"
 	"github.com/spf13/cobra"
 
+	"github.com/bacalhau-project/bacalhau/pkg/config"
+	"github.com/bacalhau-project/bacalhau/pkg/config/types"
 	"github.com/bacalhau-project/bacalhau/pkg/models"
+	"github.com/bacalhau-project/bacalhau/pkg/publicapi/client"
 
 	"github.com/bacalhau-project/bacalhau/pkg/version"
 )
 
-func CheckVersion(cmd *cobra.Command, args []string) error {
-	// corba doesn't do PersistentPreRun{,E} chaining yet
-	// https://github.com/spf13/cobra/issues/252
-	root := cmd
-	for ; root.HasParent(); root = root.Parent() {
-	}
-	root.PersistentPreRun(cmd, args)
+type Versions struct {
+	ClientVersion *models.BuildVersionInfo `json:"clientVersion,omitempty"`
+	ServerVersion *models.BuildVersionInfo `json:"serverVersion,omitempty"`
+	LatestVersion *models.BuildVersionInfo `json:"latestVersion,omitempty"`
+	UpdateMessage string                   `json:"updateMessage,omitempty"`
+}
 
+func CheckVersion(cmd *cobra.Command, args []string) error {
 	// the client will not be known until the root persistent pre run logic is executed which
 	// sets up the repo and config
 	ctx := cmd.Context()
@@ -35,6 +39,59 @@ func CheckVersion(cmd *cobra.Command, args []string) error {
 	return nil
 }
 
+func GetAllVersions(ctx context.Context) (Versions, error) {
+	var err error
+	versions := Versions{ClientVersion: version.Get()}
+	versions.ServerVersion, err = client.NewAPIClient(config.ClientAPIHost(), config.ClientAPIPort()).Version(ctx)
+	if err != nil {
+		return versions, errors.Wrap(err, "error running version command")
+	}
+
+	clientID, err := config.GetClientID()
+	if err != nil {
+		return versions, errors.Wrap(err, "error getting client ID")
+	}
+
+	updateCheck, err := version.CheckForUpdate(ctx, versions.ClientVersion, versions.ServerVersion, clientID)
+	if err != nil {
+		return versions, errors.Wrap(err, "failed to get latest version")
+	} else {
+		versions.UpdateMessage = updateCheck.Message
+		versions.LatestVersion = updateCheck.Version
+	}
+
+	return versions, nil
+}
+
+var printMessage *string = nil
+
+// StartUpdateCheck is a Cobra pre run hook to run an update check in the
+// background. There should be no output if the check fails or the context is
+// cancelled before the check can complete.
+func StartUpdateCheck(cmd *cobra.Command, args []string) {
+	go func(ctx context.Context) {
+		if skip, err := config.Get[bool](types.SkipUpdateCheck); skip || err != nil {
+			log.Ctx(ctx).Debug().Err(err).Bool(types.SkipUpdateCheck, skip).Msg("Skipping update check")
+			return
+		}
+
+		versions, err := GetAllVersions(ctx)
+		if err == nil {
+			printMessage = &versions.UpdateMessage
+		}
+	}(cmd.Context())
+}
+
+// PrintUpdateCheck is a Cobra post run hook to print the results of an update
+// check. The message will be a non-nil pointer only if the update check
+// succeeds and should only have visible output if the message is non-empty.
+func PrintUpdateCheck(cmd *cobra.Command, args []string) {
+	if printMessage != nil && *printMessage != "" {
+		fmt.Fprintln(cmd.ErrOrStderr())
+		fmt.Fprintln(cmd.ErrOrStderr(), *printMessage)
+	}
+}
+
 func EnsureValidVersion(ctx context.Context, clientVersion, serverVersion *models.BuildVersionInfo) error {
 	if clientVersion == nil {
 		log.Ctx(ctx).Warn().Msg("Unable to parse nil client version, skipping version check")
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 4af4921349..61d8307461 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -12,7 +12,6 @@ import (
 
 	"github.com/bacalhau-project/bacalhau/pkg/config/configenv"
 	"github.com/bacalhau-project/bacalhau/pkg/config/types"
-	"github.com/bacalhau-project/bacalhau/pkg/telemetry"
 )
 
 const (
@@ -191,9 +190,6 @@ func initConfig(params initParams) (types.BacalhauConfig, error) {
 		return types.BacalhauConfig{}, err
 	}
 
-	// TODO this should be a part of the config.
-	telemetry.SetupFromEnvs()
-
 	return out, nil
 }
 
diff --git a/pkg/config/types/config.go b/pkg/config/types/config.go
index 1d9fd5232c..327a90e8bb 100644
--- a/pkg/config/types/config.go
+++ b/pkg/config/types/config.go
@@ -6,6 +6,8 @@ type BacalhauConfig struct {
 	Node    NodeConfig    `yaml:"Node"`
 	User    UserConfig    `yaml:"User"`
 	Metrics MetricsConfig `yaml:"Metrics"`
+
+	SkipUpdateCheck bool `yaml:"SkipUpdateCheck"`
 }
 
 type UserConfig struct {
diff --git a/pkg/config/types/generated_constants.go b/pkg/config/types/generated_constants.go
index cd59883b54..253305c100 100644
--- a/pkg/config/types/generated_constants.go
+++ b/pkg/config/types/generated_constants.go
@@ -120,3 +120,4 @@ const UserLibp2pKeyPath = "User.Libp2pKeyPath"
 const Metrics = "Metrics"
 const MetricsLibp2pTracerPath = "Metrics.Libp2pTracerPath"
 const MetricsEventTracerPath = "Metrics.EventTracerPath"
+const SkipUpdateCheck = "SkipUpdateCheck"
diff --git a/pkg/config/types/generated_viper_defaults.go b/pkg/config/types/generated_viper_defaults.go
index f6a4dd9880..2a0b3aea0f 100644
--- a/pkg/config/types/generated_viper_defaults.go
+++ b/pkg/config/types/generated_viper_defaults.go
@@ -123,6 +123,7 @@ func SetDefaults(cfg BacalhauConfig) {
 	viper.SetDefault(Metrics, cfg.Metrics)
 	viper.SetDefault(MetricsLibp2pTracerPath, cfg.Metrics.Libp2pTracerPath)
 	viper.SetDefault(MetricsEventTracerPath, cfg.Metrics.EventTracerPath)
+	viper.SetDefault(SkipUpdateCheck, cfg.SkipUpdateCheck)
 }
 
 func Set(cfg BacalhauConfig) {
@@ -244,4 +245,5 @@ func Set(cfg BacalhauConfig) {
 	viper.Set(Metrics, cfg.Metrics)
 	viper.Set(MetricsLibp2pTracerPath, cfg.Metrics.Libp2pTracerPath)
 	viper.Set(MetricsEventTracerPath, cfg.Metrics.EventTracerPath)
+	viper.Set(SkipUpdateCheck, cfg.SkipUpdateCheck)
 }
diff --git a/pkg/repo/fs.go b/pkg/repo/fs.go
index 64735d2dce..4a31c80578 100644
--- a/pkg/repo/fs.go
+++ b/pkg/repo/fs.go
@@ -19,6 +19,7 @@ import (
 
 	"github.com/bacalhau-project/bacalhau/pkg/config"
 	"github.com/bacalhau-project/bacalhau/pkg/storage/util"
+	"github.com/bacalhau-project/bacalhau/pkg/telemetry"
 )
 
 type RepoVersion struct {
@@ -123,6 +124,9 @@ func (fsr *FsRepo) Open() error {
 		return err
 	}
 
+	// TODO this should be a part of the config.
+	telemetry.SetupFromEnvs()
+
 	if cfg.User.KeyPath == "" {
 		// if the user has not specified the location of their user key via a config file, use the default value
 		cfg.User.KeyPath = filepath.Join(fsr.path, UserPrivateKeyFileName)
@@ -213,6 +217,9 @@ func (fsr *FsRepo) Init() error {
 	if err != nil {
 		return err
 	}
+
+	// TODO this should be a part of the config.
+	telemetry.SetupFromEnvs()
 	return fsr.writeVersion()
 }
 
diff --git a/pkg/version/update.go b/pkg/version/update.go
new file mode 100644
index 0000000000..fc47cfdfcf
--- /dev/null
+++ b/pkg/version/update.go
@@ -0,0 +1,65 @@
+package version
+
+import (
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/url"
+
+	"github.com/bacalhau-project/bacalhau/pkg/models"
+	"github.com/pkg/errors"
+)
+
+const endpoint = "http://update.bacalhau.org/version"
+
+type UpdateCheckResponse struct {
+	Version *models.BuildVersionInfo `json:"version"`
+	Message string                   `json:"message"`
+}
+
+func CheckForUpdate(
+	ctx context.Context,
+	currentClientVersion, currentServerVersion *models.BuildVersionInfo,
+	clientID string,
+) (*UpdateCheckResponse, error) {
+	u, err := url.Parse(endpoint)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to parse URL for update host")
+	}
+
+	q := u.Query()
+	q.Set("clientVersion", currentClientVersion.GitVersion)
+	if currentServerVersion.GitVersion != "" {
+		q.Set("serverVersion", currentServerVersion.GitVersion)
+	}
+	q.Set("operatingSystem", currentClientVersion.GOOS)
+	q.Set("architecture", currentClientVersion.GOARCH)
+	q.Set("userID", clientID)
+
+	u.RawQuery = q.Encode()
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to build HTTP request for update check")
+	}
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to fetch the latest version from the server")
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to read response body")
+	}
+
+	var updateCheck UpdateCheckResponse
+	err = json.Unmarshal(body, &updateCheck)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to unmarshal the server response when checking for updates")
+	}
+
+	return &updateCheck, nil
+}


Test patch:
diff --git a/cmd/cli/list/list_test.go b/cmd/cli/list/list_test.go
index ff9f601033..a3ca33b8e7 100644
--- a/cmd/cli/list/list_test.go
+++ b/cmd/cli/list/list_test.go
@@ -47,7 +47,6 @@ func (suite *ListSuite) TestList_NumberOfJobs() {
 		{numberOfJobs: 5, numberOfJobsOutput: 5},   // Test for 5 (less than default of 10)
 		{numberOfJobs: 20, numberOfJobsOutput: 10}, // Test for 20 (more than max of 10)
 		{numberOfJobs: 20, numberOfJobsOutput: 15}, // The default is 10 so test for non-default
-
 	}
 
 	for _, tc := range tests {
@@ -68,8 +67,7 @@ func (suite *ListSuite) TestList_NumberOfJobs() {
 				"--reverse", "false",
 			)
 			require.NoError(suite.T(), err)
-
-			require.Equal(suite.T(), tc.numberOfJobsOutput, strings.Count(out, "\n"))
+			require.Equal(suite.T(), tc.numberOfJobsOutput, strings.Count(out, "\n"), out)
 		})
 	}
 }
diff --git a/cmd/cli/version/version_test.go b/cmd/cli/version/version_test.go
index fbc6594b48..5aeb12501d 100644
--- a/cmd/cli/version/version_test.go
+++ b/cmd/cli/version/version_test.go
@@ -19,11 +19,11 @@ import (
 	"fmt"
 	"testing"
 
+	"github.com/bacalhau-project/bacalhau/cmd/util"
 	"github.com/bacalhau-project/bacalhau/pkg/lib/marshaller"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/suite"
 
-	"github.com/bacalhau-project/bacalhau/cmd/cli/version"
 	cmdtesting "github.com/bacalhau-project/bacalhau/cmd/testing"
 	"github.com/bacalhau-project/bacalhau/cmd/util/output"
 )
@@ -57,7 +57,7 @@ func (suite *VersionSuite) TestVersionJSONOutput() {
 	)
 	require.NoError(suite.T(), err, "Could not request version with json output.")
 
-	jsonDoc := &version.Versions{}
+	jsonDoc := &util.Versions{}
 	err = marshaller.JSONUnmarshalWithMax([]byte(out), &jsonDoc)
 	require.NoError(suite.T(), err, "Could not unmarshall the output into json - %+v", err)
 	require.Equal(suite.T(), jsonDoc.ClientVersion.GitCommit, jsonDoc.ServerVersion.GitCommit, "Client and Server do not match in json.")
@@ -71,7 +71,7 @@ func (suite *VersionSuite) TestVersionYAMLOutput() {
 	)
 	require.NoError(suite.T(), err, "Could not request version with json output.")
 
-	yamlDoc := &version.Versions{}
+	yamlDoc := &util.Versions{}
 	err = marshaller.YAMLUnmarshalWithMax([]byte(out), &yamlDoc)
 	require.NoError(suite.T(), err, "Could not unmarshall the output into yaml - %+v", err)
 	require.Equal(suite.T(), yamlDoc.ClientVersion.GitCommit, yamlDoc.ServerVersion.GitCommit, "Client and Server do not match in yaml.")
diff --git a/cmd/testing/base.go b/cmd/testing/base.go
index 6861edd47c..6c239e1182 100644
--- a/cmd/testing/base.go
+++ b/cmd/testing/base.go
@@ -3,11 +3,14 @@ package cmdtesting
 import (
 	"context"
 	"fmt"
+	"os"
 	"time"
 
 	"github.com/stretchr/testify/suite"
 
 	"github.com/bacalhau-project/bacalhau/pkg/bidstrategy/semantic"
+	"github.com/bacalhau-project/bacalhau/pkg/config"
+	"github.com/bacalhau-project/bacalhau/pkg/config/types"
 	"github.com/bacalhau-project/bacalhau/pkg/publicapi/client"
 	clientv2 "github.com/bacalhau-project/bacalhau/pkg/publicapi/client/v2"
 
@@ -33,6 +36,8 @@ func (s *BaseSuite) SetupTest() {
 	logger.ConfigureTestLogging(s.T())
 	util.Fatal = util.FakeFatalErrorHandler
 
+	os.Setenv(config.KeyAsEnvVar(types.SkipUpdateCheck), "1")
+
 	computeConfig, err := node.NewComputeConfigWith(node.ComputeConfigParams{
 		JobSelectionPolicy: node.JobSelectionPolicy{
 			Locality: semantic.Anywhere,