Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 47 additions & 36 deletions cmd/onecli/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import (

// AppsCmd is the `onecli apps` command group.
type AppsCmd struct {
List AppsListCmd `cmd:"" help:"List all app connections."`
Connect AppsConnectCmd `cmd:"" help:"Connect an OAuth app (e.g. Google)."`
Disconnect AppsDisconnectCmd `cmd:"" help:"Disconnect an app."`
List AppsListCmd `cmd:"" help:"List all apps with config and connection status."`
Configure AppsConfigureCmd `cmd:"" help:"Save OAuth credentials (BYOC) for a provider."`
Remove AppsRemoveCmd `cmd:"" help:"Remove OAuth credentials for a provider."`
Disconnect AppsDisconnectCmd `cmd:"" help:"Disconnect an app connection."`
}

// AppsListCmd is `onecli apps list`.
Expand Down Expand Up @@ -41,87 +42,97 @@ func (c *AppsListCmd) Run(out *output.Writer) error {
return out.WriteFiltered(apps, c.Fields)
}

// AppsConnectCmd is `onecli apps connect`.
type AppsConnectCmd struct {
Provider string `required:"" help:"Provider name (e.g. 'google')."`
// AppsConfigureCmd is `onecli apps configure`.
type AppsConfigureCmd struct {
Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."`
ClientID string `required:"" name:"client-id" help:"OAuth client ID."`
ClientSecret string `required:"" name:"client-secret" help:"OAuth client secret."`
Json string `optional:"" help:"Raw JSON payload. Overrides individual flags."`
DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."`
}

const docsBaseURL = "https://onecli.sh/docs/guides/credential-stubs"

// connectResult wraps the API response with onboarding guidance as structured fields.
type connectResult struct {
api.App
NextSteps string `json:"next_steps"`
DocsURL string `json:"docs_url"`
}

func (c *AppsConnectCmd) Run(out *output.Writer) error {
var input api.ConnectAppInput
func (c *AppsConfigureCmd) Run(out *output.Writer) error {
var input api.ConfigAppInput
if c.Json != "" {
if err := json.Unmarshal([]byte(c.Json), &input); err != nil {
return fmt.Errorf("invalid JSON payload: %w", err)
}
} else {
input = api.ConnectAppInput{
Provider: c.Provider,
input = api.ConfigAppInput{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
}
}

if err := validate.ResourceID(input.Provider); err != nil {
if err := validate.ResourceID(c.Provider); err != nil {
return fmt.Errorf("invalid provider: %w", err)
}

if c.DryRun {
preview := map[string]string{
"provider": input.Provider,
"provider": c.Provider,
"clientId": input.ClientID,
"clientSecret": "***",
}
return out.WriteDryRun("Would connect app", preview)
return out.WriteDryRun("Would configure app", preview)
}

client, err := newClient()
if err != nil {
return err
}
app, err := client.ConnectApp(newContext(), input)
if err != nil {
if err := client.ConfigureApp(newContext(), c.Provider, input); err != nil {
return err
}

result := connectResult{
App: *app,
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.",
DocsURL: docsBaseURL + "/" + input.Provider + ".md",
return out.Write(map[string]string{
"status": "configured",
"provider": c.Provider,
})
}

// AppsRemoveCmd is `onecli apps remove`.
type AppsRemoveCmd struct {
Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."`
DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."`
}

func (c *AppsRemoveCmd) Run(out *output.Writer) error {
if err := validate.ResourceID(c.Provider); err != nil {
return fmt.Errorf("invalid provider: %w", err)
}
return out.Write(result)
if c.DryRun {
return out.WriteDryRun("Would remove app config", map[string]string{"provider": c.Provider})
}
client, err := newClient()
if err != nil {
return err
}
if err := client.UnconfigureApp(newContext(), c.Provider); err != nil {
return err
}
return out.Write(map[string]string{"status": "removed", "provider": c.Provider})
}

// AppsDisconnectCmd is `onecli apps disconnect`.
type AppsDisconnectCmd struct {
ID string `required:"" help:"ID of the app connection to disconnect."`
DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."`
Provider string `required:"" help:"Provider name (e.g. 'github', 'gmail')."`
DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."`
}

func (c *AppsDisconnectCmd) Run(out *output.Writer) error {
if err := validate.ResourceID(c.ID); err != nil {
return fmt.Errorf("invalid app ID: %w", err)
if err := validate.ResourceID(c.Provider); err != nil {
return fmt.Errorf("invalid provider: %w", err)
}
if c.DryRun {
return out.WriteDryRun("Would disconnect app", map[string]string{"id": c.ID})
return out.WriteDryRun("Would disconnect app", map[string]string{"provider": c.Provider})
}
client, err := newClient()
if err != nil {
return err
}
if err := client.DisconnectApp(newContext(), c.ID); err != nil {
if err := client.DisconnectApp(newContext(), c.Provider); err != nil {
return err
}
return out.Write(map[string]string{"status": "disconnected", "id": c.ID})
return out.Write(map[string]string{"status": "disconnected", "provider": c.Provider})
}
13 changes: 8 additions & 5 deletions cmd/onecli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,17 @@ func (cmd *HelpCmd) Run(out *output.Writer) error {
{Name: "secrets delete", Description: "Delete a secret.", Args: []ArgInfo{
{Name: "--id", Required: true, Description: "ID of the secret to delete."},
}},
{Name: "apps list", Description: "List all app connections."},
{Name: "apps connect", Description: "Connect an OAuth app.", Args: []ArgInfo{
{Name: "--provider", Required: true, Description: "Provider name (e.g. 'google')."},
{Name: "apps list", Description: "List all apps with config and connection status."},
{Name: "apps configure", Description: "Save OAuth credentials (BYOC) for a provider.", Args: []ArgInfo{
{Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."},
{Name: "--client-id", Required: true, Description: "OAuth client ID."},
{Name: "--client-secret", Required: true, Description: "OAuth client secret."},
}},
{Name: "apps disconnect", Description: "Disconnect an app.", Args: []ArgInfo{
{Name: "--id", Required: true, Description: "ID of the app connection to disconnect."},
{Name: "apps remove", Description: "Remove OAuth credentials for a provider.", Args: []ArgInfo{
{Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."},
}},
{Name: "apps disconnect", Description: "Disconnect an app connection.", Args: []ArgInfo{
{Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."},
}},
{Name: "rules list", Description: "List all policy rules."},
{Name: "rules create", Description: "Create a new policy rule.", Args: []ArgInfo{
Expand Down
60 changes: 41 additions & 19 deletions internal/api/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,37 @@ import (
"net/http"
)

// App represents an app connection returned by the API.
// App represents an app from the unified /api/apps listing.
type App struct {
ID string `json:"id"`
Provider string `json:"provider"`
Status string `json:"status"`
Docs string `json:"docs,omitempty"`
CreatedAt string `json:"createdAt"`
ID string `json:"id"`
Name string `json:"name"`
Available bool `json:"available"`
ConnectionType string `json:"connectionType"`
Configurable bool `json:"configurable"`
Config *AppConfig `json:"config"`
Connection *AppConnection `json:"connection"`
}

// ConnectAppInput is the request body for connecting an app.
type ConnectAppInput struct {
Provider string `json:"provider"`
// AppConfig is the BYOC credential configuration status.
type AppConfig struct {
HasCredentials bool `json:"hasCredentials"`
Enabled bool `json:"enabled"`
}

// AppConnection is the OAuth connection status.
type AppConnection struct {
Status string `json:"status"`
Scopes []string `json:"scopes"`
ConnectedAt string `json:"connectedAt"`
}

// ConfigAppInput is the request body for saving BYOC credentials.
type ConfigAppInput struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}

// ListApps returns all app connections for the authenticated user.
// ListApps returns all apps with their config and connection status.
func (c *Client) ListApps(ctx context.Context) ([]App, error) {
var apps []App
if err := c.do(ctx, http.MethodGet, "/api/apps", nil, &apps); err != nil {
Expand All @@ -31,18 +45,26 @@ func (c *Client) ListApps(ctx context.Context) ([]App, error) {
return apps, nil
}

// ConnectApp creates a new app connection.
func (c *Client) ConnectApp(ctx context.Context, input ConnectAppInput) (*App, error) {
var app App
if err := c.do(ctx, http.MethodPost, "/api/apps", input, &app); err != nil {
return nil, fmt.Errorf("connecting app: %w", err)
// ConfigureApp saves BYOC credentials for a provider.
func (c *Client) ConfigureApp(ctx context.Context, provider string, input ConfigAppInput) error {
var resp SuccessResponse
if err := c.do(ctx, http.MethodPost, "/api/apps/"+provider+"/config", input, &resp); err != nil {
return fmt.Errorf("configuring app: %w", err)
}
return &app, nil
return nil
}

// UnconfigureApp removes BYOC credentials for a provider.
func (c *Client) UnconfigureApp(ctx context.Context, provider string) error {
if err := c.do(ctx, http.MethodDelete, "/api/apps/"+provider+"/config", nil, nil); err != nil {
return fmt.Errorf("unconfiguring app: %w", err)
}
return nil
}

// DisconnectApp removes an app connection by ID.
func (c *Client) DisconnectApp(ctx context.Context, id string) error {
if err := c.do(ctx, http.MethodDelete, "/api/apps/"+id, nil, nil); err != nil {
// DisconnectApp removes the OAuth connection for a provider.
func (c *Client) DisconnectApp(ctx context.Context, provider string) error {
if err := c.do(ctx, http.MethodDelete, "/api/apps/"+provider+"/connection", nil, nil); err != nil {
return fmt.Errorf("disconnecting app: %w", err)
}
return nil
Expand Down
Loading