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
1 change: 1 addition & 0 deletions .github/workflows/release-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 31 additions & 2 deletions cli/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
24 changes: 24 additions & 0 deletions cli/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package cli
import (
"net/http"
"net/url"
"os"
"path/filepath"
"testing"
"time"

"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand Down Expand Up @@ -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))
}
26 changes: 26 additions & 0 deletions cli/apiconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
60 changes: 58 additions & 2 deletions cmd/surf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -59,6 +64,7 @@ func main() {
// Initialize restish.
cli.Init("surf", version)
cli.Defaults()
applySurfAPIBaseURLOverride()
cli.AddLoader(openapi.New())
cli.AddOperationCommandHook(addHiddenCompatOperationFlags)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.")
Expand Down
53 changes: 53 additions & 0 deletions cmd/surf/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading