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
29 changes: 29 additions & 0 deletions internal/commands/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ func newAPICmd() *cobra.Command {
}
}

// Detect a path that a Git Bash / MSYS shell has rewritten into a
// Windows filesystem path (POSIX path conversion turns a leading "/"
// into "C:/Program Files/Git/..."). We refuse rather than guess the
// intended endpoint — this is a raw escape hatch and reconstructing
// the wrong path could fire a destructive call.
if isShellMangledPath(path) {
return &output.UserError{
Message: "API path was rewritten by your shell (Git Bash/MSYS turned it into a Windows path)",
Hint: "Your shell converted the leading '/' into a filesystem path. Re-run either way (substitute your real path):\n" +
fmt.Sprintf(" MSYS_NO_PATHCONV=1 dhq api %s /projects/my-app (disable path conversion)\n", method) +
fmt.Sprintf(" dhq api %s projects/my-app (omit the leading slash)", method),
}
}

client, err := cliCtx.Client()
if err != nil {
return err
Expand Down Expand Up @@ -74,3 +88,18 @@ func newAPICmd() *cobra.Command {
cmd.Flags().StringVar(&jsonBody, "body", "", "JSON request body")
return cmd
}

// isShellMangledPath reports whether p looks like an API path that a Git Bash /
// MSYS shell has rewritten into a Windows filesystem path via POSIX path
// conversion. A real API path is relative ("projects/x") or root-absolute
// ("/projects/x") and never begins with a drive letter, so a leading "C:/" or
// "C:\" is a reliable signal that the argument was mangled before the CLI saw
// it.
func isShellMangledPath(p string) bool {
if len(p) < 3 {
return false
}
c := p[0]
isLetter := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
return isLetter && p[1] == ':' && (p[2] == '/' || p[2] == '\\')
}
37 changes: 37 additions & 0 deletions internal/commands/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package commands

import "testing"

func TestIsShellMangledPath(t *testing.T) {
cases := []struct {
name string
path string
want bool
}{
// Mangled by Git Bash / MSYS POSIX path conversion.
{"drive lowercase forward slash", "c:/Program Files/Git/projects/x", true},
{"drive uppercase forward slash", "C:/Program Files/Git/projects/x", true},
{"drive backslash", `C:\Program Files\Git\projects\x`, true},
{"other drive letter", "d:/msys/projects/x", true},

// Legitimate API paths.
{"root-absolute", "/projects/my-app", false},
{"relative", "projects/my-app", false},
{"nested relative", "projects/my-app/deployments", false},
{"root only", "/", false},

// Edge cases that must not false-positive.
{"empty", "", false},
{"single char", "c", false},
{"two chars", "c:", false},
{"colon but not drive", "ab:/x", false},
{"digit prefix", "1:/x", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isShellMangledPath(tc.path); got != tc.want {
t.Errorf("isShellMangledPath(%q) = %v, want %v", tc.path, got, tc.want)
}
})
}
}
21 changes: 20 additions & 1 deletion internal/commands/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,23 @@ func resolveDeployProject(ctx context.Context, client *sdk.Client, configured st
for _, p := range projects {
items = append(items, fmt.Sprintf("%s (%s)", p.Identifier, p.Name))
}
// Interactive: let the user pick rather than hard-failing. Agents and
// piped/--non-interactive callers still get the structured error+list
// so automation can retry with a concrete --project. JSON mode also
// takes the structured path: promptui renders to stdout, which would
// corrupt the machine-readable output contract.
if !env.NonInteractive && !env.WantsJSON() {
prompt := promptui.Select{
Label: "Select project to deploy",
Items: items,
}
idx, _, perr := prompt.Run()
if perr != nil {
return "", &output.UserError{Message: "Project selection cancelled"}
}
env.Status("Selected project: %s", projects[idx].Name)
return projects[idx].Identifier, nil
}
return "", &output.UserError{
Message: "No project specified",
Hint: fmt.Sprintf("Account has %d projects — pass --project <identifier>. Available: %s",
Expand Down Expand Up @@ -413,7 +430,9 @@ func newDeployCmd() *cobra.Command {
resolvedServer = &servers[0]
env.Status("Auto-selected server: %s", servers[0].Name)
} else if err == nil && len(servers) > 1 {
if !env.NonInteractive {
// JSON mode joins the non-interactive path: promptui renders
// to stdout and would corrupt machine-readable output.
if !env.NonInteractive && !env.WantsJSON() {
// Interactive picker
items := make([]string, len(servers))
for i, s := range servers {
Expand Down
30 changes: 29 additions & 1 deletion internal/commands/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,10 @@ func TestResolveDeployProject_MultipleProjectsListsThem(t *testing.T) {
}))
defer srv.Close()
client := newTestSDKClient(t, srv)
env, _ := testEnvelope()
// Non-interactive (agents, piped output): the multi-project case must fail
// with the structured list rather than prompt. Interactive callers instead
// get a picker (see resolveDeployProject), which isn't exercised here.
env := &output.Envelope{Stdout: io.Discard, Stderr: io.Discard, NonInteractive: true}

id, err := resolveDeployProject(t.Context(), client, "", env)
require.Error(t, err)
Expand All @@ -618,6 +621,31 @@ func TestResolveDeployProject_MultipleProjectsListsThem(t *testing.T) {
assert.Contains(t, msg, "c-id (Gamma)")
}

// TestResolveDeployProject_JSONModeSkipsPicker guards the stdout=data contract:
// --json on a TTY leaves NonInteractive false, but the picker must NOT run
// because promptui renders to stdout and would corrupt JSON output. JSON mode
// must take the structured-error path instead.
func TestResolveDeployProject_JSONModeSkipsPicker(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]sdk.Project{
{Identifier: "a-id", Name: "Alpha"},
{Identifier: "b-id", Name: "Beta"},
})
}))
defer srv.Close()
client := newTestSDKClient(t, srv)
// Interactive TTY (NonInteractive false) but JSON output requested. WantsJSON()
// is true via JSONMode, so the picker must be skipped.
env := &output.Envelope{Stdout: io.Discard, Stderr: io.Discard, IsTTY: true, JSONMode: true}
require.True(t, env.WantsJSON())
require.False(t, env.NonInteractive)

id, err := resolveDeployProject(t.Context(), client, "", env)
require.Error(t, err)
assert.Empty(t, id)
assert.Equal(t, "No project specified", strings.SplitN(err.Error(), "\n", 2)[0])
}

func TestResolveDeployProject_ZeroProjects(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]sdk.Project{})
Expand Down
Loading