|
| 1 | +package resources |
| 2 | + |
| 3 | +import ( |
| 4 | + "bufio" |
| 5 | + "context" |
| 6 | + "fmt" |
| 7 | + "io" |
| 8 | + "net/url" |
| 9 | + "strings" |
| 10 | + "time" |
| 11 | + |
| 12 | + "github.com/airbytehq/airbyte-agent-cli/internal/client" |
| 13 | + "github.com/airbytehq/airbyte-agent-cli/internal/registry" |
| 14 | +) |
| 15 | + |
| 16 | +// connectorsUpdateOperation registers `connectors update`, a browser-launch |
| 17 | +// command that resolves the target connector and opens the user's webapp |
| 18 | +// credentials page. The CLI never accepts credentials directly — credential |
| 19 | +// entry happens in the browser-based widget mounted on the credentials page |
| 20 | +// (sonar/frontend/src/routes/organizations/$organizationId/credentials.tsx:1423). |
| 21 | +// |
| 22 | +// The SpecRef points at the conceptual PUT /api/v1/integrations/connectors/{id} |
| 23 | +// route the webapp invokes after the user submits the edit form. The CLI does |
| 24 | +// NOT call that endpoint — the value is informational and feeds |
| 25 | +// `airbyte-agent schema connectors update`. |
| 26 | +func connectorsUpdateOperation() registry.Operation { |
| 27 | + return registry.Operation{ |
| 28 | + Name: "update", |
| 29 | + Description: "Open the browser to edit a connector's credentials/config", |
| 30 | + Schema: registry.OperationSchema{ |
| 31 | + Description: "Open the credentials page in your browser so you can edit an existing connector. The CLI never accepts credentials directly — entry happens in the browser-based widget.", |
| 32 | + Params: map[string]registry.ParamSchema{ |
| 33 | + "name": {Type: "string", Required: false, Description: "Connector name (requires workspace)"}, |
| 34 | + "workspace": {Type: "string", Required: false, Description: "Workspace name (defaults to 'default' when used with name)"}, |
| 35 | + "id": {Type: "string", Required: false, Description: "Connector ID (alternative to name)"}, |
| 36 | + }, |
| 37 | + }, |
| 38 | + SpecRef: registry.SpecRef{Path: "/api/v1/integrations/connectors/{id}", Method: "PUT"}, |
| 39 | + Hooks: registry.OperationHooks{ |
| 40 | + PreRun: resolveConnectorID, |
| 41 | + }, |
| 42 | + Run: connectorsUpdate, |
| 43 | + } |
| 44 | +} |
| 45 | + |
| 46 | +// connectorsUpdate is the Run function for `connectors update`. After |
| 47 | +// `resolveConnectorID` populates `params["id"]`, it builds the credentials- |
| 48 | +// page URL, displays the human-readable message + a yes/no prompt, and only |
| 49 | +// opens the browser on an explicit "yes". The prompt has a short timeout so |
| 50 | +// non-interactive callers (MCP, CI, piped invocations) don't hang waiting |
| 51 | +// for input that will never arrive — they simply receive the URL in the |
| 52 | +// result map and can act on it themselves. The result always includes |
| 53 | +// `browser_opened` so callers can tell which path ran. |
| 54 | +func connectorsUpdate(ctx context.Context, c *client.Client, params map[string]any) (any, error) { |
| 55 | + id, _ := params["id"].(string) |
| 56 | + |
| 57 | + orgID := c.OrganizationID() |
| 58 | + if orgID == "" { |
| 59 | + return nil, client.NewValidationError( |
| 60 | + "no organization_id configured", |
| 61 | + "run 'airbyte-agent login' or set AIRBYTE_ORGANIZATION_ID", |
| 62 | + ) |
| 63 | + } |
| 64 | + |
| 65 | + pageURL, err := credentialsPageURL(webAppBaseURL(), orgID) |
| 66 | + if err != nil { |
| 67 | + return nil, fmt.Errorf("building credentials page URL: %w", err) |
| 68 | + } |
| 69 | + |
| 70 | + name, _ := params["name"].(string) |
| 71 | + workspace, _ := params["workspace"].(string) |
| 72 | + message := updateMessageFor(name, workspace, id) |
| 73 | + |
| 74 | + opened := false |
| 75 | + if confirmOpenBrowser(message, pageURL) { |
| 76 | + openBrowser(pageURL) |
| 77 | + opened = true |
| 78 | + } |
| 79 | + |
| 80 | + return map[string]any{ |
| 81 | + "url": pageURL, |
| 82 | + "connector_id": id, |
| 83 | + "message": message, |
| 84 | + "browser_opened": opened, |
| 85 | + }, nil |
| 86 | +} |
| 87 | + |
| 88 | +// confirmOpenBrowserTimeout caps how long the confirmation prompt waits for |
| 89 | +// stdin before defaulting to "no". Long enough for a TTY user glancing at |
| 90 | +// the prompt to type a response; short enough that non-interactive callers |
| 91 | +// don't hang noticeably. It is a var (not a const) so tests can shrink it. |
| 92 | +var confirmOpenBrowserTimeout = 10 * time.Second |
| 93 | + |
| 94 | +// confirmOpenBrowser prints the action message + a yes/no prompt to |
| 95 | +// confirmWriter (stderr by default), then reads a single line from |
| 96 | +// confirmReader (stdin by default) with a confirmOpenBrowserTimeout-bounded |
| 97 | +// wait. Returns true ONLY on an exact "yes" (case-insensitive, whitespace- |
| 98 | +// trimmed). Any other input, EOF, or a timeout returns false — the URL is |
| 99 | +// still surfaced in the caller's result map so the user/agent can act on it |
| 100 | +// independently. Declared as a var so tests can stub the whole prompt |
| 101 | +// rather than driving the real stdin read. |
| 102 | +var confirmOpenBrowser = func(message, url string) bool { |
| 103 | + fmt.Fprintln(confirmWriter, message) |
| 104 | + fmt.Fprintf(confirmWriter, "Open %s in your browser? Type 'yes' to confirm (skips after %s): ", url, confirmOpenBrowserTimeout) |
| 105 | + |
| 106 | + // Capture the reader into a local before spawning the goroutine. The |
| 107 | + // goroutine outlives this function call (it stays blocked on Read until |
| 108 | + // the process exits or input arrives), so reading the package-level |
| 109 | + // `confirmReader` from inside the goroutine would race with tests that |
| 110 | + // restore the var in a deferred cleanup. The local capture establishes |
| 111 | + // a happens-before edge that's safe for the race detector. |
| 112 | + reader := confirmReader |
| 113 | + |
| 114 | + type readResult struct { |
| 115 | + line string |
| 116 | + err error |
| 117 | + } |
| 118 | + ch := make(chan readResult, 1) |
| 119 | + go func() { |
| 120 | + line, err := bufio.NewReader(reader).ReadString('\n') |
| 121 | + ch <- readResult{line: line, err: err} |
| 122 | + }() |
| 123 | + |
| 124 | + select { |
| 125 | + case r := <-ch: |
| 126 | + confirmed := strings.EqualFold(strings.TrimSpace(r.line), "yes") |
| 127 | + switch { |
| 128 | + case confirmed: |
| 129 | + return true |
| 130 | + case r.err == io.EOF && strings.TrimSpace(r.line) == "": |
| 131 | + // Non-TTY callers (MCP, piped subprocess) typically hit EOF |
| 132 | + // instantly. Skip the chatty "(not opening browser)" notice |
| 133 | + // since the result map already conveys the outcome. |
| 134 | + return false |
| 135 | + default: |
| 136 | + fmt.Fprintln(confirmWriter, "(not opening browser)") |
| 137 | + return false |
| 138 | + } |
| 139 | + case <-time.After(confirmOpenBrowserTimeout): |
| 140 | + fmt.Fprintln(confirmWriter, "\n(timed out; not opening browser)") |
| 141 | + return false |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +// updateMessageFor crafts the user-facing instruction printed alongside the |
| 146 | +// browser launch. The lead sentence makes it explicit that the CLI does not |
| 147 | +// itself edit the connector — the link (returned in the `url` field) is the |
| 148 | +// only path, and the trailing clause points the user at the pencil icon |
| 149 | +// inside the credentials page. The most specific phrasing |
| 150 | +// (name + workspace + id) is preferred; the id-only fallback covers the |
| 151 | +// --id invocation path. |
| 152 | +const updateDisclaimer = "Connectors cannot be edited through the CLI. Visit the link below to update the connector config" |
| 153 | + |
| 154 | +func updateMessageFor(name, workspace, id string) string { |
| 155 | + switch { |
| 156 | + case name != "" && workspace != "": |
| 157 | + return fmt.Sprintf("%s — find connector %q (id %s) in workspace %q on the credentials page and click the pencil icon to edit.", updateDisclaimer, name, id, workspace) |
| 158 | + case name != "": |
| 159 | + return fmt.Sprintf("%s — find connector %q (id %s) on the credentials page and click the pencil icon to edit.", updateDisclaimer, name, id) |
| 160 | + default: |
| 161 | + return fmt.Sprintf("%s — find the connector with id %s on the credentials page and click the pencil icon to edit.", updateDisclaimer, id) |
| 162 | + } |
| 163 | +} |
| 164 | + |
| 165 | +// credentialsPageURL builds the webapp URL the browser opens for credential |
| 166 | +// edits: <baseURL>/organizations/<orgID>/credentials. The base URL may carry |
| 167 | +// an existing path; a trailing slash is collapsed so the result never |
| 168 | +// contains "//organizations/...". orgID is URL-path-escaped to defend against |
| 169 | +// path injection if the org id ever contains slashes or other reserved |
| 170 | +// characters. Both u.Path and u.RawPath are set so url.URL.String preserves |
| 171 | +// the already-escaped form instead of percent-encoding the escape sequences |
| 172 | +// a second time. |
| 173 | +func credentialsPageURL(baseURL, orgID string) (string, error) { |
| 174 | + u, err := url.Parse(baseURL) |
| 175 | + if err != nil { |
| 176 | + return "", fmt.Errorf("parsing web app URL: %w", err) |
| 177 | + } |
| 178 | + trimmed := strings.TrimRight(u.Path, "/") |
| 179 | + escapedOrg := url.PathEscape(orgID) |
| 180 | + u.Path = trimmed + "/organizations/" + orgID + "/credentials" |
| 181 | + u.RawPath = trimmed + "/organizations/" + escapedOrg + "/credentials" |
| 182 | + return u.String(), nil |
| 183 | +} |
0 commit comments