Skip to content

Commit 7e5171b

Browse files
authored
feat: to update a connector, redirect to web app for now (#39)
* to update a connector, redirect to web app for now * avoid race
1 parent 6ae1ca1 commit 7e5171b

9 files changed

Lines changed: 810 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ The CLI uses a **resource-registry** pattern:
9898
| `connectors` | `describe` | Get connector details + schema | `name`+`workspace` or `--id` |
9999
| `connectors` | `execute` | Execute a connector action | `name`+`workspace` or `--id`, `entity`, `action`, `params` |
100100
| `connectors` | `create` | Interactive credential flow | `workspace`, `name` (template) or `id` (template ID) |
101+
| `connectors` | `update` | Open the browser to edit a connector's credentials | `name`+`workspace` or `--id` |
101102
| `connectors` | `delete` | Delete a connector | `name`+`workspace` or `--id` |
102103

103104
### Common Flags

CONTEXT.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,24 @@ airbyte-agent connectors create --json '{
135135
}'
136136
```
137137

138-
### 5. Deleting a Connector
138+
### 5. Updating Connector Credentials
139+
140+
```bash
141+
# Open the credentials page in your browser to edit an existing connector
142+
airbyte-agent connectors update --json '{"workspace": "my-workspace", "name": "my-source"}'
143+
```
144+
145+
The CLI resolves the connector, prints the action message + a `Type 'yes' to confirm (skips after 10s)` prompt, and opens your browser to `<webapp>/organizations/<org_id>/credentials` only if you type `yes` within the timeout. Any other input — `no`, empty line, EOF, or the timeout firing — skips the browser open. The result on stdout always includes `url`, `connector_id`, `message`, and `browser_opened: bool`, so non-interactive callers (MCP, CI, piped subprocesses) get the URL even when the prompt is skipped. Honors `AIRBYTE_WEBAPP_URL` for non-prod environments.
146+
147+
### 6. Deleting a Connector
139148

140149
```bash
141150
airbyte-agent connectors delete --json '{"workspace": "my-workspace", "name": "old-source"}'
142151
```
143152

144153
Delete is destructive and prompts for an interactive `"Type 'yes' to confirm:"` on a TTY. Without a TTY (e.g. piped agent input), the command refuses with a `validation_error` whose hint tells you to set `"allow_destructive": true` in `~/.airbyte-agent/settings.json` (or `AIRBYTE_ALLOW_DESTRUCTIVE=true`). Once that permission is granted, the prompt is skipped.
145154

146-
### 6. Schema Introspection
155+
### 7. Schema Introspection
147156

148157
Use the top-level `schema` command to see an operation's parameter schema (and underlying OpenAPI request/response) before calling it:
149158

@@ -164,7 +173,7 @@ airbyte-agent schema connectors execute
164173
# }
165174
```
166175

167-
### 7. Loading Parameters from a File
176+
### 8. Loading Parameters from a File
168177

169178
For complex JSON payloads, use `@filename`:
170179

internal/resources/connectors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func (cr *connectorsResource) Description() string { return "Create, manage, and
2323
func (cr *connectorsResource) Operations() []registry.Operation {
2424
return []registry.Operation{
2525
connectorsCreateOperation(),
26+
connectorsUpdateOperation(),
2627
{
2728
Name: "list",
2829
Description: "List connectors in a workspace",

internal/resources/connectors_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,6 +1142,7 @@ func TestConnectorsResourceOperations(t *testing.T) {
11421142

11431143
expected := map[string]bool{
11441144
"create": false,
1145+
"update": false,
11451146
"list": false,
11461147
"list-available": false,
11471148
"describe": false,
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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

Comments
 (0)