Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 36 additions & 21 deletions experimental/ssh/internal/client/agentshim.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
"time"

"github.com/databricks/cli/libs/auth"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/env"
"github.com/databricks/cli/libs/log"
"github.com/databricks/databricks-sdk-go"
Expand Down Expand Up @@ -349,26 +348,38 @@ func ensureToolchain(ctx context.Context, home string) error {
return nil
}

ui := newProgressUI(ctx)

// 1. uv (installs into ~/.local/bin).
if _, err := exec.LookPath("uv"); err != nil {
cmdio.LogString(ctx, "Installing uv...")
if err := runShell(ctx, "curl -LsSf https://astral.sh/uv/install.sh | sh"); err != nil {
return fmt.Errorf("failed to install uv: %w", err)
if err := ui.runStep(ctx, "Installing dependencies", func(out io.Writer) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] First step label is vaguer than the siblings and than the error it wraps

The uv step is shown as "Installing dependencies" while the next two steps are "Installing Unity Gateway CLI" and "Installing Node.js". The old message was "Installing uv...", and the error returned from the same closure is still failed to install uv. On failure the user sees ✗ Installing dependencies followed by Error: failed to install uv: ..., which names two different things. The other labels name the tool; this one does not.

Suggestion: Use "Installing uv" (or "Installing uv..." if you want to keep the old wording).

if err := runShell(ctx, out, "curl -LsSf https://astral.sh/uv/install.sh | sh"); err != nil {
return fmt.Errorf("failed to install uv: %w", err)
}
return nil
}); err != nil {
return err
}
}

// 2. Unity Gateway CLI (pinned stock upstream release).
if _, err := exec.LookPath("ucode"); err != nil {
cmdio.LogString(ctx, "Installing Unity Gateway CLI...")
if err := runCommand(ctx, "uv", "tool", "install", "git+https://github.com/"+ugRepo+"@"+ugVersion); err != nil {
return fmt.Errorf("failed to install Unity Gateway CLI: %w", err)
if err := ui.runStep(ctx, "Installing Unity Gateway CLI", func(out io.Writer) error {
if err := runCommand(ctx, out, "uv", "tool", "install", "git+https://github.com/"+ugRepo+"@"+ugVersion); err != nil {
return fmt.Errorf("failed to install Unity Gateway CLI: %w", err)
}
return nil
}); err != nil {
return err
}
}

// 3. Node/npm (installs into depsDir/node/bin)
if _, err := exec.LookPath("npm"); err != nil {
cmdio.LogString(ctx, "Installing npm...")
if _, err := ensureNode(ctx, home); err != nil {
if err := ui.runStep(ctx, "Installing Node.js", func(out io.Writer) error {
_, err := ensureNode(ctx, home, out)
return err
}); err != nil {
return err
}
}
Expand Down Expand Up @@ -469,9 +480,10 @@ func injectAgentContext(ctx context.Context, home string, agent agentSpec) ([]st
}

// download the latest Krypton LTS Node into deps/node once, returning its bin.
// Linux-only by design: the shim runs on the serverless driver, so the tarball
// name is hardcoded to linux while nodeDownloadArch guards the arch.
func ensureNode(ctx context.Context, home string) (string, error) {
// Extraction output is written to out. Linux-only by design: the shim runs on
// the serverless driver, so the tarball name is hardcoded to linux while
// nodeDownloadArch guards the arch.
func ensureNode(ctx context.Context, home string, out io.Writer) (string, error) {
depsRoot := filepath.Join(home, depsDir)
nodeDir := filepath.Join(depsRoot, "node")
nodeBin := filepath.Join(nodeDir, "bin")
Expand Down Expand Up @@ -506,7 +518,7 @@ func ensureNode(ctx context.Context, home string) (string, error) {
}
defer os.RemoveAll(tmpDir) // no-op once renamed; cleans up a failed extraction
// Node's .tar.xz is the smallest download; extract it with the system tar.
if err := runCommand(ctx, "tar", "-xJf", tarball, "--strip-components=1", "-C", tmpDir); err != nil {
if err := runCommand(ctx, out, "tar", "-xJf", tarball, "--strip-components=1", "-C", tmpDir); err != nil {
return "", fmt.Errorf("failed to extract Node.js: %w", err)
}
// Clear any partial leftover from a previously-interrupted run, then publish
Expand Down Expand Up @@ -608,19 +620,22 @@ func npmGlobalPrefix(ctx context.Context) string {
return strings.TrimSpace(string(out))
}

func runCommand(ctx context.Context, name string, args ...string) error {
// runCommand runs name with the given args, writing combined stdout+stderr to out
// (captured so it surfaces only on failure) and inheriting the process env. Stdin
// is left closed: the shim's install steps are non-interactive.
func runCommand(ctx context.Context, out io.Writer, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdout = out
cmd.Stderr = out
return cmd.Run()
}

func runShell(ctx context.Context, script string) error {
// runShell runs a shell snippet — for the curl|sh / curl|tar pipelines — writing
// combined stdout+stderr to out.
Comment on lines +633 to +634

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] runShell comment mentions a curl|tar pipeline that no longer exists

The new comment says runShell is for the curl|sh / curl|tar pipelines. The only caller is the uv curl … | sh line. Node extraction goes through runCommand(..., "tar", ...) in ensureNode, not runShell.

Suggestion: Drop the curl|tar mention; say it runs the uv curl|sh install (or "a shell snippet").

func runShell(ctx context.Context, out io.Writer, script string) error {
cmd := exec.CommandContext(ctx, "sh", "-c", script)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdout = out
cmd.Stderr = out
return cmd.Run()
}

Expand Down
68 changes: 68 additions & 0 deletions experimental/ssh/internal/client/progress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package client

import (
"bytes"
"context"
"fmt"
"io"
"os"
"strings"

"github.com/charmbracelet/lipgloss"
"github.com/databricks/cli/libs/cmdio"
)

// progressUI renders the bootstrap steps: each step spins while it runs and
// leaves a checkmark line when it finishes. Subprocess output is captured per
// step and printed only when that step fails.
type progressUI struct {
w io.Writer
check string // styled "✓" prefix for a finished step
cross string // styled "✗" prefix for a failed step
}

// newProgressUI builds a progress renderer writing its checkmark lines to stderr.
func newProgressUI(ctx context.Context) *progressUI {
// Mint the checkmark styles from a renderer targeting stderr so color handling
// stays centralized in cmdio.NewRenderer, matching cmdio's own spinner.
r, _ := cmdio.NewRenderer(ctx, os.Stderr)
return &progressUI{
w: os.Stderr,
check: r.NewStyle().Foreground(lipgloss.Color("10")).Render("✓"), // green
cross: r.NewStyle().Foreground(lipgloss.Color("9")).Render("✗"), // red
}
Comment on lines +25 to +33

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Outcome lines write to os.Stderr while the spinner writes to cmdio's stderr

runStep starts cmdio.NewSpinner(ctx), which paints on c.err from context (cmd.ErrOrStderr()). Check/cross lines and the failure dump go to a hardcoded os.Stderr minted here. In the default CLI process those are the same fd, so this works today. They diverge as soon as cobra's error writer is not os.Stderr (tests using SetErr, a wrapped stderr, NewTestContextWithStderr). Color is also decided on os.Stderr (cmdio.NewRenderer(ctx, os.Stderr)) while spinner interactivity is decided on c.err. The sandbox spinner wrapper prints the post-spinner mark with cmdio.LogString so both phases share one stream. The progressUI.w seam exists so tests can avoid MockDiscard's discarded stderr; cmdio.NewTestContextWithStderr already gives a capturable, non-TTY stderr and would make the split unnecessary.

Suggestion: Print the ✓/✗ line (and the failure dump) through cmdio, e.g. cmdio.LogString for the status line and LogString of the trimmed capture for the dump, and drive tests with NewTestContextWithStderr instead of injecting os.Stderr. If a raw writer is kept, initialize w from the same stream the spinner uses rather than os.Stderr.

}

// runStep shows a cmdio spinner labelled desc while fn runs, giving fn a writer
// that captures the step's subprocess output. On success it leaves a checkmark
// line; on failure it prints the captured output (stdout+stderr, in order) before
// returning fn's error. The shared spinner shows elapsed time and degrades to no
// output in a non-interactive terminal, so the checkmark line is what the reader
// sees either way.
func (ui *progressUI) runStep(ctx context.Context, desc string, fn func(out io.Writer) error) error {
sp := cmdio.NewSpinner(ctx, cmdio.WithElapsedTime())
// Close is idempotent; defer it so a panic in fn can't leave the spinner (and
// its tea-program slot) running, while the explicit Close below still controls
// output ordering on the normal path.
defer sp.Close()
sp.Update(desc)

var buf bytes.Buffer
err := fn(&buf)
// Stop the spinner (clearing its line) before printing the step's outcome.
sp.Close()

if err != nil {
fmt.Fprintln(ui.w, ui.cross+" "+desc)
if out := buf.String(); out != "" {
fmt.Fprint(ui.w, out)
if !strings.HasSuffix(out, "\n") {
fmt.Fprintln(ui.w)
}
}
return err
}

fmt.Fprintln(ui.w, ui.check+" "+desc)
return nil
}
53 changes: 53 additions & 0 deletions experimental/ssh/internal/client/progress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package client

import (
"bytes"
"errors"
"io"
"testing"

"github.com/databricks/cli/libs/cmdio"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// newTestProgressUI returns a progressUI writing to w with sentinel check/cross
// markers so the emitted outcome line is assertable. runStep is driven with a
// non-interactive cmdio context (the mode tests run in), where the spinner
// degrades to no output, exercising the capture-and-dump logic directly.
func newTestProgressUI(w io.Writer) *progressUI {
return &progressUI{w: w, check: "OK", cross: "FAIL"}
}

func TestRunStepHidesOutputOnSuccess(t *testing.T) {
var w bytes.Buffer
ui := newTestProgressUI(&w)

err := ui.runStep(cmdio.MockDiscard(t.Context()), "Installing dependencies", func(out io.Writer) error {
_, _ = io.WriteString(out, "verbose installer chatter\n")
return nil
})
require.NoError(t, err)

// The step's subprocess output must not surface on success, but the checkmark
// line for the step is still emitted.
assert.NotContains(t, w.String(), "verbose installer chatter")
assert.Contains(t, w.String(), "OK Installing dependencies")
}

func TestRunStepShowsOutputOnFailure(t *testing.T) {
var w bytes.Buffer
ui := newTestProgressUI(&w)

sentinel := errors.New("install failed")
err := ui.runStep(cmdio.MockDiscard(t.Context()), "Installing ucode", func(out io.Writer) error {
_, _ = io.WriteString(out, "line to stdout\nline to stderr")
return sentinel
})
require.ErrorIs(t, err, sentinel)

// On failure the cross line and the full captured output are printed, with a
// trailing newline added.
assert.Contains(t, w.String(), "FAIL Installing ucode")
assert.Contains(t, w.String(), "line to stdout\nline to stderr\n")
}
Loading