From a93d6a9bd424dd1959b6734d78ecb0d662d32860 Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 11 May 2026 21:05:20 -0700 Subject: [PATCH] feat(auth): persist org_name and org_id from login/signup The login/signup API response includes both org_name and org_id. Prior to this change the CLI printed them in the success banner and discarded them. Now they're persisted to ~/.versrc as orgName and orgID, with a GetOrgName() helper that mirrors GetAPIKey's env-then-config precedence (VERS_ORG wins). A new SaveAuth(apiKey, orgName, orgID) helper performs a single write so the login path doesn't need to load+modify+save. Unblocks two follow-ups: - vers agent-context can surface the current org without an extra API call - vers repo get can synthesize /: client-side, so users can see the canonical public reference for their own repos without consulting the public discovery endpoint Empty values passed to SaveAuth don't clobber persisted ones, so a key rotation via SaveAuth(newKey, "", "") preserves the previously-stored org identity. Tested: - internal/auth/auth_test.go gains TestGetOrgName with four sub-cases (no config, persist-and-read, env override, empty-doesn't-clobber) - all existing tests pass --- cmd/login.go | 2 +- cmd/signup.go | 2 ++ internal/auth/auth.go | 39 ++++++++++++++++++++++ internal/auth/auth_test.go | 68 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/cmd/login.go b/cmd/login.go index ffdfd78..3346aca 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -188,7 +188,7 @@ func loginWithGit() error { return err } - if err := auth.SaveAPIKey(keyResp.APIKey); err != nil { + if err := auth.SaveAuth(keyResp.APIKey, keyResp.OrgName, keyResp.OrgID); err != nil { return fmt.Errorf("error saving API key: %w", err) } diff --git a/cmd/signup.go b/cmd/signup.go index 9c97847..fe6ee0f 100644 --- a/cmd/signup.go +++ b/cmd/signup.go @@ -146,6 +146,8 @@ func signupWithGit() error { config.APIKey = keyResp.APIKey config.Email = email config.SSHKeyPath = sshKeyPath + config.OrgName = keyResp.OrgName + config.OrgID = keyResp.OrgID if err := auth.SaveConfig(config); err != nil { return fmt.Errorf("error saving config: %w", err) } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 08268be..5769be5 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -18,6 +18,13 @@ type Config struct { APIKey string `json:"apiKey"` Email string `json:"email,omitempty"` SSHKeyPath string `json:"sshKeyPath,omitempty"` + // OrgName is the user's organization name (namespace). + // Persisted from the login/signup API response so that callers can + // compose canonical references (e.g. /:) without + // an extra API round-trip. + OrgName string `json:"orgName,omitempty"` + // OrgID is the user's organization UUID, persisted alongside OrgName. + OrgID string `json:"orgID,omitempty"` } // GetConfigPath returns the path to the .versrc file in the user's home directory @@ -105,6 +112,38 @@ func SaveAPIKey(apiKey string) error { return SaveConfig(config) } +// SaveAuth persists the API key plus org identity to the config file in a +// single write. Use this from the login/signup paths so that subsequent +// commands can read the user's org name without an extra API call. +func SaveAuth(apiKey, orgName, orgID string) error { + config, err := LoadConfig() + if err != nil { + return err + } + + config.APIKey = apiKey + if orgName != "" { + config.OrgName = orgName + } + if orgID != "" { + config.OrgID = orgID + } + return SaveConfig(config) +} + +// GetOrgName returns the user's org name, preferring the VERS_ORG env var +// over the persisted config value. Empty string if neither is set. +func GetOrgName() (string, error) { + if orgName := os.Getenv("VERS_ORG"); orgName != "" { + return orgName, nil + } + config, err := LoadConfig() + if err != nil { + return "", err + } + return config.OrgName, nil +} + // HasAPIKey checks if an API key is present in environment variable or config file func HasAPIKey() (bool, error) { // First check environment variable diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 82b7d67..c002c0d 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -33,3 +33,71 @@ func TestGetVMDomain(t *testing.T) { }) } } + +func TestGetOrgName(t *testing.T) { + // Save and restore HOME / VERS_ORG so this test is hermetic. + origHome := os.Getenv("HOME") + origOrg := os.Getenv("VERS_ORG") + t.Cleanup(func() { + os.Setenv("HOME", origHome) + if origOrg == "" { + os.Unsetenv("VERS_ORG") + } else { + os.Setenv("VERS_ORG", origOrg) + } + }) + + tmp := t.TempDir() + os.Setenv("HOME", tmp) + os.Unsetenv("VERS_ORG") + + // 1. No config file, no env var → empty. + got, err := GetOrgName() + if err != nil { + t.Fatalf("GetOrgName with no config: %v", err) + } + if got != "" { + t.Errorf("expected empty, got %q", got) + } + + // 2. Persist via SaveAuth, read back. + if err := SaveAuth("test-key", "acme", "org-uuid-1"); err != nil { + t.Fatalf("SaveAuth: %v", err) + } + got, err = GetOrgName() + if err != nil { + t.Fatalf("GetOrgName after SaveAuth: %v", err) + } + if got != "acme" { + t.Errorf("expected acme, got %q", got) + } + + // 3. Env var wins over persisted value. + os.Setenv("VERS_ORG", "override-org") + got, err = GetOrgName() + if err != nil { + t.Fatalf("GetOrgName with env override: %v", err) + } + if got != "override-org" { + t.Errorf("expected override-org, got %q", got) + } + + // 4. Empty SaveAuth values don't clobber persisted ones. + os.Unsetenv("VERS_ORG") + if err := SaveAuth("new-key", "", ""); err != nil { + t.Fatalf("SaveAuth empty org: %v", err) + } + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.APIKey != "new-key" { + t.Errorf("expected new-key, got %q", cfg.APIKey) + } + if cfg.OrgName != "acme" { + t.Errorf("expected acme preserved, got %q", cfg.OrgName) + } + if cfg.OrgID != "org-uuid-1" { + t.Errorf("expected org-uuid-1 preserved, got %q", cfg.OrgID) + } +}