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
12 changes: 8 additions & 4 deletions .github/workflows/desktop-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,25 +76,29 @@ jobs:

- name: Build CLI (unix)
if: runner.os != 'Windows'
env:
CGO_ENABLED: "0"
run: |
if [ "${{ matrix.go-arch }}" = "universal" ]; then
# macOS universal binary: build both architectures and lipo them
GOOS=darwin GOARCH=amd64 go build -o devsy-amd64 .
GOOS=darwin GOARCH=arm64 go build -o devsy-arm64 .
GOOS=darwin GOARCH=amd64 go build -tags netgo,osusergo -o devsy-amd64 .
GOOS=darwin GOARCH=arm64 go build -tags netgo,osusergo -o devsy-arm64 .
lipo -create -output desktop/resources/bin/devsy devsy-amd64 devsy-arm64
rm devsy-amd64 devsy-arm64
else
GOOS=${{ matrix.go-os }} GOARCH=${{ matrix.go-arch }} go build -o desktop/resources/bin/devsy .
GOOS=${{ matrix.go-os }} GOARCH=${{ matrix.go-arch }} go build -tags netgo,osusergo -o desktop/resources/bin/devsy .
fi
chmod +x desktop/resources/bin/devsy

- name: Build CLI (windows)
if: runner.os == 'Windows'
shell: pwsh
env:
CGO_ENABLED: "0"
run: |
$env:GOOS = "windows"
$env:GOARCH = "amd64"
go build -o desktop/resources/bin/devsy.exe .
go build -tags netgo,osusergo -o desktop/resources/bin/devsy.exe .

- name: Configure Windows build tools
if: runner.os == 'Windows'
Expand Down
8 changes: 5 additions & 3 deletions cmd/env_flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const (
envDevsyHost = "DEVSY_HOST"
envDevsyProject = "DEVSY_PROJECT"
cmdWorkspace = "workspace"
cmdProvider = "provider"
cmdList = "list"
)

// TestOptInEnvFlags_AppliesEnvValueToFlag verifies that for each opt-in flag,
Expand All @@ -29,7 +31,7 @@ func TestOptInEnvFlags_AppliesEnvValueToFlag(t *testing.T) {
}{
{flagName: "home", envName: "DEVSY_HOME", want: "/tmp/h", persistent: true},
{flagName: "context", envName: "DEVSY_CONTEXT", want: "ctx-a", persistent: true},
{flagName: "provider", envName: "DEVSY_PROVIDER", want: "docker", persistent: true},
{flagName: cmdProvider, envName: "DEVSY_PROVIDER", want: "docker", persistent: true},
{flagName: "debug", envName: "DEVSY_DEBUG", want: "true", persistent: true},
{
cmdPath: "pro workspace list",
Expand Down Expand Up @@ -110,7 +112,7 @@ func TestOptInEnvFlags_CLIOverridesEnv(t *testing.T) {
seen, _ = c.Flags().GetString("host")
return nil
}
rootCmd.SetArgs([]string{"pro", cmdWorkspace, "list", "--host", "cli-value"})
rootCmd.SetArgs([]string{"pro", cmdWorkspace, cmdList, "--host", "cli-value"})
require.NoError(t, rootCmd.Execute())
assert.Equal(t, "cli-value", seen, "CLI flag must override env value")
}
Expand All @@ -128,7 +130,7 @@ func TestOptInEnvFlags_EnvSatisfiesRequired(t *testing.T) {
seen, _ = c.Flags().GetString("host")
return nil
}
rootCmd.SetArgs([]string{"pro", cmdWorkspace, "list"})
rootCmd.SetArgs([]string{"pro", cmdWorkspace, cmdList})
require.NoError(t, rootCmd.Execute(), "required --host must be satisfied by env")
assert.Equal(t, "from-env.example.com", seen)
}
Expand Down
1 change: 0 additions & 1 deletion cmd/flag_aliases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
const (
flagConfig = "--config"
flagDevcontainerPath = "devcontainer-path"
flagLogFormat = "--log-format"
flagOverrideConfig = "--override-config"
flagExtraDevContainerPath = "extra-devcontainer-path"
flagDotfilesRepository = "--dotfiles-repository"
Expand Down
171 changes: 133 additions & 38 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/exec"
"strings"

"github.com/devsy-org/devsy/cmd/completion"
cliconfig "github.com/devsy-org/devsy/cmd/config"
Expand All @@ -24,19 +25,25 @@ import (
"github.com/devsy-org/devsy/pkg/config"
cliErrors "github.com/devsy-org/devsy/pkg/errors"
"github.com/devsy-org/devsy/pkg/exitcode"
"github.com/devsy-org/devsy/pkg/flatpak"
"github.com/devsy-org/devsy/pkg/log"
"github.com/devsy-org/devsy/pkg/telemetry"
"github.com/devsy-org/devsy/pkg/version"
"github.com/devsy-org/devsy/pkg/workspace"
"github.com/go-logr/logr"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
"k8s.io/klog/v2"
)

const (
logOutputJSON = "json"
logOutputLogfmt = "logfmt"
logOutputText = "text"

flagLogOutput = "--log-output"
flagLogFormat = "--log-format"

groupCore = "core"
groupConfig = "config"
Expand All @@ -48,6 +55,8 @@ const (
// feature is not ready for general use; set DEVSY_PRO_ENABLED=true to
// expose it (e.g. for internal testing).
envProEnabled = "DEVSY_PRO_ENABLED"

internalCommand = "internal"
)

func proEnabled() bool {
Expand All @@ -61,23 +70,60 @@ func isMachineLogFormat(format string) bool {
return format == logOutputJSON || format == logOutputLogfmt
}

// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func logOutputFromArgs(args []string) string {
for i, arg := range args {
for _, name := range []string{flagLogOutput, flagLogFormat} {
if val, ok := strings.CutPrefix(arg, name+"="); ok {
return val
}
if arg == name && i+1 < len(args) {
return args[i+1]
}
}
}
return ""
}

func isMachineConsumer(logOutput string, isInternal bool) bool {
switch {
case isInternal:
return true
case os.Getenv(config.EnvUI) == config.BoolTrue:
return true
case logOutput != "":
return isMachineLogFormat(logOutput)
default:
return !term.IsTerminal(int(os.Stderr.Fd())) //nolint:gosec // fd fits in int
}
}

func Execute() {
rootCmd, globalFlags := BuildRoot()
os.Exit(run())
}

// Bootstrap pre-Execute so subcommands that override PersistentPreRunE
// without chaining (e.g. pro, agent) still get telemetry.
func run() int {
rootCmd, globalFlags := BuildRoot()
target := rootCmd
if found, _, findErr := rootCmd.Find(os.Args[1:]); findErr == nil && found != nil {
target = found
}
collector := telemetry.BootstrapCLI(target)
rootCmd.SetContext(telemetry.WithCollector(gocontext.Background(), collector))
defer func() { collector.Flush() }()

err := rootCmd.Execute()
isInternal := topLevelCommand(target) == internalCommand
machineMode := configureOutput(rootCmd, globalFlags, isInternal)

if !isInternal {
if shouldExit, err := flatpak.ReexecOnHost(); err != nil {
collector.RecordCLI(err)
return exitCodeForError(err, machineMode)
} else if shouldExit {
return 0
}
}

// Re-apply opt-out post-Execute for the same PreRunE-bypass case.
err := rootCmd.Execute()
if devsyConfig, cfgErr := config.LoadConfig(
globalFlags.Context,
globalFlags.Provider,
Expand All @@ -86,44 +132,91 @@ func Execute() {
}

collector.RecordCLI(err)
collector.Flush()
if err != nil {
//nolint:all
if sshExitErr, ok := err.(*ssh.ExitError); ok {
log.Errorf("SSH command failed with exit code %d", sshExitErr.ExitStatus())
os.Exit(sshExitErr.ExitStatus())
return exitCodeForError(err, machineMode)
}
return 0
}

func configureOutput(
rootCmd *cobra.Command,
globalFlags *flags.GlobalFlags,
isInternal bool,
) bool {
logOutput := logOutputFromArgs(os.Args[1:])
machineMode := isMachineConsumer(logOutput, isInternal)
rootCmd.SilenceErrors = machineMode
rootCmd.SilenceUsage = machineMode

format := logOutput
if format == "" {
format = logOutputText
}
log.Init(log.Config{
Verbosity: globalFlags.Verbosity,
Quiet: globalFlags.Quiet,
Debug: globalFlags.Debug,
Format: format,
})
return machineMode
}

func topLevelCommand(cmd *cobra.Command) string {
if cmd == nil {
return ""
}
for cmd.HasParent() {
if !cmd.Parent().HasParent() {
return cmd.Name()
}
cmd = cmd.Parent()
}
return ""
}

func exitCodeForError(err error, machineMode bool) int {
if err == nil {
return 0
}

if code, ok := passthroughExitCode(err, machineMode); ok {
return code
}

renderCLIError(err, machineMode)
if errors.Is(err, workspace.ErrWorkspaceNotFound) {
return exitcode.WorkspaceNotFound
}
return 1
}

//nolint:all
if execExitErr, ok := err.(*exec.ExitError); ok {
func passthroughExitCode(err error, machineMode bool) (int, bool) {
if sshExitErr, ok := errors.AsType[*ssh.ExitError](err); ok {
if machineMode {
log.Errorf("SSH command failed with exit code %d", sshExitErr.ExitStatus())
}
return sshExitErr.ExitStatus(), true
}
if execExitErr, ok := errors.AsType[*exec.ExitError](err); ok {
if machineMode {
log.Errorf("Command failed with exit code %d", execExitErr.ExitCode())
os.Exit(execExitErr.ExitCode())
}
return execExitErr.ExitCode(), true
}
return 0, false
}

cliErr := cliErrors.Classify(err, cliErrors.ClassifyContext{})
// Always emit the error through zap so the configured log encoder
// (json/logfmt/text) governs the wire format. JSONError preserves
// the full err.Error() chain in the top-level "msg" field and ships
// the structured CLIError under "cliError" for the desktop IPC.
func renderCLIError(err error, machineMode bool) {
cliErr := cliErrors.Classify(err, cliErrors.ClassifyContext{})
if machineMode {
log.JSONError(cliErr)
// In human-friendly text mode, follow up with hint/doc affordances
// that don't fit cleanly into the zap line. These extras are
// suppressed in machine-readable modes so log streams stay parseable.
if !isMachineLogFormat(globalFlags.LogOutput) {
if cliErr.Hint != "" {
fmt.Fprintf(os.Stderr, "Hint: %s\n", cliErr.Hint)
}
if cliErr.DocURL != "" {
fmt.Fprintf(os.Stderr, "See: %s\n", cliErr.DocURL)
}
}
// Signal workspace-not-found via a distinct exit code so parent
// processes (e.g. SetupBackhaul) can detect the registration race
// without parsing stderr.
if errors.Is(err, workspace.ErrWorkspaceNotFound) {
os.Exit(exitcode.WorkspaceNotFound)
}
os.Exit(1)
return
}
if cliErr.Hint != "" {
fmt.Fprintf(os.Stderr, "Hint: %s\n", cliErr.Hint)
}
if cliErr.DocURL != "" {
fmt.Fprintf(os.Stderr, "See: %s\n", cliErr.DocURL)
}
}

Expand All @@ -144,6 +237,8 @@ func BuildRoot() (*cobra.Command, *flags.GlobalFlags) {
_ = completion.RegisterFlagCompletionFuns(rootCmd, globalFlags)

rootCmd.PersistentPreRunE = func(cobraCmd *cobra.Command, _ []string) error {
cobraCmd.SilenceUsage = true

log.Init(log.Config{
Verbosity: globalFlags.Verbosity,
Quiet: globalFlags.Quiet,
Expand Down
Loading
Loading