Skip to content
Merged
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
1 change: 1 addition & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ words:
- protoimpl
- Retryable
- runcontext
- surveyterm
- unmarshals
- unmarshaling
- unsetting
Expand Down
3 changes: 2 additions & 1 deletion cli/azd/cmd/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/azure/azure-dev/cli/azd/internal/cmd"
"github.com/azure/azure-dev/cli/azd/internal/grpcserver"
"github.com/azure/azure-dev/cli/azd/internal/repository"
"github.com/azure/azure-dev/cli/azd/internal/terminal"
"github.com/azure/azure-dev/cli/azd/pkg/account"
"github.com/azure/azure-dev/cli/azd/pkg/ai"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
Expand Down Expand Up @@ -132,7 +133,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) {
}

isTerminal := cmd.OutOrStdout() == os.Stdout &&
cmd.InOrStdin() == os.Stdin && input.IsTerminal(os.Stdout.Fd(), os.Stdin.Fd())
cmd.InOrStdin() == os.Stdin && terminal.IsTerminal(os.Stdout.Fd(), os.Stdin.Fd())

return input.NewConsole(rootOptions.NoPrompt, isTerminal, input.Writers{Output: writer}, input.ConsoleHandles{
Stdin: cmd.InOrStdin(),
Expand Down
30 changes: 30 additions & 0 deletions cli/azd/internal/terminal/terminal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package terminal

import (
"os"
"strconv"

"github.com/azure/azure-dev/cli/azd/internal/tracing/resource"
"github.com/mattn/go-isatty"
)

// IsTerminal returns true if the given file descriptors are attached to a terminal,
// taking into account of environment variables that force TTY behavior.
func IsTerminal(stdoutFd uintptr, stdinFd uintptr) bool {
// User override to force TTY behavior
if forceTty, err := strconv.ParseBool(os.Getenv("AZD_FORCE_TTY")); err == nil {
return forceTty
}

// By default, detect if we are running on CI and force no TTY mode if we are.
// If this is affecting you locally while debugging on a CI machine,
// use the override AZD_FORCE_TTY=true.
if resource.IsRunningOnCI() {
return false
}

return isatty.IsTerminal(stdoutFd) && isatty.IsTerminal(stdinFd)
}
30 changes: 5 additions & 25 deletions cli/azd/pkg/input/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,14 @@ import (
"time"

"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
surveyterm "github.com/AlecAivazis/survey/v2/terminal"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/azure/azure-dev/cli/azd/internal/tracing"
"github.com/azure/azure-dev/cli/azd/internal/tracing/resource"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"github.com/azure/azure-dev/cli/azd/pkg/output/ux"
tm "github.com/buger/goterm"
"github.com/mattn/go-isatty"
"github.com/nathan-fiscaletti/consolesize-go"
"github.com/theckman/yacspin"
"go.uber.org/atomic"
Expand Down Expand Up @@ -600,7 +598,7 @@ func (c *AskerConsole) Prompt(ctx context.Context, options ConsoleOptions) (stri

result, err := c.promptClient.Prompt(ctx, opts)
if errors.Is(err, promptCancelledErr) {
return "", terminal.InterruptErr
return "", surveyterm.InterruptErr
} else if err != nil {
return "", err
}
Expand Down Expand Up @@ -655,7 +653,7 @@ func (c *AskerConsole) Select(ctx context.Context, options ConsoleOptions) (int,

result, err := c.promptClient.Prompt(ctx, opts)
if errors.Is(err, promptCancelledErr) {
return -1, terminal.InterruptErr
return -1, surveyterm.InterruptErr
} else if err != nil {
return -1, err
}
Expand Down Expand Up @@ -735,7 +733,7 @@ func (c *AskerConsole) MultiSelect(ctx context.Context, options ConsoleOptions)

result, err := c.promptClient.Prompt(ctx, opts)
if errors.Is(err, promptCancelledErr) {
return nil, terminal.InterruptErr
return nil, surveyterm.InterruptErr
} else if err != nil {
return nil, err
}
Expand Down Expand Up @@ -802,7 +800,7 @@ func (c *AskerConsole) Confirm(ctx context.Context, options ConsoleOptions) (boo

result, err := c.promptClient.Prompt(ctx, opts)
if errors.Is(err, promptCancelledErr) {
return false, terminal.InterruptErr
return false, surveyterm.InterruptErr
} else if err != nil {
return false, err
}
Expand Down Expand Up @@ -1023,24 +1021,6 @@ func NewConsole(
return c
}

// IsTerminal returns true if the given file descriptors are attached to a terminal,
// taking into account of environment variables that force TTY behavior.
func IsTerminal(stdoutFd uintptr, stdinFd uintptr) bool {
// User override to force TTY behavior
if forceTty, err := strconv.ParseBool(os.Getenv("AZD_FORCE_TTY")); err == nil {
return forceTty
}

// By default, detect if we are running on CI and force no TTY mode if we are.
// If this is affecting you locally while debugging on a CI machine,
// use the override AZD_FORCE_TTY=true.
if resource.IsRunningOnCI() {
return false
}

return isatty.IsTerminal(stdoutFd) && isatty.IsTerminal(stdinFd)
}

func GetStepResultFormat(result error) SpinnerUxType {
formatResult := StepDone
if result != nil {
Expand Down
9 changes: 9 additions & 0 deletions cli/azd/pkg/output/colors.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strconv"
"strings"

"github.com/azure/azure-dev/cli/azd/internal/terminal"
"github.com/charmbracelet/glamour"
"github.com/fatih/color"
"github.com/nathan-fiscaletti/consolesize-go"
Expand Down Expand Up @@ -95,7 +96,15 @@ func WithMarkdown(markdownText string) string {
}

// WithHyperlink wraps text with the colored hyperlink format escape sequence.
// When stdout is not a terminal (e.g., in CI/CD pipelines like GitHub Actions),
// it returns the plain URL without escape codes to avoid displaying raw ANSI sequences.
func WithHyperlink(url string, text string) string {
// Check if stdout is a terminal
if !terminal.IsTerminal(os.Stdout.Fd(), os.Stdin.Fd()) {
// Not a terminal - return plain URL without escape codes
return url
}
// Terminal - use hyperlink escape codes
return WithLinkFormat(fmt.Sprintf("\033]8;;%s\007%s\033]8;;\007", url, text))
}

Expand Down
65 changes: 65 additions & 0 deletions cli/azd/pkg/output/colors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package output

import (
"os"
"testing"

"github.com/stretchr/testify/require"
)

func TestWithHyperlink(t *testing.T) {
tests := []struct {
name string
url string
text string
expectEscape bool
expectedPlain string
}{
{
name: "URL and text are the same",
url: "https://example.com",
text: "https://example.com",
expectEscape: true,
expectedPlain: "https://example.com",
},
{
name: "URL and text are different",
url: "https://example.com",
text: "Example Site",
expectEscape: true,
expectedPlain: "https://example.com",
},
{
name: "Text is empty",
url: "https://example.com",
text: "",
expectEscape: true,
expectedPlain: "https://example.com",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test non-terminal mode by checking the actual output
// Since we can't reliably set os.Stdout.Fd() to non-terminal in tests,
// we'll just verify the function doesn't panic and returns a string
result := WithHyperlink(tt.url, tt.text)
require.NotEmpty(t, result)

// When running in a non-TTY environment (like most CI systems),
// the result should be the plain text version
if !isTTY() {
require.Equal(t, tt.expectedPlain, result)
}
})
}
}

// isTTY checks if stdout is a TTY (for testing purposes)
func isTTY() bool {
fileInfo, _ := os.Stdout.Stat()
return (fileInfo.Mode() & os.ModeCharDevice) != 0
}
10 changes: 10 additions & 0 deletions cli/azd/pkg/ux/ux.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"unicode/utf8"

"github.com/azure/azure-dev/cli/azd/internal/terminal"
"github.com/azure/azure-dev/cli/azd/pkg/ux/internal"
"github.com/fatih/color"
"github.com/nathan-fiscaletti/consolesize-go"
Expand All @@ -26,11 +27,20 @@ func init() {
}

// Hyperlink returns a hyperlink formatted string.
// When stdout is not a terminal (e.g., in CI/CD pipelines like GitHub Actions),
// it returns the plain URL without escape codes to avoid displaying raw ANSI sequences.
func Hyperlink(url string, text ...string) string {
if len(text) == 0 {
text = []string{url}
}

// Check if stdout is a terminal
if !terminal.IsTerminal(os.Stdout.Fd(), os.Stdin.Fd()) {
// Not a terminal - return plain URL without escape codes
return url
}

// Terminal - use hyperlink escape codes
return fmt.Sprintf("\033]8;;%s\007%s\033]8;;\007", url, text[0])
}

Expand Down
64 changes: 64 additions & 0 deletions cli/azd/pkg/ux/ux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,73 @@
package ux

import (
"os"
"testing"
)

func TestHyperlink(t *testing.T) {
tests := []struct {
name string
url string
text []string
expectEscape bool
expectedPlain string
}{
{
name: "URL only",
url: "https://example.com",
text: nil,
expectEscape: true,
expectedPlain: "https://example.com",
},
{
name: "URL and text are the same",
url: "https://example.com",
text: []string{"https://example.com"},
expectEscape: true,
expectedPlain: "https://example.com",
},
{
name: "URL and text are different",
url: "https://example.com",
text: []string{"Example Site"},
expectEscape: true,
expectedPlain: "https://example.com",
},
{
name: "Text is empty string",
url: "https://example.com",
text: []string{""},
expectEscape: true,
expectedPlain: "https://example.com",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test non-terminal mode by checking the actual output
result := Hyperlink(tt.url, tt.text...)
if len(result) == 0 {
t.Errorf("expected non-empty result")
}

// When running in a non-TTY environment (like most CI systems),
// the result should be the plain text version
if !isTTY() {
if result != tt.expectedPlain {
t.Errorf("expected %q, got %q", tt.expectedPlain, result)
}
}
})
}
}

// isTTY checks if stdout is a TTY (for testing purposes)
func isTTY() bool {
fileInfo, _ := os.Stdout.Stat()
return (fileInfo.Mode() & os.ModeCharDevice) != 0
}

func Test_CountLineBreaks(t *testing.T) {
testCases := []struct {
name string
Expand Down
Loading