Skip to content

Commit c173592

Browse files
authored
feat: gram profile (#455)
Add profile support to CLI for storing and managing credentials. Users can now save their authentication credentials in named profiles, eliminating the need to pass them as explicit environment variables for each command invocation.
1 parent 4b35071 commit c173592

File tree

9 files changed

+242
-66
lines changed

9 files changed

+242
-66
lines changed

.changeset/slow-parrots-join.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@gram/cli": minor
3+
---
4+
5+
Add profile support to CLI for storing and managing credentials. Users can now save their authentication credentials in named profiles, eliminating the need to pass them as explicit environment variables for each command invocation.

cli/internal/app/app.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/speakeasy-api/gram/cli/internal/app/logging"
1212
"github.com/speakeasy-api/gram/cli/internal/o11y"
13+
"github.com/speakeasy-api/gram/cli/internal/profile"
1314
)
1415

1516
func newApp() *cli.App {
@@ -18,6 +19,8 @@ func newApp() *cli.App {
1819
shortSha = GitSHA[:7]
1920
}
2021

22+
defaultProfilePath, _ := profile.DefaultProfilePath()
23+
2124
return &cli.App{
2225
Name: "gram",
2326
Usage: "A command line interface for the Gram platform. Get started at https://docs.getgram.ai/",
@@ -46,6 +49,12 @@ func newApp() *cli.App {
4649
Usage: "Toggle pretty logging",
4750
EnvVars: []string{"GRAM_LOG_PRETTY"},
4851
},
52+
&cli.StringFlag{
53+
Name: "profile-path",
54+
Usage: fmt.Sprintf("Path to profile JSON file (default: %s)", defaultProfilePath),
55+
EnvVars: []string{"GRAM_PROFILE_PATH"},
56+
Hidden: true,
57+
},
4958
},
5059
Before: func(c *cli.Context) error {
5160
logger := slog.New(o11y.NewLogHandler(&o11y.LogHandlerOptions{
@@ -55,6 +64,29 @@ func newApp() *cli.App {
5564
}))
5665

5766
ctx := logging.PushLogger(c.Context, logger)
67+
68+
profilePath := c.String("profile-path")
69+
userSpecifiedPath := c.IsSet("profile-path")
70+
if profilePath == "" {
71+
profilePath = defaultProfilePath
72+
}
73+
prof, err := profile.Load(profilePath)
74+
if err != nil {
75+
logger.WarnContext(
76+
ctx,
77+
"failed to load profile, continuing without it",
78+
slog.String("profile path", profilePath),
79+
slog.String("error", err.Error()),
80+
)
81+
} else if userSpecifiedPath && prof == nil {
82+
logger.WarnContext(
83+
ctx,
84+
"profile file not found at specified path",
85+
slog.String("profile path", profilePath),
86+
)
87+
}
88+
ctx = profile.WithProfile(ctx, prof)
89+
5890
c.Context = ctx
5991
return nil
6092
},

cli/internal/app/push.go

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package app
33
import (
44
"fmt"
55
"log/slog"
6-
"net/url"
76
"os"
87
"os/signal"
98
"path/filepath"
@@ -13,7 +12,8 @@ import (
1312
"github.com/speakeasy-api/gram/cli/internal/deploy"
1413
"github.com/speakeasy-api/gram/cli/internal/flags"
1514
"github.com/speakeasy-api/gram/cli/internal/o11y"
16-
"github.com/speakeasy-api/gram/cli/internal/secret"
15+
"github.com/speakeasy-api/gram/cli/internal/profile"
16+
"github.com/speakeasy-api/gram/cli/internal/workflow"
1717
"github.com/urfave/cli/v2"
1818
)
1919

@@ -65,15 +65,11 @@ NOTE: Names and slugs must be unique across all sources.`[1:],
6565
defer cancel()
6666

6767
logger := logging.PullLogger(ctx)
68-
projectSlug := c.String("project")
68+
prof := profile.FromContext(ctx)
6969

70-
apiURL, err := url.Parse(c.String("api-url"))
70+
workflowParams, err := workflow.ResolveParams(c, prof)
7171
if err != nil {
72-
return fmt.Errorf(
73-
"failed to parse API URL '%s': %w",
74-
c.String("api-url"),
75-
err,
76-
)
72+
return fmt.Errorf("failed to resolve workflow params: %w", err)
7773
}
7874

7975
configFilename, err := filepath.Abs(c.String("config"))
@@ -97,16 +93,11 @@ NOTE: Names and slugs must be unique across all sources.`[1:],
9793
logger.InfoContext(
9894
ctx,
9995
"Deploying to project",
100-
slog.String("project", projectSlug),
96+
slog.String("project", workflowParams.ProjectSlug),
10197
slog.String("config", c.String("config")),
10298
)
10399

104-
params := deploy.WorkflowParams{
105-
APIKey: secret.Secret(c.String("api-key")),
106-
APIURL: apiURL,
107-
ProjectSlug: projectSlug,
108-
}
109-
result := deploy.NewWorkflow(ctx, logger, params).
100+
result := workflow.New(ctx, logger, workflowParams).
110101
UploadAssets(ctx, config.Sources).
111102
CreateDeployment(ctx, c.String("idempotency-key"))
112103

cli/internal/app/status.go

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@ package app
33
import (
44
"encoding/json"
55
"fmt"
6-
"net/url"
76
"os"
87
"os/signal"
98
"syscall"
109

1110
"github.com/speakeasy-api/gram/cli/internal/app/logging"
12-
"github.com/speakeasy-api/gram/cli/internal/deploy"
1311
"github.com/speakeasy-api/gram/cli/internal/flags"
14-
"github.com/speakeasy-api/gram/cli/internal/secret"
12+
"github.com/speakeasy-api/gram/cli/internal/profile"
13+
"github.com/speakeasy-api/gram/cli/internal/workflow"
1514
"github.com/speakeasy-api/gram/server/gen/types"
1615
"github.com/urfave/cli/v2"
1716
)
@@ -42,25 +41,16 @@ If no deployment ID is provided, shows the status of the latest deployment.`,
4241
defer cancel()
4342

4443
logger := logging.PullLogger(ctx)
45-
projectSlug := c.String("project")
44+
prof := profile.FromContext(ctx)
4645
deploymentID := c.String("id")
4746
jsonOutput := c.Bool("json")
4847

49-
apiURL, err := url.Parse(c.String("api-url"))
48+
workflowParams, err := workflow.ResolveParams(c, prof)
5049
if err != nil {
51-
return fmt.Errorf(
52-
"failed to parse API URL '%s': %w",
53-
c.String("api-url"),
54-
err,
55-
)
50+
return fmt.Errorf("failed to resolve workflow params: %w", err)
5651
}
5752

58-
params := deploy.WorkflowParams{
59-
APIKey: secret.Secret(c.String("api-key")),
60-
APIURL: apiURL,
61-
ProjectSlug: projectSlug,
62-
}
63-
result := deploy.NewWorkflow(ctx, logger, params)
53+
result := workflow.New(ctx, logger, workflowParams)
6454

6555
if deploymentID != "" {
6656
result.LoadDeploymentByID(ctx, deploymentID)

cli/internal/app/upload.go

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ package app
33
import (
44
"fmt"
55
"log/slog"
6-
"net/url"
76
"os"
87
"os/signal"
98
"syscall"
109

1110
"github.com/speakeasy-api/gram/cli/internal/app/logging"
1211
"github.com/speakeasy-api/gram/cli/internal/deploy"
1312
"github.com/speakeasy-api/gram/cli/internal/flags"
14-
"github.com/speakeasy-api/gram/cli/internal/secret"
13+
"github.com/speakeasy-api/gram/cli/internal/profile"
14+
"github.com/speakeasy-api/gram/cli/internal/workflow"
1515
"github.com/urfave/cli/v2"
1616
)
1717

@@ -64,21 +64,14 @@ Example:
6464
defer cancel()
6565

6666
logger := logging.PullLogger(ctx)
67-
apiURL, err := url.Parse(c.String("api-url"))
67+
prof := profile.FromContext(ctx)
68+
69+
workflowParams, err := workflow.ResolveParams(c, prof)
6870
if err != nil {
69-
return fmt.Errorf(
70-
"failed to parse API URL '%s': %w",
71-
c.String("api-url"),
72-
err,
73-
)
74-
}
75-
params := deploy.WorkflowParams{
76-
APIKey: secret.Secret(c.String("api-key")),
77-
APIURL: apiURL,
78-
ProjectSlug: c.String("project"),
71+
return fmt.Errorf("failed to resolve workflow params: %w", err)
7972
}
8073

81-
result := deploy.NewWorkflow(ctx, logger, params).
74+
result := workflow.New(ctx, logger, workflowParams).
8275
UploadAssets(ctx, []deploy.Source{parseSource(c)}).
8376
LoadLatestDeployment(ctx)
8477
if result.Deployment == nil {

cli/internal/flags/flags.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ import "github.com/urfave/cli/v2"
55

66
func APIKey() *cli.StringFlag {
77
return &cli.StringFlag{
8-
Name: "api-key",
9-
Usage: "Your Gram API key (must be scoped as a 'Provider')",
10-
EnvVars: []string{"GRAM_API_KEY"},
11-
Required: true,
8+
Name: "api-key",
9+
Usage: "Your Gram API key (must be scoped as a 'Provider')",
10+
EnvVars: []string{"GRAM_API_KEY"},
1211
}
1312
}
1413

@@ -24,9 +23,8 @@ func APIEndpoint() *cli.StringFlag {
2423

2524
func Project() *cli.StringFlag {
2625
return &cli.StringFlag{
27-
Name: "project",
28-
Usage: "The target Gram project",
29-
EnvVars: []string{"GRAM_PROJECT"},
30-
Required: true,
26+
Name: "project",
27+
Usage: "The target Gram project",
28+
EnvVars: []string{"GRAM_PROJECT"},
3129
}
3230
}

cli/internal/profile/profile.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Package profile provides profile-based configuration management for the Gram
2+
// CLI.
3+
package profile
4+
5+
import (
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"os"
10+
"path/filepath"
11+
)
12+
13+
// Config represents the profile configuration file structure.
14+
type Config struct {
15+
Current string `json:"current"`
16+
Profiles map[string]*Profile `json:"profiles"`
17+
}
18+
19+
// Profile represents a single profile with authentication and project settings.
20+
type Profile struct {
21+
Secret string `json:"secret"`
22+
DefaultProjectSlug string `json:"defaultProjectSlug"`
23+
APIUrl string `json:"apiUrl"`
24+
Org any `json:"org"`
25+
Projects []any `json:"projects"`
26+
}
27+
28+
// DefaultProfilePath returns the default path to the profile configuration file.
29+
func DefaultProfilePath() (string, error) {
30+
homeDir, err := os.UserHomeDir()
31+
if err != nil {
32+
return "", fmt.Errorf("failed to get user home directory: %w", err)
33+
}
34+
return filepath.Join(homeDir, ".gram", "profile.json"), nil
35+
}
36+
37+
// Load reads the profile configuration from the specified path, or from
38+
// DefaultProfilePath() if path is empty, and returns the currently active
39+
// profile based on the "current" field.
40+
//
41+
// Returns (nil, nil) if the profile file doesn't exist.
42+
// Returns an error if the file is malformed or the current profile is invalid.
43+
func Load(path string) (*Profile, error) {
44+
var profilePath string
45+
if path != "" {
46+
profilePath = path
47+
} else {
48+
defaultPath, err := DefaultProfilePath()
49+
if err != nil {
50+
return nil, err
51+
}
52+
profilePath = defaultPath
53+
}
54+
55+
data, err := os.ReadFile(filepath.Clean(profilePath))
56+
if err != nil {
57+
if os.IsNotExist(err) {
58+
return nil, nil
59+
}
60+
return nil, fmt.Errorf("failed to read profile file: %w", err)
61+
}
62+
63+
var config Config
64+
if err := json.Unmarshal(data, &config); err != nil {
65+
return nil, fmt.Errorf("failed to parse profile file: %w", err)
66+
}
67+
68+
if config.Current == "" {
69+
return nil, fmt.Errorf("profile file missing 'current' field")
70+
}
71+
72+
profile, ok := config.Profiles[config.Current]
73+
if !ok {
74+
return nil, fmt.Errorf(
75+
"current profile '%s' not found in profiles",
76+
config.Current,
77+
)
78+
}
79+
80+
return profile, nil
81+
}
82+
83+
type contextKey string
84+
85+
const profileContextKey contextKey = "profile"
86+
87+
// FromContext retrieves the loaded profile from the context. Returns nil if no
88+
// profile was loaded.
89+
func FromContext(ctx context.Context) *Profile {
90+
if prof, ok := ctx.Value(profileContextKey).(*Profile); ok {
91+
return prof
92+
}
93+
return nil
94+
}
95+
96+
// WithProfile adds the incoming profile to ctx. No-ops if prof is nil.
97+
func WithProfile(ctx context.Context, prof *Profile) context.Context {
98+
if prof != nil {
99+
return context.WithValue(ctx, profileContextKey, prof)
100+
}
101+
return ctx
102+
}

0 commit comments

Comments
 (0)