diff --git a/.gitignore b/.gitignore index 4db8344d43b..fed603ef12a 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ cli/azd/extensions/microsoft.azd.concurx/microsoft.azd.concurx cli/azd/extensions/microsoft.azd.concurx/microsoft.azd.concurx.exe cli/azd/azd-test cli/azd/azd +cli/azd/extensions/microsoft.azd.concurx/concurx +cli/azd/extensions/microsoft.azd.concurx/concurx +cli/azd/extensions/microsoft.azd.concurx/concurx.exe diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 3508fcae038..9a9052388a9 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -304,6 +304,9 @@ overrides: - filename: extensions/microsoft.azd.demo/internal/cmd/metadata.go words: - invopop + - filename: extensions/microsoft.azd.concurx/internal/cmd/prompt_model.go + words: + - textinput ignorePaths: - "**/*_test.go" - "**/mock*.go" diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 41c9149c5da..8ff24b1daef 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -135,11 +135,24 @@ func registerCommonDependencies(container *ioc.NestedContainer) { isTerminal := cmd.OutOrStdout() == os.Stdout && cmd.InOrStdin() == os.Stdin && terminal.IsTerminal(os.Stdout.Fd(), os.Stdin.Fd()) + // Check for external prompt configuration from environment variables + var externalPromptCfg *input.ExternalPromptConfiguration + if endpoint := os.Getenv("AZD_UI_PROMPT_ENDPOINT"); endpoint != "" { + if key := os.Getenv("AZD_UI_PROMPT_KEY"); key != "" { + externalPromptCfg = &input.ExternalPromptConfiguration{ + Endpoint: endpoint, + Key: key, + Transporter: http.DefaultClient, + NoPromptDialog: os.Getenv("AZD_UI_NO_PROMPT_DIALOG") != "", + } + } + } + return input.NewConsole(rootOptions.NoPrompt, isTerminal, input.Writers{Output: writer}, input.ConsoleHandles{ Stdin: cmd.InOrStdin(), Stdout: cmd.OutOrStdout(), Stderr: cmd.ErrOrStderr(), - }, formatter, nil) + }, formatter, externalPromptCfg) }) container.MustRegisterSingleton( diff --git a/cli/azd/cmd/external_prompt_test.go b/cli/azd/cmd/external_prompt_test.go new file mode 100644 index 00000000000..2425fc291ce --- /dev/null +++ b/cli/azd/cmd/external_prompt_test.go @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +// TestExternalPromptFromEnvironmentVariables verifies that the console reads +// AZD_UI_PROMPT_ENDPOINT and AZD_UI_PROMPT_KEY environment variables and +// configures external prompting accordingly. +func TestExternalPromptFromEnvironmentVariables(t *testing.T) { + // Create a test HTTP server that simulates the external prompt endpoint + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return a success response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "success", + "value": "test-response", + }) + })) + defer server.Close() + + testKey := "test-secret-key-12345" + + // Set environment variables + t.Setenv("AZD_UI_PROMPT_ENDPOINT", server.URL) + t.Setenv("AZD_UI_PROMPT_KEY", testKey) + + // Create a mock cobra command + cmd := &cobra.Command{} + cmd.SetIn(os.Stdin) + cmd.SetOut(os.Stdout) + cmd.SetErr(os.Stderr) + + // Create root options + rootOptions := &internal.GlobalCommandOptions{ + NoPrompt: false, + } + + // Create formatter + var formatter output.Formatter = nil + + // This simulates what happens in container.go when registering the console + writer := cmd.OutOrStdout() + + isTerminal := false // Force non-terminal for test + + // Check for external prompt configuration from environment variables + // (This is the code we added to container.go) + var externalPromptCfg *input.ExternalPromptConfiguration + if endpoint := os.Getenv("AZD_UI_PROMPT_ENDPOINT"); endpoint != "" { + if key := os.Getenv("AZD_UI_PROMPT_KEY"); key != "" { + externalPromptCfg = &input.ExternalPromptConfiguration{ + Endpoint: endpoint, + Key: key, + Transporter: http.DefaultClient, + } + } + } + + // Verify the config was created + require.NotNil(t, externalPromptCfg, "External prompt config should be created from env vars") + require.Equal(t, server.URL, externalPromptCfg.Endpoint) + require.Equal(t, testKey, externalPromptCfg.Key) + require.NotNil(t, externalPromptCfg.Transporter) + + // Create the console with external prompting configured + console := input.NewConsole( + rootOptions.NoPrompt, + isTerminal, + input.Writers{Output: writer}, + input.ConsoleHandles{ + Stdin: cmd.InOrStdin(), + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + }, + formatter, + externalPromptCfg, + ) + + require.NotNil(t, console) + + // Note: Actually testing that the console uses external prompting would require + // calling Prompt/Select/Confirm methods, which is tested in console_test.go + // This test verifies the environment variable reading logic works correctly. +} + +// TestExternalPromptNotConfiguredWithoutEnvVars verifies that when the +// environment variables are not set, no external prompt config is created. +func TestExternalPromptNotConfiguredWithoutEnvVars(t *testing.T) { + // Ensure env vars are not set + os.Unsetenv("AZD_UI_PROMPT_ENDPOINT") + os.Unsetenv("AZD_UI_PROMPT_KEY") + + // Check for external prompt configuration from environment variables + var externalPromptCfg *input.ExternalPromptConfiguration + if endpoint := os.Getenv("AZD_UI_PROMPT_ENDPOINT"); endpoint != "" { + if key := os.Getenv("AZD_UI_PROMPT_KEY"); key != "" { + externalPromptCfg = &input.ExternalPromptConfiguration{ + Endpoint: endpoint, + Key: key, + Transporter: http.DefaultClient, + } + } + } + + require.Nil(t, externalPromptCfg, "External prompt config should be nil when env vars not set") +} + +// TestExternalPromptRequiresBothEnvVars verifies that both environment +// variables must be set for external prompting to be configured. +func TestExternalPromptRequiresBothEnvVars(t *testing.T) { + t.Run("only endpoint set", func(t *testing.T) { + t.Setenv("AZD_UI_PROMPT_ENDPOINT", "http://localhost:8080") + os.Unsetenv("AZD_UI_PROMPT_KEY") + + var externalPromptCfg *input.ExternalPromptConfiguration + if endpoint := os.Getenv("AZD_UI_PROMPT_ENDPOINT"); endpoint != "" { + if key := os.Getenv("AZD_UI_PROMPT_KEY"); key != "" { + externalPromptCfg = &input.ExternalPromptConfiguration{ + Endpoint: endpoint, + Key: key, + Transporter: http.DefaultClient, + } + } + } + + require.Nil(t, externalPromptCfg, "Config should be nil when only endpoint is set") + }) + + t.Run("only key set", func(t *testing.T) { + os.Unsetenv("AZD_UI_PROMPT_ENDPOINT") + t.Setenv("AZD_UI_PROMPT_KEY", "secret-key") + + var externalPromptCfg *input.ExternalPromptConfiguration + if endpoint := os.Getenv("AZD_UI_PROMPT_ENDPOINT"); endpoint != "" { + if key := os.Getenv("AZD_UI_PROMPT_KEY"); key != "" { + externalPromptCfg = &input.ExternalPromptConfiguration{ + Endpoint: endpoint, + Key: key, + Transporter: http.DefaultClient, + } + } + } + + require.Nil(t, externalPromptCfg, "Config should be nil when only key is set") + }) +} diff --git a/cli/azd/docs/external-prompting.md b/cli/azd/docs/external-prompting.md index f9c6ee80ab4..07a06af2720 100644 --- a/cli/azd/docs/external-prompting.md +++ b/cli/azd/docs/external-prompting.md @@ -19,12 +19,13 @@ In both cases it would be ideal if `azd` could delegate the prompting behavior b ## Solution -Similar to our strategy for delegating authentication to an external host, we support delegating prompting to an external host via a special JSON based REST API, hosted over a local HTTP server. When run, `azd` looks for two special environment variables: +Similar to our strategy for delegating authentication to an external host, we support delegating prompting to an external host via a special JSON based REST API, hosted over a local HTTP server. When run, `azd` looks for the following environment variables: -- `AZD_UI_PROMPT_ENDPOINT` -- `AZD_UI_PROMPT_KEY` +- `AZD_UI_PROMPT_ENDPOINT` (required) +- `AZD_UI_PROMPT_KEY` (required) +- `AZD_UI_NO_PROMPT_DIALOG` (optional) -When both are set, instead of prompting using the command line - the implementation of our prompting methods now make a POST call to a special endpoint: +When both `AZD_UI_PROMPT_ENDPOINT` and `AZD_UI_PROMPT_KEY` are set, instead of prompting using the command line - the implementation of our prompting methods now make a POST call to a special endpoint: `${AZD_UI_PROMPT_ENDPOINT}/prompt?api-version=2024-02-14-preview` @@ -35,6 +36,8 @@ Setting the following headers: The use of `AZD_UI_PROMPT_KEY` allows the host to block requests coming from other clients on the same machine (since the it is expected the host runs a ephemeral HTTP server listing on `127.0.0.1` on a random port). It is expected that the host will generate a random string and use this as a shared key for the lifetime of an `azd` invocation. +## Simple Prompt API + The body of the request contains a JSON object with all the information about the prompt that `azd` needs a response for: ```typescript @@ -90,6 +93,262 @@ Some error happened during prompting - the status is `error` and the `message` p Note that an error prompting leads to a successful result at the HTTP layer (200 OK) but with a special error object. `azd` treats other responses as if the server has an internal bug. +## Prompt Dialog API + +In addition to the simple prompt API, `azd` supports a **Prompt Dialog API** that allows collecting multiple parameter values in a single request. This is primarily used by Visual Studio to present all required parameters in a single dialog. + +When external prompting is enabled, `azd` checks `console.SupportsPromptDialog()` to determine whether to use the dialog API. By default, this returns `true` when external prompting is configured. + +### Dialog Request Format + +```typescript +interface PromptDialogRequest { + title: string + description: string + prompts: PromptDialogPrompt[] +} + +interface PromptDialogPrompt { + id: string // unique identifier for this prompt + kind: "string" | "password" | "select" | "multiSelect" | "confirm" + displayName: string + description?: string + defaultValue?: string + required: boolean + choices?: PromptDialogChoice[] +} + +interface PromptDialogChoice { + value: string + description?: string +} +``` + +### Dialog Response Format + +```typescript +interface PromptDialogResponse { + result: "success" | "cancelled" | "error" + message?: string // present when result is "error" + inputs?: PromptDialogResponseInput[] // present when result is "success" +} + +interface PromptDialogResponseInput { + id: string // matches the id from the request + value: any // the user-provided value +} +``` + +### How Visual Studio Uses Prompt Dialog + +Visual Studio uses the Prompt Dialog API to collect all deployment parameters at once. When a user runs `azd provision`, VS: + +1. Receives a single dialog request with all required parameters (location, resource names, etc.) +2. Presents a unified form/wizard UI to collect all values +3. Returns all values in a single response + +This provides a better UX for GUI environments where presenting multiple sequential prompts would be jarring. + +### Trade-offs of Prompt Dialog + +**Advantages:** +- Single unified UI for collecting multiple values +- Better UX for GUI-based hosts like Visual Studio +- Host can present all parameters at once with custom validation + +**Disadvantages:** +- **Location prompts lose their options**: When using the dialog API, location parameters are sent as simple `kind: "string"` prompts without the list of available Azure locations. The host must either: + - Require users to type location names manually (e.g., `eastus`, `westus2`) + - Fetch and populate the location list independently +- **No rich metadata**: Parameters with special `azd.type` metadata (like `location`) are converted to basic string inputs +- **Host complexity**: The host must handle the dialog format and potentially fetch additional data + +### Disabling Prompt Dialog + +For hosts that want external prompting but prefer individual prompts with full options (including location lists), set: + +``` +AZD_UI_NO_PROMPT_DIALOG=1 +``` + +When this environment variable is set (to any non-empty value), `azd` will: +- Still use external prompting for all user interactions +- Send each prompt individually using the Simple Prompt API +- Include full choice lists for location and other select prompts + +This is the recommended approach for terminal-based UIs or custom TUIs that can handle sequential prompts. + +## Example Implementation: ConcurX Extension + +The `microsoft.azd.concurx` extension demonstrates how to implement external prompting in a Bubble Tea TUI. Here's the key implementation pattern: + +### 1. Create a Prompt Server + +```go +// prompt_server.go - HTTP server to receive prompts from azd subprocesses + +type PromptServer struct { + listener net.Listener + server *http.Server + endpoint string + key string + ui *tea.Program // Bubble Tea program to send prompts to + pendingReq *pendingPrompt +} + +func NewPromptServer(ctx context.Context) (*PromptServer, error) { + // Generate random key for authentication + keyBytes := make([]byte, 32) + rand.Read(keyBytes) + key := hex.EncodeToString(keyBytes) + + // Create listener on random port + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + + endpoint := fmt.Sprintf("http://%s", listener.Addr().String()) + + ps := &PromptServer{ + listener: listener, + endpoint: endpoint, + key: key, + } + + mux := http.NewServeMux() + mux.HandleFunc("/prompt", ps.handlePrompt) + ps.server = &http.Server{Handler: mux} + + return ps, nil +} + +// EnvVars returns environment variables to pass to azd subprocesses +func (ps *PromptServer) EnvVars() []string { + return []string{ + fmt.Sprintf("AZD_UI_PROMPT_ENDPOINT=%s", ps.endpoint), + fmt.Sprintf("AZD_UI_PROMPT_KEY=%s", ps.key), + // Disable dialog to get individual prompts with full location list + "AZD_UI_NO_PROMPT_DIALOG=1", + } +} + +func (ps *PromptServer) handlePrompt(w http.ResponseWriter, r *http.Request) { + // Validate auth header + if r.Header.Get("Authorization") != fmt.Sprintf("Bearer %s", ps.key) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse request + var req PromptRequest + json.NewDecoder(r.Body).Decode(&req) + + // Create response channel and send to TUI + responseChan := make(chan *PromptResponse, 1) + ps.pendingReq = &pendingPrompt{request: &req, response: responseChan} + + // Send to Bubble Tea program + ps.ui.Send(promptRequestMsg{request: &req}) + + // Wait for response from TUI + response := <-responseChan + + // Send response back to azd + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} +``` + +### 2. Pass Environment Variables to Subprocesses + +```go +// concurrent_deployer.go - Running azd commands with prompt support + +func (d *ConcurrentDeployer) runProvision(ctx context.Context, project *DeploymentProject) error { + args := []string{"provision", "--cwd", project.Path} + + cmd := exec.CommandContext(ctx, "azd", args...) + + // Pass prompt server env vars so azd uses external prompting + cmd.Env = append(os.Environ(), d.promptServer.EnvVars()...) + + return cmd.Run() +} +``` + +### 3. Handle Prompts in the TUI + +```go +// deployment_model.go - Bubble Tea model handling prompts + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case promptRequestMsg: + // Switch to prompt view + m.viewMode = viewPrompt + m.promptModel = newPromptModel(msg.request) + return m, nil + + case tea.KeyMsg: + if m.viewMode == viewPrompt { + // Handle prompt input + m.promptModel, cmd = m.promptModel.Update(msg) + + if m.promptModel.submitted { + // Send response back to prompt server + response := m.promptModel.GetResponse() + m.promptServer.RespondToPrompt(response) + m.viewMode = viewDeployment + } + return m, cmd + } + } + return m, nil +} +``` + +### 4. Render Prompt UI + +```go +// prompt_model.go - UI for displaying prompts + +func (m promptModel) View() string { + var content strings.Builder + + content.WriteString(m.message) + content.WriteString("\n") + + switch m.promptType { + case PromptTypeSelect: + // Show filterable list with scroll support + for i := m.scrollOffset; i < min(m.scrollOffset+maxVisible, len(m.filteredChoices)); i++ { + choice := m.choices[m.filteredIndices[i]] + if i == m.selectedIndex { + content.WriteString("▸ " + choice.Value) + } else { + content.WriteString(" " + choice.Value) + } + content.WriteString("\n") + } + content.WriteString("Type to filter • ↑/↓ navigate • Enter select") + + case PromptTypeString: + content.WriteString(m.textInput.View()) + content.WriteString("\nEnter to submit • Esc to cancel") + } + + return content.String() +} +``` + +This pattern allows the extension to: +- Run `azd provision` and `azd deploy` as subprocesses +- Intercept all prompts and display them in a custom TUI +- Get full location lists by using `AZD_UI_NO_PROMPT_DIALOG=1` +- Provide filtering, scrolling, and keyboard navigation for long lists + ## Open Issues -- [ ] Some hosts, such as VS, may want to collect a set of prompts up front and present them all on a single page as part of an end to end - how would we support this? It may be that the answer is "that's a separate API" and this solution is simply focused on "when `azd` it self is driving and end to end workflow". +- [x] ~~Some hosts, such as VS, may want to collect a set of prompts up front and present them all on a single page as part of an end to end - how would we support this?~~ **Resolved**: The Prompt Dialog API supports this use case. +- [ ] Consider adding a way for the dialog API to request location lists from azd, so hosts using dialog mode don't have to fetch locations independently. diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.mod b/cli/azd/extensions/microsoft.azd.concurx/go.mod index 1164ab05cfb..660188da172 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/go.mod +++ b/cli/azd/extensions/microsoft.azd.concurx/go.mod @@ -19,6 +19,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect @@ -76,9 +77,9 @@ require ( go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.37.0 // indirect + golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect google.golang.org/grpc v1.76.0 // indirect diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.sum b/cli/azd/extensions/microsoft.azd.concurx/go.sum index f9299daa7c5..4638c2a4497 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/go.sum +++ b/cli/azd/extensions/microsoft.azd.concurx/go.sum @@ -17,6 +17,8 @@ github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NT github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -205,8 +207,8 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0 golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -214,8 +216,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go index a781654eb51..d978101e047 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/concurrent_deployer.go @@ -34,6 +34,7 @@ type ConcurrentDeployer struct { finalSummaryMu sync.Mutex finalSummary string debug bool + promptServer *PromptServer } // NewConcurrentDeployer creates a new concurrent deployer @@ -43,6 +44,7 @@ func NewConcurrentDeployer( services map[string]*azdext.ServiceConfig, ui *tea.Program, debug bool, + promptServer *PromptServer, ) (*ConcurrentDeployer, error) { // Create logs directory with unique timestamp timestamp := time.Now().Format("20060102-150405") @@ -65,6 +67,7 @@ func NewConcurrentDeployer( buildGate: newBuildGate(), provision: newProvisionState(), debug: debug, + promptServer: promptServer, }, nil } @@ -122,6 +125,7 @@ func (cd *ConcurrentDeployer) runProvision() { cmd.Stderr = logFile cmd.Dir, _ = os.Getwd() cmd.Env = append(os.Environ(), "NO_COLOR=1", "AZD_FORCE_TTY=false") + cmd.Env = append(cmd.Env, cd.promptServer.EnvVars()...) err = cmd.Run() if err != nil { @@ -145,6 +149,8 @@ func (cd *ConcurrentDeployer) runProvision() { // startServiceDeployments starts all service deployment goroutines func (cd *ConcurrentDeployer) startServiceDeployments() { + promptEnvVars := cd.promptServer.EnvVars() + for serviceName, service := range cd.services { cd.wg.Add(1) cd.activeDeployments.Add(1) @@ -159,6 +165,7 @@ func (cd *ConcurrentDeployer) startServiceDeployments() { cd.provision, cd.errChan, cd.debug, + promptEnvVars, ) go func() { @@ -287,17 +294,18 @@ func (bg *buildGate) Wait(ctx context.Context) error { // serviceDeployer handles deployment of a single service type serviceDeployer struct { - ctx context.Context - serviceName string - service *azdext.ServiceConfig - logsDir string - ui *tea.Program - buildGate *buildGate - provision *provisionState - errChan chan error - logFile *os.File - logPath string - debug bool + ctx context.Context + serviceName string + service *azdext.ServiceConfig + logsDir string + ui *tea.Program + buildGate *buildGate + provision *provisionState + errChan chan error + logFile *os.File + logPath string + debug bool + promptEnvVars []string } func newServiceDeployer( @@ -310,17 +318,19 @@ func newServiceDeployer( provision *provisionState, errChan chan error, debug bool, + promptEnvVars []string, ) *serviceDeployer { return &serviceDeployer{ - ctx: ctx, - serviceName: serviceName, - service: service, - logsDir: logsDir, - ui: ui, - buildGate: buildGate, - provision: provision, - errChan: errChan, - debug: debug, + ctx: ctx, + serviceName: serviceName, + service: service, + logsDir: logsDir, + ui: ui, + buildGate: buildGate, + provision: provision, + errChan: errChan, + debug: debug, + promptEnvVars: promptEnvVars, } } @@ -425,6 +435,7 @@ func (sd *serviceDeployer) runDeployment(isFirstAspire bool) error { cmd.Stderr = outputWriter cmd.Dir, _ = os.Getwd() cmd.Env = append(os.Environ(), "NO_COLOR=1", "AZD_FORCE_TTY=false") + cmd.Env = append(cmd.Env, sd.promptEnvVars...) err := cmd.Run() if isFirstAspire { diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go index 565382fab91..87749b31ec5 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/deployment_model.go @@ -46,6 +46,7 @@ type viewMode int const ( viewDeployment viewMode = iota viewLogs + viewPrompt ) // deploymentModel is the Bubble Tea model for deployment visualization @@ -71,6 +72,10 @@ type deploymentModel struct { height int ready bool autoRefresh bool // Auto-refresh logs when enabled + // Prompt state + promptServer *PromptServer + activePrompt *promptModel + previousView viewMode // View to return to after prompt is handled } // Messages that can be sent to the Bubble Tea program @@ -121,7 +126,7 @@ var ( Italic(true) ) -func newDeploymentModel(serviceNames []string, cancel context.CancelFunc) deploymentModel { +func newDeploymentModel(serviceNames []string, cancel context.CancelFunc, promptServer *PromptServer) deploymentModel { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) @@ -152,6 +157,7 @@ func newDeploymentModel(serviceNames []string, cancel context.CancelFunc) deploy tabNames: tabNames, logContents: make(map[string]string), viewport: vp, + promptServer: promptServer, } } @@ -175,7 +181,52 @@ func logRefreshCmd() tea.Cmd { } func (m deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle prompt view first - it takes priority + if m.viewMode == viewPrompt && m.activePrompt != nil { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.activePrompt.width = msg.Width + m.activePrompt.height = msg.Height + return m, nil + + case tea.KeyMsg: + updatedPrompt, cmd := m.activePrompt.Update(msg) + m.activePrompt = &updatedPrompt + + if m.activePrompt.submitted { + // Get response and send it back to the prompt server + response := m.activePrompt.GetResponse() + if m.promptServer != nil { + m.promptServer.RespondToPrompt(response) + } + + // Return to previous view + m.viewMode = m.previousView + m.activePrompt = nil + return m, nil + } + return m, cmd + + default: + updatedPrompt, cmd := m.activePrompt.Update(msg) + m.activePrompt = &updatedPrompt + return m, cmd + } + } + switch msg := msg.(type) { + case promptRequestMsg: + // Handle incoming prompt request + m.previousView = m.viewMode + m.viewMode = viewPrompt + pm := newPromptModel(msg.request) + pm.width = m.width + pm.height = m.height + m.activePrompt = &pm + return m, m.activePrompt.Init() + case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -346,6 +397,11 @@ func (m deploymentModel) View() string { return statusFailedStyle.Render(fmt.Sprintf("Error: %v\n", m.err)) } + // Show prompt view if active + if m.viewMode == viewPrompt && m.activePrompt != nil { + return m.activePrompt.View() + } + if m.quitting { return m.renderFinalView() } diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/prompt_model.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/prompt_model.go new file mode 100644 index 00000000000..8db3bf590fe --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/prompt_model.go @@ -0,0 +1,510 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Maximum number of visible items in select/multiselect lists +const maxVisibleItems = 3 + +// promptModel is the Bubble Tea model for handling interactive prompts +type promptModel struct { + promptType PromptType + message string + help string + choices []PromptChoice + filteredIndices []int // indices of choices that match filter + filterText string // current filter text + defaultValue any + textInput textinput.Model + selectedIndex int // index within filteredIndices + scrollOffset int // for scrolling long lists + selectedItems map[int]bool // for multiselect (uses original indices) + width int + height int + cancelled bool + submitted bool +} + +// Styles for prompt UI +var ( + promptTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("205")). + MarginBottom(1) + + promptMessageStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("15")). + MarginBottom(1) + + promptHelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Italic(true). + MarginBottom(1) + + promptChoiceStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + promptSelectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("39")). + Bold(true) + + promptCheckedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("46")) + + promptBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("87")). + Padding(1, 2). + MarginTop(1) + + promptInstructionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + MarginTop(1) +) + +// newPromptModel creates a new prompt model from a prompt request +func newPromptModel(req *PromptRequest) promptModel { + ti := textinput.New() + ti.Focus() + ti.CharLimit = 256 + ti.Width = 50 + + // Set default value for text input + if req.Options.DefaultValue != nil { + switch v := req.Options.DefaultValue.(type) { + case string: + ti.SetValue(v) + } + } + + // Handle password type + if req.Type == PromptTypePassword { + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '•' + } + + // Initialize selected items for multiselect + selectedItems := make(map[int]bool) + if req.Type == PromptTypeMultiSelect { + // Check for default values in multiselect + if defaults, ok := req.Options.DefaultValue.([]interface{}); ok { + for _, d := range defaults { + if dStr, ok := d.(string); ok { + for i, choice := range req.Options.Choices { + if choice.Value == dStr { + selectedItems[i] = true + break + } + } + } + } + } + } + + // Set default selection for select + selectedIndex := 0 + if req.Type == PromptTypeSelect && req.Options.DefaultValue != nil { + if dStr, ok := req.Options.DefaultValue.(string); ok { + for i, choice := range req.Options.Choices { + if choice.Value == dStr { + selectedIndex = i + break + } + } + } + } + + // Set default for confirm + if req.Type == PromptTypeConfirm && req.Options.DefaultValue != nil { + if dBool, ok := req.Options.DefaultValue.(bool); ok { + if dBool { + selectedIndex = 0 // Yes + } else { + selectedIndex = 1 // No + } + } + } + + // Initialize filtered indices to include all choices + filteredIndices := make([]int, len(req.Options.Choices)) + for i := range req.Options.Choices { + filteredIndices[i] = i + } + + return promptModel{ + promptType: req.Type, + message: req.Options.Message, + help: req.Options.Help, + choices: req.Options.Choices, + filteredIndices: filteredIndices, + defaultValue: req.Options.DefaultValue, + textInput: ti, + selectedIndex: selectedIndex, + selectedItems: selectedItems, + } +} + +func (m promptModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m promptModel) Update(msg tea.Msg) (promptModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.cancelled = true + m.submitted = true + return m, nil + + case "enter": + // Handle submission + if m.promptType == PromptTypeMultiSelect { + m.submitted = true + return m, nil + } + m.submitted = true + return m, nil + + case "up": + // Move selection up for select/multiselect/confirm + if m.promptType == PromptTypeSelect || m.promptType == PromptTypeMultiSelect { + if m.selectedIndex > 0 { + m.selectedIndex-- + // Adjust scroll offset if cursor moves above visible area + if m.selectedIndex < m.scrollOffset { + m.scrollOffset = m.selectedIndex + } + } + } else if m.promptType == PromptTypeConfirm { + m.selectedIndex = 0 // Yes + } + return m, nil + + case "down": + // Move selection down for select/multiselect/confirm + if m.promptType == PromptTypeSelect || m.promptType == PromptTypeMultiSelect { + if m.selectedIndex < len(m.filteredIndices)-1 { + m.selectedIndex++ + // Adjust scroll offset if cursor moves below visible area + if m.selectedIndex >= m.scrollOffset+maxVisibleItems { + m.scrollOffset = m.selectedIndex - maxVisibleItems + 1 + } + } + } else if m.promptType == PromptTypeConfirm { + m.selectedIndex = 1 // No + } + return m, nil + + case "tab": + // Toggle selection for multiselect using tab (space is used for filter) + if m.promptType == PromptTypeMultiSelect && len(m.filteredIndices) > 0 { + originalIdx := m.filteredIndices[m.selectedIndex] + m.selectedItems[originalIdx] = !m.selectedItems[originalIdx] + } + return m, nil + + case "backspace": + // Handle backspace for filter + if (m.promptType == PromptTypeSelect || m.promptType == PromptTypeMultiSelect) && len(m.filterText) > 0 { + m.filterText = m.filterText[:len(m.filterText)-1] + m.updateFilter() + return m, nil + } + + case "y", "Y": + // Quick yes for confirm + if m.promptType == PromptTypeConfirm { + m.selectedIndex = 0 + m.submitted = true + return m, nil + } + + case "n", "N": + // Quick no for confirm + if m.promptType == PromptTypeConfirm { + m.selectedIndex = 1 + m.submitted = true + return m, nil + } + } + } + + // Update text input for string/password/directory types + if m.promptType == PromptTypeString || m.promptType == PromptTypePassword || m.promptType == PromptTypeDirectory { + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd + } + + // Handle typing for filter in select/multiselect + if m.promptType == PromptTypeSelect || m.promptType == PromptTypeMultiSelect { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + // Only handle printable characters for filter + if len(keyMsg.String()) == 1 && keyMsg.String() >= " " { + m.filterText += keyMsg.String() + m.updateFilter() + return m, nil + } + } + } + + return m, nil +} + +// updateFilter updates the filtered indices based on the current filter text +func (m *promptModel) updateFilter() { + if m.filterText == "" { + // No filter, show all + m.filteredIndices = make([]int, len(m.choices)) + for i := range m.choices { + m.filteredIndices[i] = i + } + } else { + // Filter choices + m.filteredIndices = nil + lowerFilter := strings.ToLower(m.filterText) + for i, choice := range m.choices { + if strings.Contains(strings.ToLower(choice.Value), lowerFilter) || + strings.Contains(strings.ToLower(choice.Detail), lowerFilter) { + m.filteredIndices = append(m.filteredIndices, i) + } + } + } + // Reset selection to first item + m.selectedIndex = 0 + m.scrollOffset = 0 +} + +func (m promptModel) View() string { + var content strings.Builder + + // Message (compact, no title) + content.WriteString(promptMessageStyle.Render(m.message)) + content.WriteString("\n") + + // Render based on prompt type + switch m.promptType { + case PromptTypeString, PromptTypePassword, PromptTypeDirectory: + content.WriteString(m.textInput.View()) + content.WriteString("\n") + content.WriteString(promptInstructionStyle.Render("Enter to submit • Esc to cancel")) + + case PromptTypeSelect: + // Show filter input if active + if m.filterText != "" { + content.WriteString(promptSelectedStyle.Render(fmt.Sprintf("Filter: %s", m.filterText))) + content.WriteString("\n") + } + + if len(m.filteredIndices) == 0 { + content.WriteString(promptHelpStyle.Render(" No matches")) + content.WriteString("\n") + } else { + // Calculate visible range based on filtered items + visibleCount := min(maxVisibleItems, len(m.filteredIndices)) + endIndex := min(m.scrollOffset+visibleCount, len(m.filteredIndices)) + + // Show scroll indicator at top if not at beginning + if m.scrollOffset > 0 { + content.WriteString(promptHelpStyle.Render(fmt.Sprintf(" ↑ %d more", m.scrollOffset))) + content.WriteString("\n") + } + + // Render only visible items from filtered list + for i := m.scrollOffset; i < endIndex; i++ { + originalIdx := m.filteredIndices[i] + choice := m.choices[originalIdx] + cursor := " " + style := promptChoiceStyle + if i == m.selectedIndex { + cursor = "▸ " + style = promptSelectedStyle + } + line := cursor + choice.Value + if choice.Detail != "" { + line += fmt.Sprintf(" (%s)", choice.Detail) + } + content.WriteString(style.Render(line)) + content.WriteString("\n") + } + + // Show scroll indicator at bottom if more items below + if endIndex < len(m.filteredIndices) { + content.WriteString(promptHelpStyle.Render(fmt.Sprintf(" ↓ %d more", len(m.filteredIndices)-endIndex))) + content.WriteString("\n") + } + + // Show position indicator + content.WriteString(promptHelpStyle.Render(fmt.Sprintf(" [%d/%d]", m.selectedIndex+1, len(m.filteredIndices)))) + content.WriteString("\n") + } + content.WriteString(promptInstructionStyle.Render("Type to filter • ↑/↓ nav • Enter select")) + + case PromptTypeMultiSelect: + // Show filter input if active + if m.filterText != "" { + content.WriteString(promptSelectedStyle.Render(fmt.Sprintf("Filter: %s", m.filterText))) + content.WriteString("\n") + } + + if len(m.filteredIndices) == 0 { + content.WriteString(promptHelpStyle.Render(" No matches")) + content.WriteString("\n") + } else { + // Calculate visible range based on filtered items + visibleCount := min(maxVisibleItems, len(m.filteredIndices)) + endIndex := min(m.scrollOffset+visibleCount, len(m.filteredIndices)) + + // Show scroll indicator at top if not at beginning + if m.scrollOffset > 0 { + content.WriteString(promptHelpStyle.Render(fmt.Sprintf(" ↑ %d more", m.scrollOffset))) + content.WriteString("\n") + } + + // Render only visible items from filtered list + for i := m.scrollOffset; i < endIndex; i++ { + originalIdx := m.filteredIndices[i] + choice := m.choices[originalIdx] + cursor := " " + checkbox := "[ ]" + style := promptChoiceStyle + + if i == m.selectedIndex { + cursor = "▸ " + style = promptSelectedStyle + } + if m.selectedItems[originalIdx] { + checkbox = "[✓]" + style = promptCheckedStyle + if i == m.selectedIndex { + style = promptSelectedStyle + } + } + + line := cursor + checkbox + " " + choice.Value + if choice.Detail != "" { + line += fmt.Sprintf(" (%s)", choice.Detail) + } + content.WriteString(style.Render(line)) + content.WriteString("\n") + } + + // Show scroll indicator at bottom if more items below + if endIndex < len(m.filteredIndices) { + content.WriteString(promptHelpStyle.Render(fmt.Sprintf(" ↓ %d more", len(m.filteredIndices)-endIndex))) + content.WriteString("\n") + } + + // Show position indicator + content.WriteString(promptHelpStyle.Render(fmt.Sprintf(" [%d/%d]", m.selectedIndex+1, len(m.filteredIndices)))) + content.WriteString("\n") + } + content.WriteString(promptInstructionStyle.Render("Type filter • ↑/↓ nav • Tab toggle • Enter submit")) + + case PromptTypeConfirm: + yesStyle := promptChoiceStyle + noStyle := promptChoiceStyle + yesCursor := " " + noCursor := " " + + if m.selectedIndex == 0 { + yesStyle = promptSelectedStyle + yesCursor = "▸ " + } else { + noStyle = promptSelectedStyle + noCursor = "▸ " + } + + content.WriteString(yesStyle.Render(yesCursor + "Yes")) + content.WriteString("\n") + content.WriteString(noStyle.Render(noCursor + "No")) + content.WriteString("\n") + content.WriteString(promptInstructionStyle.Render("↑/↓ or y/n to select • Enter to confirm • Esc to cancel")) + } + + // Wrap in a box + boxContent := promptBoxStyle.Render(content.String()) + + // Center the box if we have dimensions + if m.width > 0 && m.height > 0 { + return lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + boxContent, + ) + } + + return boxContent +} + +// GetResponse returns the prompt response based on user input +func (m promptModel) GetResponse() *PromptResponse { + if m.cancelled { + return &PromptResponse{ + Status: PromptStatusCancelled, + } + } + + switch m.promptType { + case PromptTypeString, PromptTypePassword, PromptTypeDirectory: + return &PromptResponse{ + Status: PromptStatusSuccess, + Value: m.textInput.Value(), + } + + case PromptTypeSelect: + if len(m.filteredIndices) > 0 && m.selectedIndex < len(m.filteredIndices) { + originalIdx := m.filteredIndices[m.selectedIndex] + return &PromptResponse{ + Status: PromptStatusSuccess, + Value: m.choices[originalIdx].Value, + } + } + return &PromptResponse{ + Status: PromptStatusError, + Message: "No selection made", + } + + case PromptTypeMultiSelect: + var selected []string + for i, choice := range m.choices { + if m.selectedItems[i] { + selected = append(selected, choice.Value) + } + } + return &PromptResponse{ + Status: PromptStatusSuccess, + Value: selected, + } + + case PromptTypeConfirm: + value := "false" + if m.selectedIndex == 0 { + value = "true" + } + return &PromptResponse{ + Status: PromptStatusSuccess, + Value: value, + } + } + + return &PromptResponse{ + Status: PromptStatusError, + Message: "Unknown prompt type", + } +} diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/prompt_server.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/prompt_server.go new file mode 100644 index 00000000000..1309905b861 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/prompt_server.go @@ -0,0 +1,505 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "sync" + + tea "github.com/charmbracelet/bubbletea" +) + +// PromptType represents the type of prompt to display +type PromptType string + +const ( + PromptTypeString PromptType = "string" + PromptTypePassword PromptType = "password" + PromptTypeDirectory PromptType = "directory" + PromptTypeSelect PromptType = "select" + PromptTypeMultiSelect PromptType = "multiSelect" + PromptTypeConfirm PromptType = "confirm" +) + +// PromptChoice represents a choice option for select/multiselect prompts +type PromptChoice struct { + Value string `json:"value"` + Detail string `json:"detail,omitempty"` +} + +// PromptOptions contains the options for a prompt +type PromptOptions struct { + Message string `json:"message"` + Help string `json:"help,omitempty"` + Choices []PromptChoice `json:"choices,omitempty"` + DefaultValue any `json:"defaultValue,omitempty"` +} + +// PromptRequest represents an incoming prompt request from azd +type PromptRequest struct { + Type PromptType `json:"type"` + Options PromptOptions `json:"options"` +} + +// PromptDialogRequest represents a dialog-style prompt request (multiple prompts at once) +type PromptDialogRequest struct { + Title string `json:"title"` + Description string `json:"description"` + Prompts []PromptDialogPrompt `json:"prompts"` +} + +// PromptDialogPrompt represents a single prompt within a dialog +type PromptDialogPrompt struct { + ID string `json:"id"` + Kind string `json:"kind"` // "string", "select", etc. + DisplayName string `json:"displayName"` + Description *string `json:"description,omitempty"` + DefaultValue *string `json:"defaultValue,omitempty"` + Required bool `json:"required"` + Choices []PromptDialogChoice `json:"choices,omitempty"` +} + +// PromptDialogChoice represents a choice in a dialog prompt +type PromptDialogChoice struct { + Value string `json:"value"` + Description *string `json:"description,omitempty"` +} + +// PromptDialogResponse represents the response to a dialog request +type PromptDialogResponse struct { + Result string `json:"result"` // "success", "cancelled", "error" + Message *string `json:"message,omitempty"` + Inputs []PromptDialogResponseInput `json:"inputs,omitempty"` +} + +// PromptDialogResponseInput represents a single input value in a dialog response +type PromptDialogResponseInput struct { + ID string `json:"id"` + Value any `json:"value"` +} + +// PromptResponseStatus represents the status of a prompt response +type PromptResponseStatus string + +const ( + PromptStatusSuccess PromptResponseStatus = "success" + PromptStatusCancelled PromptResponseStatus = "cancelled" + PromptStatusError PromptResponseStatus = "error" +) + +// PromptResponse represents the response to send back to azd +type PromptResponse struct { + Status PromptResponseStatus `json:"status"` + Value any `json:"value,omitempty"` + Message string `json:"message,omitempty"` +} + +// PromptServer handles external prompting requests from azd subprocesses +type PromptServer struct { + listener net.Listener + server *http.Server + endpoint string + key string + ui *tea.Program + mu sync.Mutex + pendingReq *pendingPrompt + ctx context.Context + cancel context.CancelFunc + debugLog *log.Logger +} + +// pendingPrompt tracks an active prompt waiting for user response +type pendingPrompt struct { + request *PromptRequest + response chan *PromptResponse +} + +// pendingDialogPrompt tracks an active dialog prompt waiting for user response +type pendingDialogPrompt struct { + request *PromptDialogRequest + response chan *PromptDialogResponse +} + +// promptRequestMsg is sent to the TUI when a prompt arrives +type promptRequestMsg struct { + request *PromptRequest +} + +// promptDialogRequestMsg is sent to the TUI when a dialog prompt arrives +type promptDialogRequestMsg struct { + request *PromptDialogRequest +} + +// promptResponseMsg is sent from the TUI when the user responds +type promptResponseMsg struct { + response *PromptResponse +} + +// NewPromptServer creates a new prompt server +func NewPromptServer(ctx context.Context) (*PromptServer, error) { + // Create debug log file + logFile, err := os.OpenFile("/tmp/concurx-prompt-server.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + logFile = os.Stderr // fallback to stderr + } + debugLog := log.New(logFile, "[PROMPT-SERVER] ", log.LstdFlags|log.Lmicroseconds) + + // Generate random key for authentication + keyBytes := make([]byte, 32) + if _, err := rand.Read(keyBytes); err != nil { + return nil, fmt.Errorf("failed to generate prompt key: %w", err) + } + key := hex.EncodeToString(keyBytes) + + // Create listener on random port + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("failed to create listener: %w", err) + } + + endpoint := fmt.Sprintf("http://%s", listener.Addr().String()) + + debugLog.Printf("Created prompt server: endpoint=%s", endpoint) + + serverCtx, cancel := context.WithCancel(ctx) + + ps := &PromptServer{ + listener: listener, + endpoint: endpoint, + key: key, + ctx: serverCtx, + cancel: cancel, + debugLog: debugLog, + } + + mux := http.NewServeMux() + mux.HandleFunc("/prompt", ps.handlePrompt) + + ps.server = &http.Server{ + Handler: mux, + } + + return ps, nil +} + +// Start begins serving prompt requests +func (ps *PromptServer) Start() { + ps.debugLog.Printf("Starting prompt server at %s", ps.endpoint) + go func() { + err := ps.server.Serve(ps.listener) + if err != nil && err != http.ErrServerClosed { + ps.debugLog.Printf("Server error: %v", err) + } + }() +} + +// Stop shuts down the prompt server +func (ps *PromptServer) Stop() { + ps.cancel() + _ = ps.server.Shutdown(context.Background()) +} + +// SetUI sets the Bubble Tea program to send prompt requests to +func (ps *PromptServer) SetUI(ui *tea.Program) { + ps.ui = ui +} + +// Endpoint returns the server endpoint URL +func (ps *PromptServer) Endpoint() string { + return ps.endpoint +} + +// Key returns the authentication key +func (ps *PromptServer) Key() string { + return ps.key +} + +// EnvVars returns the environment variables to set for azd subprocesses +func (ps *PromptServer) EnvVars() []string { + return []string{ + fmt.Sprintf("AZD_UI_PROMPT_ENDPOINT=%s", ps.endpoint), + fmt.Sprintf("AZD_UI_PROMPT_KEY=%s", ps.key), + // Disable prompt dialog to get individual prompts with full options (e.g., location list) + "AZD_UI_NO_PROMPT_DIALOG=1", + } +} + +// RespondToPrompt sends a response back to the waiting prompt request +func (ps *PromptServer) RespondToPrompt(response *PromptResponse) { + ps.mu.Lock() + defer ps.mu.Unlock() + + if ps.pendingReq != nil && ps.pendingReq.response != nil { + ps.pendingReq.response <- response + ps.pendingReq = nil + } +} + +// handlePrompt handles incoming prompt requests from azd +func (ps *PromptServer) handlePrompt(w http.ResponseWriter, r *http.Request) { + ps.debugLog.Printf("Received request: %s %s", r.Method, r.URL.String()) + + // Only accept POST requests + if r.Method != http.MethodPost { + ps.debugLog.Printf("Method not allowed: %s", r.Method) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Validate authorization header + authHeader := r.Header.Get("Authorization") + expectedAuth := fmt.Sprintf("Bearer %s", ps.key) + if authHeader != expectedAuth { + ps.debugLog.Printf("Unauthorized: got '%s', expected '%s'", authHeader, expectedAuth) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Read and log raw body for debugging + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + ps.debugLog.Printf("Failed to read body: %v", err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + ps.debugLog.Printf("Raw body: %s", string(bodyBytes)) + + // Try to detect request format by checking for "prompts" field (dialog) vs "type" field (simple) + var rawMsg map[string]json.RawMessage + if err := json.Unmarshal(bodyBytes, &rawMsg); err != nil { + ps.debugLog.Printf("Bad request (raw parse): %v", err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Check if this is a dialog request (has "prompts" field) + if _, hasPrompts := rawMsg["prompts"]; hasPrompts { + ps.debugLog.Printf("Detected DIALOG request format") + ps.handleDialogPrompt(w, bodyBytes) + return + } + + // Otherwise it's a simple prompt request + ps.debugLog.Printf("Detected SIMPLE prompt request format") + ps.handleSimplePrompt(w, bodyBytes) +} + +// handleSimplePrompt handles the simple single-prompt format +func (ps *PromptServer) handleSimplePrompt(w http.ResponseWriter, bodyBytes []byte) { + // Parse request body + var req PromptRequest + if err := json.Unmarshal(bodyBytes, &req); err != nil { + ps.debugLog.Printf("Bad request: %v", err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + ps.debugLog.Printf("Parsed prompt request: type=%s, message=%s, choices=%d", req.Type, req.Options.Message, len(req.Options.Choices)) + + // Create response channel + responseChan := make(chan *PromptResponse, 1) + + // Store pending request + ps.mu.Lock() + ps.pendingReq = &pendingPrompt{ + request: &req, + response: responseChan, + } + ps.mu.Unlock() + + // Send prompt request to TUI + if ps.ui != nil { + ps.debugLog.Printf("Sending prompt request to TUI") + ps.ui.Send(promptRequestMsg{request: &req}) + } else { + ps.debugLog.Printf("WARNING: ui is nil, cannot send prompt request") + } + + ps.debugLog.Printf("Waiting for response...") + + // Wait for response or context cancellation + var response *PromptResponse + select { + case response = <-responseChan: + ps.debugLog.Printf("Got response from user: status=%s", response.Status) + case <-ps.ctx.Done(): + ps.debugLog.Printf("Context cancelled, server shutting down") + response = &PromptResponse{ + Status: PromptStatusCancelled, + Message: "Server shutting down", + } + } + + // Send response + ps.debugLog.Printf("Sending response: %+v", response) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +// handleDialogPrompt handles the dialog-style multi-prompt format +func (ps *PromptServer) handleDialogPrompt(w http.ResponseWriter, bodyBytes []byte) { + // Parse dialog request + var dialogReq PromptDialogRequest + if err := json.Unmarshal(bodyBytes, &dialogReq); err != nil { + ps.debugLog.Printf("Bad dialog request: %v", err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + ps.debugLog.Printf("Parsed dialog request: title=%s, description=%s, prompts=%d", + dialogReq.Title, dialogReq.Description, len(dialogReq.Prompts)) + + // For now, we'll process each prompt in the dialog sequentially + // by converting them to simple prompts + inputs := make([]PromptDialogResponseInput, 0, len(dialogReq.Prompts)) + + for _, prompt := range dialogReq.Prompts { + ps.debugLog.Printf("Processing dialog prompt: id=%s, kind=%s, displayName=%s", + prompt.ID, prompt.Kind, prompt.DisplayName) + + // Convert dialog prompt to simple prompt + simpleReq := ps.convertDialogPromptToSimple(&prompt) + + // Create response channel + responseChan := make(chan *PromptResponse, 1) + + // Store pending request + ps.mu.Lock() + ps.pendingReq = &pendingPrompt{ + request: simpleReq, + response: responseChan, + } + ps.mu.Unlock() + + // Send prompt request to TUI + if ps.ui != nil { + ps.debugLog.Printf("Sending dialog prompt to TUI: %s", prompt.ID) + ps.ui.Send(promptRequestMsg{request: simpleReq}) + } else { + ps.debugLog.Printf("WARNING: ui is nil, cannot send prompt request") + } + + // Wait for response + var response *PromptResponse + select { + case response = <-responseChan: + ps.debugLog.Printf("Got response for prompt %s: status=%s", prompt.ID, response.Status) + case <-ps.ctx.Done(): + ps.debugLog.Printf("Context cancelled during dialog") + dialogResp := &PromptDialogResponse{ + Result: "cancelled", + Message: stringPtr("Server shutting down"), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(dialogResp) + return + } + + // Check for cancellation + if response.Status == PromptStatusCancelled { + dialogResp := &PromptDialogResponse{ + Result: "cancelled", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(dialogResp) + return + } + + // Check for error + if response.Status == PromptStatusError { + dialogResp := &PromptDialogResponse{ + Result: "error", + Message: stringPtr(response.Message), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(dialogResp) + return + } + + // Add to inputs + inputs = append(inputs, PromptDialogResponseInput{ + ID: prompt.ID, + Value: response.Value, + }) + } + + // Send success response with all inputs + dialogResp := &PromptDialogResponse{ + Result: "success", + Inputs: inputs, + } + ps.debugLog.Printf("Sending dialog response: %+v", dialogResp) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(dialogResp) +} + +// convertDialogPromptToSimple converts a dialog prompt to a simple prompt request +func (ps *PromptServer) convertDialogPromptToSimple(prompt *PromptDialogPrompt) *PromptRequest { + var promptType PromptType + switch prompt.Kind { + case "string": + promptType = PromptTypeString + case "password": + promptType = PromptTypePassword + case "select": + promptType = PromptTypeSelect + case "multiSelect": + promptType = PromptTypeMultiSelect + case "confirm": + promptType = PromptTypeConfirm + default: + promptType = PromptTypeString + } + + // Convert choices + var choices []PromptChoice + if len(prompt.Choices) > 0 { + choices = make([]PromptChoice, len(prompt.Choices)) + for i, c := range prompt.Choices { + detail := "" + if c.Description != nil { + detail = *c.Description + } + choices[i] = PromptChoice{ + Value: c.Value, + Detail: detail, + } + } + } + + // Build message + message := prompt.DisplayName + if prompt.Description != nil && *prompt.Description != "" { + message = fmt.Sprintf("%s\n%s", prompt.DisplayName, *prompt.Description) + } + + // Get default value + var defaultValue any + if prompt.DefaultValue != nil { + defaultValue = *prompt.DefaultValue + } + + return &PromptRequest{ + Type: promptType, + Options: PromptOptions{ + Message: message, + Choices: choices, + DefaultValue: defaultValue, + }, + } +} + +// stringPtr returns a pointer to the given string +func stringPtr(s string) *string { + return &s +} diff --git a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go index 3eb10f62f66..c6eb31a4e25 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go +++ b/cli/azd/extensions/microsoft.azd.concurx/internal/cmd/up.go @@ -87,11 +87,22 @@ func runUpCommand(cmd *cobra.Command, args []string) error { // Get debug flag debug, _ := cmd.Flags().GetBool("debug") - // Create Bubble Tea UI with cancel function - ui := createUI(services, cancel) + // Create prompt server for external prompting + promptServer, err := NewPromptServer(ctx) + if err != nil { + return fmt.Errorf("failed to create prompt server: %w", err) + } + defer promptServer.Stop() + + // Create Bubble Tea UI with cancel function and prompt server + ui := createUI(services, cancel, promptServer) + + // Set the UI on the prompt server so it can send messages + promptServer.SetUI(ui) + promptServer.Start() // Create and run concurrent deployer - deployer, err := NewConcurrentDeployer(ctx, workflowClient, services, ui, debug) + deployer, err := NewConcurrentDeployer(ctx, workflowClient, services, ui, debug, promptServer) if err != nil { return err } @@ -138,13 +149,13 @@ func initializeAzdClient(ctx context.Context) ( } // createUI initializes the Bubble Tea program -func createUI(services map[string]*azdext.ServiceConfig, cancel context.CancelFunc) *tea.Program { +func createUI(services map[string]*azdext.ServiceConfig, cancel context.CancelFunc, promptServer *PromptServer) *tea.Program { serviceNames := make([]string, 0, len(services)) for name := range services { serviceNames = append(serviceNames, name) } - model := newDeploymentModel(serviceNames, cancel) + model := newDeploymentModel(serviceNames, cancel, promptServer) return tea.NewProgram( model, tea.WithAltScreen(), // Use alternate screen buffer diff --git a/cli/azd/pkg/input/console.go b/cli/azd/pkg/input/console.go index bcca5db1827..e2aef9baa8f 100644 --- a/cli/azd/pkg/input/console.go +++ b/cli/azd/pkg/input/console.go @@ -153,6 +153,8 @@ type AskerConsole struct { noPrompt bool // when non nil, use this client instead of prompting ourselves on the console. promptClient *externalPromptClient + // noPromptDialog when true, disables SupportsPromptDialog() even when promptClient is set. + noPromptDialog bool showProgressMu sync.Mutex // ensures atomicity when swapping the current progress renderer (spinner or previewer) @@ -533,7 +535,7 @@ func promptFromOptions(options ConsoleOptions) survey.Prompt { const afterIoSentinel = "0\n" func (c *AskerConsole) SupportsPromptDialog() bool { - return c.promptClient != nil + return c.promptClient != nil && !c.noPromptDialog } // PromptDialog prompts for multiple values using a single dialog. When successful, it returns a map of prompt IDs to their @@ -965,6 +967,11 @@ type ExternalPromptConfiguration struct { Endpoint string Key string Transporter policy.Transporter + // NoPromptDialog when true, disables the prompt dialog feature even when external prompting is enabled. + // This causes each prompt to be sent individually through the external prompt API, which is useful + // for clients that don't support the dialog API but still want location prompts to include the full + // list of available locations. + NoPromptDialog bool } // Creates a new console with the specified writers, handles and formatter. When externalPromptCfg is non nil, it is used @@ -996,6 +1003,7 @@ func NewConsole( if externalPromptCfg != nil { c.promptClient = newExternalPromptClient( externalPromptCfg.Endpoint, externalPromptCfg.Key, externalPromptCfg.Transporter) + c.noPromptDialog = externalPromptCfg.NoPromptDialog } spinnerConfig := yacspin.Config{ diff --git a/cli/azd/pkg/input/console_test.go b/cli/azd/pkg/input/console_test.go index d9b7d532cda..1ab6a21aaae 100644 --- a/cli/azd/pkg/input/console_test.go +++ b/cli/azd/pkg/input/console_test.go @@ -97,9 +97,7 @@ func TestAskerConsole_Spinner_NonTty(t *testing.T) { } func TestAskerConsoleExternalPrompt(t *testing.T) { - t.Skip("Need to be updated to use the new external prompt mechanism.") - - newConsole := func() Console { + newConsole := func(externalPromptCfg *ExternalPromptConfiguration) Console { return NewConsole( false, false, @@ -112,7 +110,7 @@ func TestAskerConsoleExternalPrompt(t *testing.T) { Stdout: os.Stdout, }, nil, - nil, + externalPromptCfg, ) } @@ -127,10 +125,13 @@ func TestAskerConsoleExternalPrompt(t *testing.T) { }) t.Cleanup(server.Close) - t.Setenv("AZD_UI_PROMPT_ENDPOINT", server.URL) - t.Setenv("AZD_UI_PROMPT_KEY", "fake-key-for-testing") + externalPromptCfg := &ExternalPromptConfiguration{ + Endpoint: server.URL, + Key: "fake-key-for-testing", + Transporter: http.DefaultClient, + } - c := newConsole() + c := newConsole(externalPromptCfg) res, err := c.Confirm(context.Background(), ConsoleOptions{Message: "Are you sure?", DefaultValue: true}) require.NoError(t, err) @@ -147,10 +148,13 @@ func TestAskerConsoleExternalPrompt(t *testing.T) { }) t.Cleanup(server.Close) - t.Setenv("AZD_UI_PROMPT_ENDPOINT", server.URL) - t.Setenv("AZD_UI_PROMPT_KEY", "fake-key-for-testing") + externalPromptCfg := &ExternalPromptConfiguration{ + Endpoint: server.URL, + Key: "fake-key-for-testing", + Transporter: http.DefaultClient, + } - c := newConsole() + c := newConsole(externalPromptCfg) res, err := c.Prompt(context.Background(), ConsoleOptions{Message: "What is your name?"}) require.NoError(t, err) @@ -178,10 +182,13 @@ func TestAskerConsoleExternalPrompt(t *testing.T) { }) t.Cleanup(server.Close) - t.Setenv("AZD_UI_PROMPT_ENDPOINT", server.URL) - t.Setenv("AZD_UI_PROMPT_KEY", "fake-key-for-testing") + externalPromptCfg := &ExternalPromptConfiguration{ + Endpoint: server.URL, + Key: "fake-key-for-testing", + Transporter: http.DefaultClient, + } - c := newConsole() + c := newConsole(externalPromptCfg) res, err := c.Select( context.Background(), @@ -216,10 +223,13 @@ func TestAskerConsoleExternalPrompt(t *testing.T) { }) t.Cleanup(server.Close) - t.Setenv("AZD_UI_PROMPT_ENDPOINT", server.URL) - t.Setenv("AZD_UI_PROMPT_KEY", "fake-key-for-testing") + externalPromptCfg := &ExternalPromptConfiguration{ + Endpoint: server.URL, + Key: "fake-key-for-testing", + Transporter: http.DefaultClient, + } - c := newConsole() + c := newConsole(externalPromptCfg) res, err := c.MultiSelect( context.Background(),