diff --git a/cli/root.go b/cli/root.go index b0e9ec715ddb9..34d9cb1473c91 100644 --- a/cli/root.go +++ b/cli/root.go @@ -417,6 +417,17 @@ func isTTY(cmd *cobra.Command) bool { // This accepts a reader to work with Cobra's "OutOrStdout" // function for simple testing. func isTTYOut(cmd *cobra.Command) bool { + return isTTYWriter(cmd, cmd.OutOrStdout) +} + +// isTTYErr returns whether the passed reader is a TTY or not. +// This accepts a reader to work with Cobra's "ErrOrStderr" +// function for simple testing. +func isTTYErr(cmd *cobra.Command) bool { + return isTTYWriter(cmd, cmd.ErrOrStderr) +} + +func isTTYWriter(cmd *cobra.Command, writer func() io.Writer) bool { // If the `--force-tty` command is available, and set, // assume we're in a tty. This is primarily for cases on Windows // where we may not be able to reliably detect this automatically (ie, tests) @@ -424,7 +435,7 @@ func isTTYOut(cmd *cobra.Command) bool { if forceTty && err == nil { return true } - file, ok := cmd.OutOrStdout().(*os.File) + file, ok := writer().(*os.File) if !ok { return false } diff --git a/cli/ssh.go b/cli/ssh.go index 7d5b25337b3fb..f4dcce3180395 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net/url" "os" "path/filepath" "strings" @@ -72,6 +73,11 @@ func ssh() *cobra.Command { return err } + updateWorkspaceBanner, outdated := verifyWorkspaceOutdated(client, workspace) + if outdated && isTTYErr(cmd) { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), updateWorkspaceBanner) + } + // OpenSSH passes stderr directly to the calling TTY. // This is required in "stdio" mode so a connecting indicator can be displayed. err = cliui.Agent(ctx, cmd.ErrOrStderr(), cliui.AgentOptions{ @@ -343,3 +349,18 @@ func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID u return deadline.Truncate(time.Minute), callback } } + +// Verify if the user workspace is outdated and prepare an actionable message for user. +func verifyWorkspaceOutdated(client *codersdk.Client, workspace codersdk.Workspace) (string, bool) { + if !workspace.Outdated { + return "", false // workspace is up-to-date + } + + workspaceLink := buildWorkspaceLink(client.URL, workspace) + return fmt.Sprintf("👋 Your workspace is outdated! Update it here: %s\n", workspaceLink), true +} + +// Build the user workspace link which navigates to the Coder web UI. +func buildWorkspaceLink(serverURL *url.URL, workspace codersdk.Workspace) *url.URL { + return serverURL.ResolveReference(&url.URL{Path: fmt.Sprintf("@%s/%s", workspace.OwnerName, workspace.Name)}) +} diff --git a/cli/ssh_internal_test.go b/cli/ssh_internal_test.go new file mode 100644 index 0000000000000..d9624f393dfa6 --- /dev/null +++ b/cli/ssh_internal_test.go @@ -0,0 +1,58 @@ +package cli + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/codersdk" +) + +const ( + fakeOwnerName = "fake-owner-name" + fakeServerURL = "https://fake-foo-url" + fakeWorkspaceName = "fake-workspace-name" +) + +func TestVerifyWorkspaceOutdated(t *testing.T) { + t.Parallel() + + serverURL, err := url.Parse(fakeServerURL) + require.NoError(t, err) + + client := codersdk.Client{URL: serverURL} + + t.Run("Up-to-date", func(t *testing.T) { + t.Parallel() + + workspace := codersdk.Workspace{Name: fakeWorkspaceName, OwnerName: fakeOwnerName} + + _, outdated := verifyWorkspaceOutdated(&client, workspace) + + assert.False(t, outdated, "workspace should be up-to-date") + }) + t.Run("Outdated", func(t *testing.T) { + t.Parallel() + + workspace := codersdk.Workspace{Name: fakeWorkspaceName, OwnerName: fakeOwnerName, Outdated: true} + + updateWorkspaceBanner, outdated := verifyWorkspaceOutdated(&client, workspace) + + assert.True(t, outdated, "workspace should be outdated") + assert.NotEmpty(t, updateWorkspaceBanner, "workspace banner should be present") + }) +} + +func TestBuildWorkspaceLink(t *testing.T) { + t.Parallel() + + serverURL, err := url.Parse(fakeServerURL) + require.NoError(t, err) + + workspace := codersdk.Workspace{Name: fakeWorkspaceName, OwnerName: fakeOwnerName} + workspaceLink := buildWorkspaceLink(serverURL, workspace) + + assert.Equal(t, workspaceLink.String(), fakeServerURL+"/@"+fakeOwnerName+"/"+fakeWorkspaceName) +}