diff --git a/cmd/stui/main.go b/cmd/stui/main.go index 3566c4f..d8135f5 100644 --- a/cmd/stui/main.go +++ b/cmd/stui/main.go @@ -6,6 +6,7 @@ import ( "os" tea "github.com/charmbracelet/bubbletea" + "github.com/natevick/stui/internal/providers" "github.com/natevick/stui/internal/security" "github.com/natevick/stui/internal/tui" ) @@ -16,9 +17,12 @@ var ( func main() { // Parse flags - profile := flag.String("profile", os.Getenv("AWS_PROFILE"), "AWS profile to use (can also use AWS_PROFILE env var)") + profile := flag.String("profile", os.Getenv("AWS_PROFILE"), "Profile (aws/stui) or alias (minio) name to use (also AWS_PROFILE env var)") + alias := flag.String("alias", "", "Alias name (synonym of --profile, matching mc terminology)") + provider := flag.String("provider", "", "Force a provider: aws|minio|stui (skips cross-provider matching)") region := flag.String("region", os.Getenv("AWS_REGION"), "AWS region (can also use AWS_REGION env var)") bucket := flag.String("bucket", "", "Start directly in this S3 bucket") + endpoint := flag.String("endpoint-url", "", "Custom S3 endpoint URL (for MinIO, SeaweedFS, Ceph, etc.; overrides resolved)") demo := flag.Bool("demo", false, "Run with mock data (no AWS credentials needed)") showVersion := flag.Bool("version", false, "Show version and exit") flag.Parse() @@ -28,9 +32,32 @@ func main() { os.Exit(0) } + // --profile and --alias are synonyms (one names the same target). Reject a + // conflicting pair rather than silently picking one. + name := *profile + if *alias != "" { + if name != "" && name != *alias { + fmt.Fprintf(os.Stderr, "--profile and --alias are synonyms; specify only one\n") + os.Exit(1) + } + name = *alias + } + + if *provider != "" && !providers.Valid(*provider) { + fmt.Fprintf(os.Stderr, "Invalid provider %q (valid: aws, minio, stui)\n", *provider) + os.Exit(1) + } + + // --provider only takes effect together with a name; on its own the picker + // runs and would ignore it. Fail loudly instead of silently dropping it. + if *provider != "" && name == "" { + fmt.Fprintf(os.Stderr, "--provider requires --profile/--alias\n") + os.Exit(1) + } + // Validate inputs - if err := security.ValidProfileName(*profile); err != nil { - fmt.Fprintf(os.Stderr, "Invalid profile: %v\n", err) + if err := security.ValidProfileName(name); err != nil { + fmt.Fprintf(os.Stderr, "Invalid profile/alias: %v\n", err) os.Exit(1) } if err := security.ValidBucketName(*bucket); err != nil { @@ -40,9 +67,11 @@ func main() { // Create TUI model cfg := tui.Config{ - Profile: *profile, + Profile: name, + Provider: *provider, Region: *region, Bucket: *bucket, + Endpoint: *endpoint, DemoMode: *demo, } diff --git a/go.mod b/go.mod index 3064489..b97c980 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.6 require ( github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2/config v1.32.7 + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 github.com/charmbracelet/bubbles v0.21.1 @@ -17,7 +18,6 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect diff --git a/internal/aws/client.go b/internal/aws/client.go index de1d74c..41391d1 100644 --- a/internal/aws/client.go +++ b/internal/aws/client.go @@ -10,48 +10,136 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" ) // Client wraps the AWS S3 client with configuration type Client struct { - S3 *s3.Client - Config aws.Config - Profile string - Region string + S3 *s3.Client + Config aws.Config + Profile string + Region string + Endpoint string + + // opts is retained so WithRegion can rebuild an equivalent client + // (preserving endpoint, path-style and static credentials). + opts ClientOptions } -// NewClient creates a new AWS client with the specified profile -// Supports SSO profiles - user must run `aws sso login --profile ` first +// ClientOptions configures how the AWS/S3 client is built. +type ClientOptions struct { + Profile string + Region string + Endpoint string // custom S3-compatible endpoint URL; "" for real AWS + // PathStyle forces path-style addressing. If nil, it defaults to true + // whenever a custom Endpoint is in effect (required by most S3-compatible + // servers). + PathStyle *bool + // Static credentials. When AccessKeyID and SecretAccessKey are both set, + // they are used directly instead of the AWS profile / default credential + // chain. SessionToken is optional (for temporary credentials). + AccessKeyID string + SecretAccessKey string + SessionToken string +} + +// NewClient creates a new AWS client with the specified profile. +// Supports SSO profiles - user must run `aws sso login --profile ` first. func NewClient(ctx context.Context, profile, region string) (*Client, error) { - var opts []func(*config.LoadOptions) error + return NewClientWithOptions(ctx, ClientOptions{Profile: profile, Region: region}) +} - if profile != "" { - opts = append(opts, config.WithSharedConfigProfile(profile)) +// NewClientWithOptions creates a new AWS client, optionally pointed at a custom +// S3-compatible endpoint (SeaweedFS, MinIO, Ceph, etc.). +// +// Region and SSO are loaded from the standard AWS config (~/.aws/config, +// ~/.aws/credentials) via the SDK. Credentials come from opts.AccessKeyID / +// opts.SecretAccessKey when both are set (used directly, bypassing any AWS +// profile); otherwise from the named profile / SDK default chain. The custom +// endpoint is supplied by stui's own config (see internal/config), an mc alias +// (see internal/providers), or the --endpoint-url flag; stui never extends the +// AWS config schema itself. +// +// The endpoint actually used is, in order of precedence: opts.Endpoint, then +// anything the SDK itself resolved from standard config or AWS_ENDPOINT_URL[_S3] +// env vars. When any custom endpoint is in effect, path-style addressing is +// enabled unless opts.PathStyle explicitly disables it. +func NewClientWithOptions(ctx context.Context, opts ClientOptions) (*Client, error) { + var loadOpts []func(*config.LoadOptions) error + + useStaticCreds := opts.AccessKeyID != "" && opts.SecretAccessKey != "" + + // With static credentials the endpoint is self-contained (the "profile" may + // exist only in stui's config, not in ~/.aws), so don't try to load a shared + // AWS profile that might not exist. + if opts.Profile != "" && !useStaticCreds { + loadOpts = append(loadOpts, config.WithSharedConfigProfile(opts.Profile)) + } + if opts.Region != "" { + loadOpts = append(loadOpts, config.WithRegion(opts.Region)) } - if region != "" { - opts = append(opts, config.WithRegion(region)) + // Use static credentials from stui config when provided, bypassing the AWS + // profile / default credential chain. + if useStaticCreds { + loadOpts = append(loadOpts, config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider( + opts.AccessKeyID, opts.SecretAccessKey, opts.SessionToken, + ), + )) } - cfg, err := config.LoadDefaultConfig(ctx, opts...) + cfg, err := config.LoadDefaultConfig(ctx, loadOpts...) if err != nil { return nil, fmt.Errorf("failed to load AWS config: %w", err) } - s3Client := s3.NewFromConfig(cfg) + s3Client := s3.NewFromConfig(cfg, func(o *s3.Options) { + if opts.Endpoint != "" { + o.BaseEndpoint = aws.String(opts.Endpoint) + } + }) + + // Determine the endpoint actually in effect (explicit, or SDK-resolved from + // AWS_ENDPOINT_URL[_S3] env vars). + resolvedEndpoint := opts.Endpoint + if resolvedEndpoint == "" && s3Client.Options().BaseEndpoint != nil { + resolvedEndpoint = *s3Client.Options().BaseEndpoint + } + + // Decide on path-style addressing: explicit override, else default to true + // whenever a custom endpoint is in effect. + pathStyle := s3Client.Options().UsePathStyle + if opts.PathStyle != nil { + pathStyle = *opts.PathStyle + } else if resolvedEndpoint != "" { + pathStyle = true + } + + if resolvedEndpoint != "" && pathStyle != s3Client.Options().UsePathStyle { + s3Client = s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(resolvedEndpoint) + o.UsePathStyle = pathStyle + }) + } return &Client{ - S3: s3Client, - Config: cfg, - Profile: profile, - Region: cfg.Region, + S3: s3Client, + Config: cfg, + Profile: opts.Profile, + Region: cfg.Region, + Endpoint: resolvedEndpoint, + opts: opts, }, nil } -// WithRegion creates a new client with a different region +// WithRegion creates a new client with a different region, preserving the +// original endpoint, path-style and credential settings. func (c *Client) WithRegion(ctx context.Context, region string) (*Client, error) { - return NewClient(ctx, c.Profile, region) + opts := c.opts + opts.Region = region + return NewClientWithOptions(ctx, opts) } // ProfileInfo contains information about an AWS profile @@ -72,6 +160,11 @@ func ListProfiles() ([]ProfileInfo, error) { configPath := filepath.Join(homeDir, ".aws", "config") file, err := os.Open(configPath) if err != nil { + // No AWS config is fine: the user may rely solely on stui's own config + // (e.g. a self-contained MinIO/SeaweedFS endpoint). + if os.IsNotExist(err) { + return nil, nil + } return nil, fmt.Errorf("failed to open AWS config: %w", err) } defer file.Close() @@ -90,8 +183,8 @@ func ListProfiles() ([]ProfileInfo, error) { // Check for section header if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { - // Save previous profile if it exists and has SSO config - if currentProfile != nil && currentProfile.SSOSession != "" { + // Save previous profile if it exists + if currentProfile != nil { profiles = append(profiles, *currentProfile) } @@ -133,7 +226,7 @@ func ListProfiles() ([]ProfileInfo, error) { } // Don't forget the last profile - if currentProfile != nil && currentProfile.SSOSession != "" { + if currentProfile != nil { profiles = append(profiles, *currentProfile) } diff --git a/internal/aws/endpoint_resolve_test.go b/internal/aws/endpoint_resolve_test.go new file mode 100644 index 0000000..ada8832 --- /dev/null +++ b/internal/aws/endpoint_resolve_test.go @@ -0,0 +1,114 @@ +package aws + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestEndpointFromFlatProfile(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config") + os.WriteFile(cfgPath, []byte("[profile sw]\nregion = us-east-1\nendpoint_url = http://localhost:8333\n"), 0600) + t.Setenv("AWS_CONFIG_FILE", cfgPath) + t.Setenv("AWS_ACCESS_KEY_ID", "x") + t.Setenv("AWS_SECRET_ACCESS_KEY", "y") + + c, err := NewClient(context.Background(), "sw", "") + if err != nil { + t.Fatal(err) + } + if c.Endpoint != "http://localhost:8333" { + t.Fatalf("endpoint not resolved, got %q", c.Endpoint) + } + if !c.S3.Options().UsePathStyle { + t.Fatal("expected path-style addressing for custom endpoint") + } +} + +func TestServicesSectionEndpoint(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config") + os.WriteFile(cfgPath, []byte("[profile sw]\nregion = us-east-1\nservices = local\n\n[services local]\ns3 =\n endpoint_url = http://localhost:9000\n"), 0600) + t.Setenv("AWS_CONFIG_FILE", cfgPath) + t.Setenv("AWS_ACCESS_KEY_ID", "x") + t.Setenv("AWS_SECRET_ACCESS_KEY", "y") + + c, err := NewClient(context.Background(), "sw", "") + if err != nil { + t.Fatal(err) + } + if c.Endpoint != "http://localhost:9000" { + t.Fatalf("services-section endpoint not resolved, got %q", c.Endpoint) + } +} + + +func TestStaticCredentialsSkipProfile(t *testing.T) { + // Point AWS config/credentials at empty temp files so the default chain + // has nothing; static creds from ClientOptions must still work. + dir := t.TempDir() + t.Setenv("AWS_CONFIG_FILE", filepath.Join(dir, "config")) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "credentials")) + t.Setenv("AWS_REGION", "") + t.Setenv("AWS_PROFILE", "") + + c, err := NewClientWithOptions(context.Background(), ClientOptions{ + Profile: "minio", // does not exist in ~/.aws + Region: "us-east-1", + Endpoint: "http://localhost:9000", + AccessKeyID: "AKIDLOCAL", + SecretAccessKey: "secret123", + }) + if err != nil { + t.Fatal(err) + } + if c.Endpoint != "http://localhost:9000" { + t.Fatalf("endpoint wrong: %q", c.Endpoint) + } + if !c.S3.Options().UsePathStyle { + t.Fatal("expected path-style for custom endpoint") + } + creds, err := c.Config.Credentials.Retrieve(context.Background()) + if err != nil { + t.Fatalf("retrieve creds: %v", err) + } + if creds.AccessKeyID != "AKIDLOCAL" || creds.SecretAccessKey != "secret123" { + t.Fatalf("static creds not applied: %#v", creds) + } +} + +func TestWithRegionPreservesStaticCreds(t *testing.T) { + dir := t.TempDir() + t.Setenv("AWS_CONFIG_FILE", filepath.Join(dir, "config")) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "credentials")) + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_REGION", "") + + c, err := NewClientWithOptions(context.Background(), ClientOptions{ + Profile: "minio", + Region: "us-east-1", + Endpoint: "http://localhost:9000", + AccessKeyID: "AKIDLOCAL", + SecretAccessKey: "secret123", + }) + if err != nil { + t.Fatal(err) + } + + c2, err := c.WithRegion(context.Background(), "eu-west-1") + if err != nil { + t.Fatalf("WithRegion: %v", err) + } + if c2.Endpoint != "http://localhost:9000" || !c2.S3.Options().UsePathStyle { + t.Fatalf("WithRegion lost endpoint/path-style: %#v", c2.Endpoint) + } + creds, err := c2.Config.Credentials.Retrieve(context.Background()) + if err != nil { + t.Fatalf("retrieve creds after WithRegion: %v", err) + } + if creds.AccessKeyID != "AKIDLOCAL" || creds.SecretAccessKey != "secret123" { + t.Fatalf("WithRegion lost static creds: %#v", creds) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..97cae9a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,137 @@ +// Package config handles stui's own configuration, kept separate from the +// standard AWS config files (~/.aws/config, ~/.aws/credentials). +// +// AWS profiles, credentials, regions and SSO are read from ~/.aws as usual via +// the AWS SDK. stui never writes to or extends the AWS config schema. Anything +// that is specific to stui — most importantly custom S3 endpoints for +// S3-compatible servers like SeaweedFS, MinIO or Ceph — lives here, in +// ~/.config/stui/config.json. +// +// This is one of three independent provider configs stui understands (see +// internal/providers): "aws" (~/.aws), "minio" (~/.mc/config.json) and "stui" +// (this file). Providers never fall back to one another. +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// EndpointConfig describes how to reach a custom S3-compatible endpoint for a +// given AWS profile. Credentials and region still come from the AWS profile; +// these fields only override what the AWS SDK can't express for non-AWS +// backends. +type EndpointConfig struct { + // EndpointURL is the base URL of the S3-compatible server, e.g. + // "http://localhost:8333" (SeaweedFS) or "http://localhost:9000" (MinIO). + EndpointURL string `json:"endpoint_url"` + + // AccessKeyID and SecretAccessKey are optional static credentials for the + // endpoint. When set, they are used directly and no ~/.aws profile is + // required. When empty, credentials fall back to the AWS profile / SDK + // default chain (env, ~/.aws/credentials, SSO, etc.). + AccessKeyID string `json:"access_key_id,omitempty"` + SecretAccessKey string `json:"secret_access_key,omitempty"` + // SessionToken is an optional temporary-credential session token. + SessionToken string `json:"session_token,omitempty"` + + // PathStyle forces path-style ("endpoint/bucket/key") vs virtual-hosted + // ("bucket.endpoint/key") addressing. If nil, path-style is used + // automatically whenever EndpointURL is set, which is what most + // S3-compatible servers require. + PathStyle *bool `json:"path_style,omitempty"` + + // Region optionally overrides the region for this endpoint. Many + // S3-compatible servers accept any value (e.g. "us-east-1"). + Region string `json:"region,omitempty"` +} + +// Config is stui's persisted configuration. +type Config struct { + // Endpoints maps an AWS profile name to its custom endpoint settings. + Endpoints map[string]EndpointConfig `json:"endpoints,omitempty"` + + path string +} + +// configFileName is the stui config file inside ~/.config/stui. +const configFileName = "config.json" + +// Path returns the location of the stui config file. +func Path() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(homeDir, ".config", "stui", configFileName), nil +} + +// Load reads ~/.config/stui/config.json. A missing file is not an error; it +// returns an empty config so stui works out of the box. +func Load() (*Config, error) { + path, err := Path() + if err != nil { + return nil, err + } + + cfg := &Config{Endpoints: map[string]EndpointConfig{}, path: path} + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return nil, fmt.Errorf("failed to read stui config: %w", err) + } + + if err := json.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("failed to parse stui config %s: %w", path, err) + } + if cfg.Endpoints == nil { + cfg.Endpoints = map[string]EndpointConfig{} + } + cfg.path = path + return cfg, nil +} + +// Save writes the config to disk with 0600 permissions, creating the directory +// (0700) if needed. +func (c *Config) Save() error { + path := c.path + if path == "" { + p, err := Path() + if err != nil { + return err + } + path = p + } + + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal stui config: %w", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write stui config: %w", err) + } + return nil +} + +// EndpointFor returns the endpoint configuration for a profile, if one is +// defined. +func (c *Config) EndpointFor(profile string) (EndpointConfig, bool) { + if c == nil || c.Endpoints == nil || profile == "" { + return EndpointConfig{}, false + } + ep, ok := c.Endpoints[profile] + if !ok || ep.EndpointURL == "" { + return EndpointConfig{}, false + } + return ep, true +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..8829405 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,77 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadMissingFileReturnsEmpty(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cfg, err := Load() + if err != nil { + t.Fatalf("Load on missing file should not error: %v", err) + } + if cfg == nil || len(cfg.Endpoints) != 0 { + t.Fatalf("expected empty config, got %#v", cfg) + } + if _, ok := cfg.EndpointFor("anything"); ok { + t.Fatal("expected no endpoint for unknown profile") + } +} + +func TestLoadAndEndpointFor(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".config", "stui") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + body := `{ + "endpoints": { + "seaweed": { "endpoint_url": "http://localhost:8333" }, + "minio": { "endpoint_url": "http://localhost:9000", "path_style": false, "region": "us-east-1", "access_key_id": "AKIDLOCAL", "secret_access_key": "secret123" } + } +}` + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(body), 0600); err != nil { + t.Fatal(err) + } + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + + sw, ok := cfg.EndpointFor("seaweed") + if !ok || sw.EndpointURL != "http://localhost:8333" || sw.PathStyle != nil { + t.Fatalf("seaweed endpoint wrong: %#v ok=%v", sw, ok) + } + + mi, ok := cfg.EndpointFor("minio") + if !ok || mi.PathStyle == nil || *mi.PathStyle != false || mi.Region != "us-east-1" { + t.Fatalf("minio endpoint wrong: %#v ok=%v", mi, ok) + } + if mi.AccessKeyID != "AKIDLOCAL" || mi.SecretAccessKey != "secret123" { + t.Fatalf("minio credentials wrong: %#v", mi) + } +} + +func TestSaveRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + pathStyle := true + cfg := &Config{Endpoints: map[string]EndpointConfig{ + "local": {EndpointURL: "http://localhost:8333", PathStyle: &pathStyle}, + }} + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + + loaded, err := Load() + if err != nil { + t.Fatal(err) + } + ep, ok := loaded.EndpointFor("local") + if !ok || ep.EndpointURL != "http://localhost:8333" || ep.PathStyle == nil || !*ep.PathStyle { + t.Fatalf("round trip failed: %#v ok=%v", ep, ok) + } +} diff --git a/internal/providers/minio.go b/internal/providers/minio.go new file mode 100644 index 0000000..4029c10 --- /dev/null +++ b/internal/providers/minio.go @@ -0,0 +1,79 @@ +package providers + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// mcConfig is the subset of ~/.mc/config.json that stui reads. +type mcConfig struct { + Aliases map[string]mcAlias `json:"aliases"` +} + +type mcAlias struct { + URL string `json:"url"` + AccessKey string `json:"accessKey"` + SecretKey string `json:"secretKey"` + API string `json:"api"` // e.g. "s3v4" + Path string `json:"path"` // "auto" | "on" | "off" +} + +// mcConfigPath returns the location of the mc client config, honoring +// $MC_CONFIG_DIR like mc itself does. +func mcConfigPath() (string, error) { + if dir := os.Getenv("MC_CONFIG_DIR"); dir != "" { + return filepath.Join(dir, "config.json"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".mc", "config.json"), nil +} + +func minioEntries() ([]Entry, error) { + path, err := mcConfigPath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read mc config: %w", err) + } + + var cfg mcConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse mc config %s: %w", path, err) + } + + var entries []Entry + for name, a := range cfg.Aliases { + if a.URL == "" { + continue + } + e := Entry{ + Provider: MinIO, + Name: name, + Endpoint: a.URL, + AccessKeyID: a.AccessKey, + SecretAccessKey: a.SecretKey, + } + switch strings.ToLower(a.Path) { + case "on": + t := true + e.PathStyle = &t + case "off": + f := false + e.PathStyle = &f + } + entries = append(entries, e) + } + return entries, nil +} diff --git a/internal/providers/providers.go b/internal/providers/providers.go new file mode 100644 index 0000000..8fd8d9b --- /dev/null +++ b/internal/providers/providers.go @@ -0,0 +1,201 @@ +// Package providers resolves a connection target across the three independent +// configuration sources stui understands: +// +// - "aws" : AWS profiles in ~/.aws/config (+ ~/.aws/credentials) +// - "minio" : mc aliases in ~/.mc/config.json (or $MC_CONFIG_DIR) +// - "stui" : endpoints in ~/.config/stui/config.json +// +// Each provider is independent: there is never any fallback from one provider +// to another. A name (an AWS "profile" or an mc "alias") is looked up within a +// single provider only. If the same name exists in more than one provider, the +// user must disambiguate with an explicit provider. +package providers + +import ( + "errors" + "fmt" + "strings" + + "github.com/natevick/stui/internal/aws" +) + +// Provider identifiers. +const ( + AWS = "aws" + MinIO = "minio" + Stui = "stui" +) + +// All lists every supported provider, in resolution order. +var All = []string{AWS, MinIO, Stui} + +// Valid reports whether p is a known provider id. +func Valid(p string) bool { + for _, x := range All { + if x == p { + return true + } + } + return false +} + +// Entry is a resolved connection target from one provider. +type Entry struct { + Provider string // AWS | MinIO | Stui + Name string // profile (aws/stui) or alias (minio) + + Region string + Endpoint string // custom S3 endpoint; "" for real AWS + + // Static credentials (minio/stui). Empty for aws, which uses the SDK + // credential chain for the named profile. + AccessKeyID string + SecretAccessKey string + SessionToken string + + PathStyle *bool // nil = auto (path-style when Endpoint set) + + // AWS-only display metadata. + SSOSession string + AccountID string +} + +// ClientOptions maps an entry to AWS client options. The aws provider loads the +// named shared profile (with its own credential chain); the others are +// self-contained endpoints with their own static credentials. +func (e Entry) ClientOptions() aws.ClientOptions { + if e.Provider == AWS { + return aws.ClientOptions{Profile: e.Name, Region: e.Region} + } + return aws.ClientOptions{ + Region: e.Region, + Endpoint: e.Endpoint, + PathStyle: e.PathStyle, + AccessKeyID: e.AccessKeyID, + SecretAccessKey: e.SecretAccessKey, + SessionToken: e.SessionToken, + } +} + +// AmbiguousError is returned when a name matches more than one provider and no +// explicit provider was given. +type AmbiguousError struct { + Name string + Providers []string +} + +func (e *AmbiguousError) Error() string { + return fmt.Sprintf("%q matches multiple providers (%s); specify one with --provider <%s>", + e.Name, strings.Join(e.Providers, ", "), strings.Join(e.Providers, "|")) +} + +// entriesFor returns the entries for a single provider. A missing config file +// yields no entries and no error. +func entriesFor(provider string) ([]Entry, error) { + switch provider { + case AWS: + return awsEntries() + case MinIO: + return minioEntries() + case Stui: + return stuiEntries() + default: + return nil, fmt.Errorf("unknown provider %q (valid: %s)", provider, strings.Join(All, ", ")) + } +} + +// List returns every entry across all providers. It is best effort: entries +// from healthy providers are always returned, and any per-provider load errors +// (e.g. a malformed ~/.mc/config.json) are joined into the returned error so +// the caller can surface them without hiding the working providers. +func List() ([]Entry, error) { + var out []Entry + var errs []error + for _, p := range All { + entries, err := entriesFor(p) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", p, err)) + continue + } + out = append(out, entries...) + } + return out, errors.Join(errs...) +} + +// Resolve finds the entry for name. When providerHint is non-empty, only that +// provider is consulted (no cross-provider fallback ever). Otherwise every +// provider is searched; a name present in multiple providers is an +// *AmbiguousError. +func Resolve(name, providerHint string) (Entry, error) { + if name == "" { + return Entry{}, fmt.Errorf("no profile/alias specified") + } + + if providerHint != "" { + if !Valid(providerHint) { + return Entry{}, fmt.Errorf("unknown provider %q (valid: %s)", providerHint, strings.Join(All, ", ")) + } + entries, err := entriesFor(providerHint) + if err != nil { + return Entry{}, err + } + for _, e := range entries { + if e.Name == name { + return e, nil + } + } + return Entry{}, fmt.Errorf("profile/alias %q not found in provider %q", name, providerHint) + } + + var matches []Entry + var errs []error + for _, p := range All { + entries, err := entriesFor(p) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", p, err)) + continue + } + for _, e := range entries { + if e.Name == name { + matches = append(matches, e) + } + } + } + + switch len(matches) { + case 0: + if len(errs) > 0 { + // Don't claim "not found" when a provider failed to load — the name + // might live in the broken config. + return Entry{}, fmt.Errorf("profile/alias %q not found in any readable provider (%s); some providers failed to load: %w", + name, strings.Join(All, ", "), errors.Join(errs...)) + } + return Entry{}, fmt.Errorf("profile/alias %q not found in any provider (%s)", name, strings.Join(All, ", ")) + case 1: + return matches[0], nil + default: + provs := make([]string, len(matches)) + for i, m := range matches { + provs[i] = m.Provider + } + return Entry{}, &AmbiguousError{Name: name, Providers: provs} + } +} + +func awsEntries() ([]Entry, error) { + profiles, err := aws.ListProfiles() + if err != nil { + return nil, err + } + entries := make([]Entry, len(profiles)) + for i, p := range profiles { + entries[i] = Entry{ + Provider: AWS, + Name: p.Name, + Region: p.Region, + SSOSession: p.SSOSession, + AccountID: p.AccountID, + } + } + return entries, nil +} diff --git a/internal/providers/providers_test.go b/internal/providers/providers_test.go new file mode 100644 index 0000000..d0b5a06 --- /dev/null +++ b/internal/providers/providers_test.go @@ -0,0 +1,136 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +// setupConfigs writes isolated AWS, mc and stui configs into a temp HOME and +// points the relevant env vars at them. +func setupConfigs(t *testing.T, awsCfg, mcCfg, stuiCfg string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("MC_CONFIG_DIR", "") + t.Setenv("AWS_CONFIG_FILE", "") + + if awsCfg != "" { + dir := filepath.Join(home, ".aws") + os.MkdirAll(dir, 0700) + os.WriteFile(filepath.Join(dir, "config"), []byte(awsCfg), 0600) + } + if mcCfg != "" { + dir := filepath.Join(home, ".mc") + os.MkdirAll(dir, 0700) + os.WriteFile(filepath.Join(dir, "config.json"), []byte(mcCfg), 0600) + } + if stuiCfg != "" { + dir := filepath.Join(home, ".config", "stui") + os.MkdirAll(dir, 0700) + os.WriteFile(filepath.Join(dir, "config.json"), []byte(stuiCfg), 0600) + } +} + +const mcJSON = `{ + "version": "10", + "aliases": { + "local": { "url": "http://localhost:9000", "accessKey": "ak", "secretKey": "sk", "api": "s3v4", "path": "on" }, + "shared": { "url": "http://minio.example.com", "accessKey": "a", "secretKey": "b" } + } +}` + +const stuiJSON = `{ "endpoints": { + "seaweed": { "endpoint_url": "http://localhost:8333" }, + "shared": { "endpoint_url": "http://stui.example.com", "access_key_id": "x", "secret_access_key": "y" } +} }` + +const awsCfg = "[profile work]\nregion = us-east-1\n[profile shared]\nregion = eu-west-1\n" + +func TestResolveUniquePerProvider(t *testing.T) { + setupConfigs(t, awsCfg, mcJSON, stuiJSON) + + // "local" exists only in minio + e, err := Resolve("local", "") + if err != nil { + t.Fatal(err) + } + if e.Provider != MinIO || e.Endpoint != "http://localhost:9000" || e.PathStyle == nil || !*e.PathStyle { + t.Fatalf("local resolved wrong: %#v", e) + } + + // "work" exists only in aws + e, err = Resolve("work", "") + if err != nil { + t.Fatal(err) + } + if e.Provider != AWS || e.Endpoint != "" { + t.Fatalf("work resolved wrong: %#v", e) + } + + // "seaweed" only in stui + e, err = Resolve("seaweed", "") + if err != nil || e.Provider != Stui { + t.Fatalf("seaweed resolved wrong: %#v err=%v", e, err) + } +} + +func TestResolveAmbiguous(t *testing.T) { + setupConfigs(t, awsCfg, mcJSON, stuiJSON) + + // "shared" exists in all three providers + _, err := Resolve("shared", "") + amb, ok := err.(*AmbiguousError) + if !ok { + t.Fatalf("expected AmbiguousError, got %v", err) + } + if len(amb.Providers) != 3 { + t.Fatalf("expected 3 providers, got %v", amb.Providers) + } +} + +func TestResolveWithProviderHintNoFallback(t *testing.T) { + setupConfigs(t, awsCfg, mcJSON, stuiJSON) + + // Force minio for the ambiguous name. + e, err := Resolve("shared", MinIO) + if err != nil || e.Provider != MinIO || e.AccessKeyID != "a" { + t.Fatalf("forced minio wrong: %#v err=%v", e, err) + } + + // A name that exists elsewhere but not in the forced provider must NOT + // fall back. + if _, err := Resolve("work", MinIO); err == nil { + t.Fatal("expected not-found for work in minio (no fallback)") + } +} + +func TestResolveNotFound(t *testing.T) { + setupConfigs(t, awsCfg, mcJSON, stuiJSON) + if _, err := Resolve("nope", ""); err == nil { + t.Fatal("expected not-found error") + } +} + +func TestListSurfacesProviderErrors(t *testing.T) { + // Valid stui + aws, but a corrupt mc config. + setupConfigs(t, awsCfg, "{ this is not json", stuiJSON) + + entries, err := List() + if err == nil { + t.Fatal("expected error from corrupt mc config") + } + // Healthy providers must still contribute entries. + var sawAWS, sawStui bool + for _, e := range entries { + if e.Provider == AWS { + sawAWS = true + } + if e.Provider == Stui { + sawStui = true + } + } + if !sawAWS || !sawStui { + t.Fatalf("healthy providers dropped: aws=%v stui=%v", sawAWS, sawStui) + } +} diff --git a/internal/providers/stui.go b/internal/providers/stui.go new file mode 100644 index 0000000..c1f7d7c --- /dev/null +++ b/internal/providers/stui.go @@ -0,0 +1,27 @@ +package providers + +import "github.com/natevick/stui/internal/config" + +func stuiEntries() ([]Entry, error) { + cfg, err := config.Load() + if err != nil { + return nil, err + } + var entries []Entry + for name, ep := range cfg.Endpoints { + if ep.EndpointURL == "" { + continue + } + entries = append(entries, Entry{ + Provider: Stui, + Name: name, + Endpoint: ep.EndpointURL, + Region: ep.Region, + PathStyle: ep.PathStyle, + AccessKeyID: ep.AccessKeyID, + SecretAccessKey: ep.SecretAccessKey, + SessionToken: ep.SessionToken, + }) + } + return entries, nil +} diff --git a/internal/tui/model.go b/internal/tui/model.go index b8a42cf..a215308 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -8,6 +8,7 @@ import ( "github.com/natevick/stui/internal/aws" "github.com/natevick/stui/internal/bookmarks" "github.com/natevick/stui/internal/download" + "github.com/natevick/stui/internal/providers" "github.com/natevick/stui/internal/views/bookmarksview" "github.com/natevick/stui/internal/views/browser" "github.com/natevick/stui/internal/views/buckets" @@ -19,8 +20,10 @@ import ( type Model struct { // AWS client *aws.Client - profile string + profile string // profile/alias name (from --profile/--alias or picker) + providerHint string // explicit provider (from --provider or picker); "" = auto-resolve region string + endpoint string // custom S3 endpoint (from --endpoint-url), overrides resolved initialBucket string // bucket to start in (from --bucket flag) demoMode bool // use mock data @@ -65,9 +68,11 @@ type Model struct { // Config holds configuration for the TUI type Config struct { - Profile string + Profile string // profile (aws/stui) or alias (minio) name + Provider string // explicit provider: aws|minio|stui; "" = auto-resolve Region string Bucket string // Start directly in this bucket + Endpoint string // Custom S3 endpoint URL (MinIO/SeaweedFS/etc.) DemoMode bool // Use mock data instead of real AWS } @@ -86,7 +91,9 @@ func New(cfg Config) Model { return Model{ profile: cfg.Profile, + providerHint: cfg.Provider, region: cfg.Region, + endpoint: cfg.Endpoint, initialBucket: cfg.Bucket, demoMode: cfg.DemoMode, activeView: activeView, @@ -148,10 +155,27 @@ func (m Model) initDemo() tea.Cmd { // demoReadyMsg is sent when demo mode is ready type demoReadyMsg struct{} -// initAWS initializes the AWS client +// initAWS initializes the AWS client by resolving the requested profile/alias +// across the configured providers (aws, minio, stui). There is never any +// fallback between providers; an ambiguous name produces a clear error asking +// the user to pass --provider. func (m Model) initAWS() tea.Cmd { return func() tea.Msg { - client, err := aws.NewClient(m.ctx, m.profile, m.region) + entry, err := providers.Resolve(m.profile, m.providerHint) + if err != nil { + return ErrorMsg{Err: err} + } + + opts := entry.ClientOptions() + // CLI overrides. + if m.region != "" { + opts.Region = m.region + } + if m.endpoint != "" { + opts.Endpoint = m.endpoint + } + + client, err := aws.NewClientWithOptions(m.ctx, opts) if err != nil { return ErrorMsg{Err: err} } diff --git a/internal/tui/update.go b/internal/tui/update.go index 2ae5c62..05a256d 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -84,16 +84,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.loadDemoBuckets() case profilesReadyMsg: - // Load available profiles - if err := m.profilesView.LoadProfiles(); err != nil { + // Load available profiles/aliases from all providers + if err := m.profilesView.LoadEntries(); err != nil { m.errorMsg = security.SanitizeErrorGeneric(err, "Failed to load profiles") m.errorTimeout = time.Now().Add(5 * time.Second) } return m, nil case profiles.SelectedMsg: - // Profile was selected, initialize AWS with it + // Profile/alias was selected, initialize AWS with it m.profile = msg.Profile + m.providerHint = msg.Provider m.activeView = ViewBuckets m.bucketsView.SetLoading(true) return m, m.initAWS() diff --git a/internal/views/profiles/profiles.go b/internal/views/profiles/profiles.go index 43848c9..ad5c948 100644 --- a/internal/views/profiles/profiles.go +++ b/internal/views/profiles/profiles.go @@ -2,38 +2,53 @@ package profiles import ( "fmt" + "strings" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/natevick/stui/internal/aws" + "github.com/natevick/stui/internal/providers" ) -// Item represents a profile in the list +// Item represents a provider entry in the list type Item struct { - profile aws.ProfileInfo + entry providers.Entry } -func (i Item) Title() string { return i.profile.Name } +func (i Item) Title() string { + return fmt.Sprintf("%s [%s]", i.entry.Name, i.entry.Provider) +} func (i Item) Description() string { - desc := fmt.Sprintf("Region: %s", i.profile.Region) - if i.profile.AccountID != "" { - desc += fmt.Sprintf(" | Account: %s", i.profile.AccountID) + parts := []string{} + if i.entry.Region != "" { + parts = append(parts, "Region: "+i.entry.Region) + } + if i.entry.AccountID != "" { + parts = append(parts, "Account: "+i.entry.AccountID) + } + if i.entry.Endpoint != "" { + parts = append(parts, "Endpoint: "+i.entry.Endpoint) + } + if len(parts) == 0 { + return i.entry.Provider } - return desc + return strings.Join(parts, " | ") } -func (i Item) FilterValue() string { return i.profile.Name } -// SelectedMsg is sent when a profile is selected +// FilterValue lets the user filter by both name and provider. +func (i Item) FilterValue() string { return i.entry.Name + " " + i.entry.Provider } + +// SelectedMsg is sent when a profile/alias is selected type SelectedMsg struct { - Profile string + Profile string + Provider string } // Model is the profile picker view model type Model struct { list list.Model - profiles []aws.ProfileInfo + entries []providers.Entry width int height int selected string @@ -51,7 +66,7 @@ func New() Model { Background(lipgloss.Color("39")) l := list.New([]list.Item{}, delegate, 0, 0) - l.Title = "Select AWS Profile" + l.Title = "Select Profile / Alias" l.SetShowStatusBar(true) l.SetFilteringEnabled(true) l.SetShowHelp(false) @@ -72,20 +87,19 @@ func (m *Model) SetSize(width, height int) { m.list.SetSize(width, height) } -// LoadProfiles loads available AWS profiles -func (m *Model) LoadProfiles() error { - profiles, err := aws.ListProfiles() - if err != nil { - return err - } - - m.profiles = profiles - items := make([]list.Item, len(profiles)) - for i, p := range profiles { - items[i] = Item{profile: p} +// LoadEntries loads selectable entries from all providers (aws profiles, mc +// aliases, stui endpoints). Healthy providers are always loaded; a non-nil +// error reports providers that failed to load (e.g. a malformed config) without +// hiding the ones that worked. +func (m *Model) LoadEntries() error { + entries, err := providers.List() + m.entries = entries + items := make([]list.Item, len(m.entries)) + for i, e := range m.entries { + items[i] = Item{entry: e} } m.list.SetItems(items) - return nil + return err } // SelectedProfile returns the selected profile name @@ -109,9 +123,10 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if key.Matches(msg, key.NewBinding(key.WithKeys("enter"))) { if item, ok := m.list.SelectedItem().(Item); ok { - m.selected = item.profile.Name + m.selected = item.entry.Name + entry := item.entry return m, func() tea.Msg { - return SelectedMsg{Profile: item.profile.Name} + return SelectedMsg{Profile: entry.Name, Provider: entry.Provider} } } } @@ -124,14 +139,14 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // View renders the view func (m Model) View() string { - if len(m.profiles) == 0 { + if len(m.entries) == 0 { style := lipgloss.NewStyle(). Width(m.width). Height(m.height). Align(lipgloss.Center, lipgloss.Center). Foreground(lipgloss.Color("196")) - return style.Render("No AWS SSO profiles found in ~/.aws/config\n\nRun 'aws configure sso' to set up a profile") + return style.Render("No profiles or aliases found.\n\nConfigure an AWS profile (~/.aws/config), an mc alias (~/.mc/config.json),\nor a stui endpoint (~/.config/stui/config.json).") } return m.list.View()