diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index e2a1851..dd2e87a 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -15,6 +15,7 @@ jobs: AWS_REGION: ${{ secrets.AWS_REGION }} RELEASE_BUCKET: ${{ secrets.RELEASE_BUCKET }} RELEASE_DIRECTORY: ${{ secrets.RELEASE_DIRECTORY }} + SURF_GATEWAY_BASE: https://api.stg.ask.surf/gateway steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aaae00a..6300ce4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,7 @@ jobs: AWS_REGION: ${{ secrets.AWS_REGION }} RELEASE_BUCKET: ${{ secrets.RELEASE_BUCKET }} RELEASE_DIRECTORY: ${{ secrets.RELEASE_DIRECTORY }} + SURF_GATEWAY_BASE: https://api.asksurf.ai/gateway steps: - uses: actions/checkout@v4 with: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 726dea3..925a856 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -18,7 +18,7 @@ builds: - amd64 - arm64 ldflags: - - -s -w -X main.version={{.Version}} + - -s -w -X main.version={{.Version}} -X main.buildSurfGatewayBase={{.Env.SURF_GATEWAY_BASE}} archives: - formats: diff --git a/README.md b/README.md index ee21c47..ff9804f 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ To test against the staging CDN + API, install from the staging installer: ```sh curl -fsSL https://downloads-stg.asksurf.ai/cli/releases/install.sh | sh +surf sync +surf list-operations --category equity ``` > **Do not run `surf install` afterwards.** It pulls the production binary diff --git a/cli/api.go b/cli/api.go index 22cd324..682daae 100644 --- a/cli/api.go +++ b/cli/api.go @@ -103,6 +103,9 @@ func cacheAPI(name string, api *API) { return } + if base := apiCacheBase(name); base != "" { + Cache.Set(name+".base", base) + } Cache.Set(name+".expires", time.Now().Add(24*time.Hour)) Cache.WriteConfig() @@ -116,11 +119,37 @@ func cacheAPI(name string, api *API) { } } +func apiCacheBase(name string) string { + config := configs[name] + if config == nil { + return "" + } + base := config.Base + profile := viper.GetString("rsh-profile") + if profile != "" && profile != "default" { + if profileConfig := config.Profiles[profile]; profileConfig != nil && profileConfig.Base != "" { + base = profileConfig.Base + } + } + return strings.TrimRight(base, "/") +} + +func apiCacheMatchesBase(name string) bool { + expected := apiCacheBase(name) + if expected == "" { + return true + } + return Cache.GetString(name+".base") == expected +} + // LoadCachedAPI loads an API from the local cache without making network // requests. Returns nil if no valid cache exists or is expired. // Unlike Load, this skips the version check since it is used only to // populate command names and descriptions for help output. func LoadCachedAPI(name string) *API { + if !apiCacheMatchesBase(name) { + return nil + } expires := Cache.GetTime(name + ".expires") if expires.IsZero() || !time.Now().Before(expires) { return nil @@ -161,12 +190,12 @@ func Load(entrypoint string, root *cobra.Command) (API, error) { // See if there is a cache we can quickly load. expires := Cache.GetTime(name + ".expires") - if !viper.GetBool("rsh-no-cache") && !expires.IsZero() && expires.After(time.Now()) { + if !viper.GetBool("rsh-no-cache") && apiCacheMatchesBase(name) && !expires.IsZero() && expires.After(time.Now()) { var cached API filename := filepath.Join(getCacheDir(), name+".cbor") if data, err := os.ReadFile(filename); err == nil { if err := cbor.Unmarshal(data, &cached); err == nil { - if cached.RestishVersion == root.Version { + if root.Version == "" || cached.RestishVersion == root.Version { setupRootFromAPI(root, &cached) return cached, nil } diff --git a/cli/api_test.go b/cli/api_test.go index 37b88ce..1a2dcb6 100644 --- a/cli/api_test.go +++ b/cli/api_test.go @@ -3,7 +3,10 @@ package cli import ( "net/http" "net/url" + "os" + "path/filepath" "testing" + "time" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -116,3 +119,24 @@ func TestLoadAppliesOperationCommandHooks(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, cmd.Flags().Lookup("hooked")) } + +func TestLoadCachedAPIIsScopedToConfiguredBase(t *testing.T) { + reset(false) + name := "base-scoped-cache" + configs[name] = &APIConfig{ + name: name, + Base: "https://api.stg.example/gateway", + Profiles: map[string]*APIProfile{"default": {}}, + } + + Cache.Set(name+".base", "https://api.prod.example/gateway") + Cache.Set(name+".expires", time.Now().Add(time.Hour)) + if err := Cache.WriteConfig(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(getCacheDir(), name+".cbor"), []byte("stale"), 0o600); err != nil { + t.Fatal(err) + } + + assert.Nil(t, LoadCachedAPI(name)) +} diff --git a/cli/apiconfig.go b/cli/apiconfig.go index 97d783e..12e368c 100644 --- a/cli/apiconfig.go +++ b/cli/apiconfig.go @@ -87,6 +87,32 @@ type apiConfigs map[string]*APIConfig var configs apiConfigs var apiCommand *cobra.Command +// OverrideAPIConfig updates an API config in memory before operations are +// loaded. Surf uses this to select the gateway baked into each release build. +func OverrideAPIConfig(name, base string, specFiles []string) { + config := configs[name] + if config == nil { + config = &APIConfig{name: name} + } + config.name = name + config.Base = base + config.SpecFiles = specFiles + if config.Profiles == nil { + config.Profiles = map[string]*APIProfile{} + } + if config.Profiles["default"] == nil { + config.Profiles["default"] = &APIProfile{} + } + configs[name] = config + + for _, cmd := range Root.Commands() { + if cmd.Use == name { + cmd.Short = base + break + } + } +} + func initAPIConfig() { apis = viper.New() diff --git a/cmd/surf/main.go b/cmd/surf/main.go index 5845e54..5fabadc 100644 --- a/cmd/surf/main.go +++ b/cmd/surf/main.go @@ -22,6 +22,11 @@ var embeddedAPIsJSON []byte var version = "dev" var configDir string +const productionSurfGatewayBase = "https://api.asksurf.ai/gateway" + +// buildSurfGatewayBase is overridden by GoReleaser for staging builds. +var buildSurfGatewayBase = productionSurfGatewayBase + func main() { // Force config and cache to ~/.surf/ on all platforms. home, err := os.UserHomeDir() @@ -59,6 +64,7 @@ func main() { // Initialize restish. cli.Init("surf", version) cli.Defaults() + applySurfAPIBaseURLOverride() cli.AddLoader(openapi.New()) cli.AddOperationCommandHook(addHiddenCompatOperationFlags) @@ -202,7 +208,7 @@ func main() { if cli.LoadCachedAPI("surf") == nil && needsCachedAPI() { fmt.Fprintln(os.Stderr, "No cached API spec, syncing...") viper.Set("rsh-no-cache", true) - if _, err := cli.Load("https://api.asksurf.ai/gateway", cli.Root); err != nil { + if _, err := cli.Load(currentSurfGatewayBase(), cli.Root); err != nil { fmt.Fprintf(os.Stderr, "Auto-sync failed: %v\n", err) // Fall through — the downstream "no cached API spec" / // "unknown command" path will produce a more specific error. @@ -345,6 +351,56 @@ func shouldInjectAPIName() bool { return false } +func explicitSurfAPIBaseURLOverride(args []string) string { + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case strings.HasPrefix(arg, "--surf-api-base-url="): + return strings.TrimPrefix(arg, "--surf-api-base-url=") + case arg == "--surf-api-base-url" && i+1 < len(args): + return args[i+1] + case strings.HasPrefix(arg, "-s="): + return strings.TrimPrefix(arg, "-s=") + case arg == "-s" && i+1 < len(args): + return args[i+1] + case strings.HasPrefix(arg, "-s") && len(arg) > 2: + return strings.TrimPrefix(arg, "-s") + } + } + return os.Getenv("SURF_API_BASE_URL") +} + +func normalizeSurfGatewayBase(raw string) string { + base := strings.TrimRight(strings.TrimSpace(raw), "/") + if strings.HasSuffix(base, "/v1") { + base = strings.TrimSuffix(base, "/v1") + } + return base +} + +func effectiveSurfAPIBaseURL() string { + if raw := explicitSurfAPIBaseURLOverride(os.Args[1:]); raw != "" { + return raw + } + return currentSurfGatewayBase() + "/v1" +} + +func currentSurfGatewayBase() string { + base := normalizeSurfGatewayBase(buildSurfGatewayBase) + if base == "" { + base = productionSurfGatewayBase + } + return base +} + +func applySurfAPIBaseURLOverride() { + base := currentSurfGatewayBase() + if explicitSurfAPIBaseURLOverride(os.Args[1:]) == "" { + viper.Set("surf-api-base-url", base+"/v1") + } + cli.OverrideAPIConfig("surf", base, []string{base + "/openapi.json"}) +} + func removeCommands(root *cobra.Command, names ...string) { unwanted := make(map[string]bool, len(names)) for _, n := range names { @@ -451,7 +507,7 @@ func newSyncCmd() *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { viper.Set("rsh-no-cache", true) - if _, err := cli.Load("https://api.asksurf.ai/gateway", cli.Root); err != nil { + if _, err := cli.Load(currentSurfGatewayBase(), cli.Root); err != nil { return fmt.Errorf("sync failed: %w", err) } fmt.Fprintln(os.Stderr, "API spec synced.") diff --git a/cmd/surf/main_test.go b/cmd/surf/main_test.go index f0d4019..b860984 100644 --- a/cmd/surf/main_test.go +++ b/cmd/surf/main_test.go @@ -107,3 +107,56 @@ func TestNeedsCachedAPI(t *testing.T) { }) } } + +func TestEffectiveSurfAPIBaseURLUsesBuildDefault(t *testing.T) { + t.Setenv("SURF_API_BASE_URL", "") + oldArgs := os.Args + oldBuildBase := buildSurfGatewayBase + t.Cleanup(func() { + os.Args = oldArgs + buildSurfGatewayBase = oldBuildBase + }) + os.Args = []string{"surf", "sync"} + buildSurfGatewayBase = "https://api.stg.ask.surf/gateway" + + if got, want := effectiveSurfAPIBaseURL(), "https://api.stg.ask.surf/gateway/v1"; got != want { + t.Fatalf("effectiveSurfAPIBaseURL() = %q, want %q", got, want) + } +} + +func TestEffectiveSurfAPIBaseURLPrefersExplicitOverride(t *testing.T) { + t.Setenv("SURF_API_BASE_URL", "https://env.example/gateway/v1") + oldArgs := os.Args + oldBuildBase := buildSurfGatewayBase + t.Cleanup(func() { + os.Args = oldArgs + buildSurfGatewayBase = oldBuildBase + }) + buildSurfGatewayBase = "https://api.stg.ask.surf/gateway" + + os.Args = []string{"surf", "sync"} + if got, want := effectiveSurfAPIBaseURL(), "https://env.example/gateway/v1"; got != want { + t.Fatalf("env override = %q, want %q", got, want) + } + + os.Args = []string{"surf", "--surf-api-base-url", "https://flag.example/gateway/v1", "sync"} + if got, want := effectiveSurfAPIBaseURL(), "https://flag.example/gateway/v1"; got != want { + t.Fatalf("flag override = %q, want %q", got, want) + } +} + +func TestCurrentSurfGatewayBaseUsesBuildDefaultDespiteRuntimeOverride(t *testing.T) { + t.Setenv("SURF_API_BASE_URL", "https://runtime.example/gateway/v1") + oldArgs := os.Args + oldBuildBase := buildSurfGatewayBase + t.Cleanup(func() { + os.Args = oldArgs + buildSurfGatewayBase = oldBuildBase + }) + os.Args = []string{"surf", "sync"} + buildSurfGatewayBase = "https://api.stg.ask.surf/gateway" + + if got, want := currentSurfGatewayBase(), "https://api.stg.ask.surf/gateway"; got != want { + t.Fatalf("currentSurfGatewayBase() = %q, want %q", got, want) + } +}