Skip to content

Commit 49cccb2

Browse files
johnnyfishguyb1
andauthored
feat: add apps connect/list/disconnect commands for OAuth app connections (#12)
* feat: add apps connect/list/disconnect commands for OAuth app connections * fix --------- Co-authored-by: Guy Ben Aharon <guybenah@gmail.com>
1 parent b127ac2 commit 49cccb2

File tree

5 files changed

+189
-0
lines changed

5 files changed

+189
-0
lines changed

cmd/onecli/apps.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
7+
"github.com/onecli/onecli-cli/internal/api"
8+
"github.com/onecli/onecli-cli/pkg/output"
9+
"github.com/onecli/onecli-cli/pkg/validate"
10+
)
11+
12+
// AppsCmd is the `onecli apps` command group.
13+
type AppsCmd struct {
14+
List AppsListCmd `cmd:"" help:"List all app connections."`
15+
Connect AppsConnectCmd `cmd:"" help:"Connect an OAuth app (e.g. Google)."`
16+
Disconnect AppsDisconnectCmd `cmd:"" help:"Disconnect an app."`
17+
}
18+
19+
// AppsListCmd is `onecli apps list`.
20+
type AppsListCmd struct {
21+
Fields string `optional:"" help:"Comma-separated list of fields to include in output."`
22+
Quiet string `optional:"" name:"quiet" help:"Output only the specified field, one per line."`
23+
Max int `optional:"" default:"20" help:"Maximum number of results to return."`
24+
}
25+
26+
func (c *AppsListCmd) Run(out *output.Writer) error {
27+
client, err := newClient()
28+
if err != nil {
29+
return err
30+
}
31+
apps, err := client.ListApps(newContext())
32+
if err != nil {
33+
return err
34+
}
35+
if c.Max > 0 && len(apps) > c.Max {
36+
apps = apps[:c.Max]
37+
}
38+
if c.Quiet != "" {
39+
return out.WriteQuiet(apps, c.Quiet)
40+
}
41+
return out.WriteFiltered(apps, c.Fields)
42+
}
43+
44+
// AppsConnectCmd is `onecli apps connect`.
45+
type AppsConnectCmd struct {
46+
Provider string `required:"" help:"Provider name (e.g. 'google')."`
47+
ClientID string `required:"" name:"client-id" help:"OAuth client ID."`
48+
ClientSecret string `required:"" name:"client-secret" help:"OAuth client secret."`
49+
Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."`
50+
DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."`
51+
}
52+
53+
const docsBaseURL = "https://onecli.sh/docs/guides/credential-stubs"
54+
55+
// connectResult wraps the API response with onboarding guidance as structured fields.
56+
type connectResult struct {
57+
api.App
58+
NextSteps string `json:"next_steps"`
59+
DocsURL string `json:"docs_url"`
60+
}
61+
62+
func (c *AppsConnectCmd) Run(out *output.Writer) error {
63+
var input api.ConnectAppInput
64+
if c.Json != "" {
65+
if err := json.Unmarshal([]byte(c.Json), &input); err != nil {
66+
return fmt.Errorf("invalid JSON payload: %w", err)
67+
}
68+
} else {
69+
input = api.ConnectAppInput{
70+
Provider: c.Provider,
71+
ClientID: c.ClientID,
72+
ClientSecret: c.ClientSecret,
73+
}
74+
}
75+
76+
if err := validate.ResourceID(input.Provider); err != nil {
77+
return fmt.Errorf("invalid provider: %w", err)
78+
}
79+
80+
if c.DryRun {
81+
preview := map[string]string{
82+
"provider": input.Provider,
83+
"clientId": input.ClientID,
84+
"clientSecret": "***",
85+
}
86+
return out.WriteDryRun("Would connect app", preview)
87+
}
88+
89+
client, err := newClient()
90+
if err != nil {
91+
return err
92+
}
93+
app, err := client.ConnectApp(newContext(), input)
94+
if err != nil {
95+
return err
96+
}
97+
98+
result := connectResult{
99+
App: *app,
100+
NextSteps: "Create local credential stub files using 'onecli-managed' as placeholder for all secrets. The OneCLI gateway handles real OAuth token exchange at request time.",
101+
DocsURL: docsBaseURL + "/" + input.Provider + ".md",
102+
}
103+
return out.Write(result)
104+
}
105+
106+
// AppsDisconnectCmd is `onecli apps disconnect`.
107+
type AppsDisconnectCmd struct {
108+
ID string `required:"" help:"ID of the app connection to disconnect."`
109+
DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."`
110+
}
111+
112+
func (c *AppsDisconnectCmd) Run(out *output.Writer) error {
113+
if err := validate.ResourceID(c.ID); err != nil {
114+
return fmt.Errorf("invalid app ID: %w", err)
115+
}
116+
if c.DryRun {
117+
return out.WriteDryRun("Would disconnect app", map[string]string{"id": c.ID})
118+
}
119+
client, err := newClient()
120+
if err != nil {
121+
return err
122+
}
123+
if err := client.DisconnectApp(newContext(), c.ID); err != nil {
124+
return err
125+
}
126+
return out.Write(map[string]string{"status": "disconnected", "id": c.ID})
127+
}

cmd/onecli/help.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ func (cmd *HelpCmd) Run(out *output.Writer) error {
7979
{Name: "secrets delete", Description: "Delete a secret.", Args: []ArgInfo{
8080
{Name: "--id", Required: true, Description: "ID of the secret to delete."},
8181
}},
82+
{Name: "apps list", Description: "List all app connections."},
83+
{Name: "apps connect", Description: "Connect an OAuth app.", Args: []ArgInfo{
84+
{Name: "--provider", Required: true, Description: "Provider name (e.g. 'google')."},
85+
{Name: "--client-id", Required: true, Description: "OAuth client ID."},
86+
{Name: "--client-secret", Required: true, Description: "OAuth client secret."},
87+
}},
88+
{Name: "apps disconnect", Description: "Disconnect an app.", Args: []ArgInfo{
89+
{Name: "--id", Required: true, Description: "ID of the app connection to disconnect."},
90+
}},
8291
{Name: "rules list", Description: "List all policy rules."},
8392
{Name: "rules create", Description: "Create a new policy rule.", Args: []ArgInfo{
8493
{Name: "--name", Required: true, Description: "Display name for the rule."},

cmd/onecli/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type CLI struct {
2323
Help HelpCmd `cmd:"" help:"Show available commands."`
2424
Agents AgentsCmd `cmd:"" help:"Manage agents."`
2525
Secrets SecretsCmd `cmd:"" help:"Manage secrets."`
26+
Apps AppsCmd `cmd:"" help:"Manage app connections."`
2627
Rules RulesCmd `cmd:"" help:"Manage policy rules."`
2728
Auth AuthCmd `cmd:"" help:"Manage authentication."`
2829
Config ConfigCmd `cmd:"" help:"Manage configuration settings."`
@@ -117,6 +118,8 @@ func hintForCommand(cmd, host string) string {
117118
return "Manage your secrets \u2192 " + host
118119
case "agents":
119120
return "Manage your agents \u2192 " + host
121+
case "apps":
122+
return "Manage your app connections \u2192 " + host
120123
case "rules":
121124
return "Manage your policy rules \u2192 " + host
122125
case "auth":

internal/api/apps.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
)
8+
9+
// App represents an app connection returned by the API.
10+
type App struct {
11+
ID string `json:"id"`
12+
Provider string `json:"provider"`
13+
Status string `json:"status"`
14+
Docs string `json:"docs,omitempty"`
15+
CreatedAt string `json:"createdAt"`
16+
}
17+
18+
// ConnectAppInput is the request body for connecting an app.
19+
type ConnectAppInput struct {
20+
Provider string `json:"provider"`
21+
ClientID string `json:"clientId"`
22+
ClientSecret string `json:"clientSecret"`
23+
}
24+
25+
// ListApps returns all app connections for the authenticated user.
26+
func (c *Client) ListApps(ctx context.Context) ([]App, error) {
27+
var apps []App
28+
if err := c.do(ctx, http.MethodGet, "/api/apps", nil, &apps); err != nil {
29+
return nil, fmt.Errorf("listing apps: %w", err)
30+
}
31+
return apps, nil
32+
}
33+
34+
// ConnectApp creates a new app connection.
35+
func (c *Client) ConnectApp(ctx context.Context, input ConnectAppInput) (*App, error) {
36+
var app App
37+
if err := c.do(ctx, http.MethodPost, "/api/apps", input, &app); err != nil {
38+
return nil, fmt.Errorf("connecting app: %w", err)
39+
}
40+
return &app, nil
41+
}
42+
43+
// DisconnectApp removes an app connection by ID.
44+
func (c *Client) DisconnectApp(ctx context.Context, id string) error {
45+
if err := c.do(ctx, http.MethodDelete, "/api/apps/"+id, nil, nil); err != nil {
46+
return fmt.Errorf("disconnecting app: %w", err)
47+
}
48+
return nil
49+
}

pkg/output/output.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func NewWithWriters(out, err io.Writer) *Writer {
3838
// property in every JSON response written to stdout.
3939
func (w *Writer) SetHint(msg string) {
4040
w.hint = msg
41+
w.hintFn = nil
4142
}
4243

4344
// SetHintFunc sets a function that lazily resolves the hint message at write

0 commit comments

Comments
 (0)