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
6 changes: 3 additions & 3 deletions cmd/spinloop/alias_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,7 @@ func TestEnvAlias_ReachesRemote(t *testing.T) {
t.Chdir(t.TempDir()) // no ./Spinloop, so only the variable can find it
t.Setenv("SPINLOOP_ALIAS", "q3")

if err := cmdRemoteStop(nil); err != nil {
if err := cmdRemoteStop([]string{"--env", "default"}); err != nil {
t.Fatalf("cmdRemoteStop with SPINLOOP_ALIAS: %v", err)
}
select {
Expand Down Expand Up @@ -879,7 +879,7 @@ func TestEnvAlias_RemoteFailsRatherThanFallingBack(t *testing.T) {
t.Chdir(t.TempDir())
t.Setenv("SPINLOOP_ALIAS", "nope")

err := cmdRemoteStop(nil)
err := cmdRemoteStop([]string{"--env", "default"})
if err == nil {
t.Fatal("expected an error for an unregistered SPINLOOP_ALIAS")
}
Expand All @@ -903,7 +903,7 @@ func TestEnvAlias_RemoteFallsBackWithoutREMOTE(t *testing.T) {
t.Chdir(t.TempDir())
t.Setenv("SPINLOOP_ALIAS", "q3")

err := cmdRemoteStop(nil)
err := cmdRemoteStop([]string{"--env", "default"})
if err == nil {
t.Fatal("expected an error: there is no default endpoint config either")
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/spinloop/flagparse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestPflagParseForms(t *testing.T) {
"apply": func() error { return cmdApply([]string{"--nope"}) },
"serve": func() error { return cmdServe([]string{"--nope"}) },
"fleet metrics": func() error { return cmdFleet([]string{"metrics", "--nope"}) },
"remote start": func() error { return cmdRemoteStart([]string{"--nope"}) },
"remote start": func() error { return cmdRemoteStart([]string{"--env", "default", "--nope"}) },
"daemon": func() error { return cmdDaemon([]string{"--nope"}) },
} {
if err := call(); err == nil || !strings.Contains(err.Error(), "unknown flag: --nope") {
Expand All @@ -51,7 +51,7 @@ func TestPflagParseForms(t *testing.T) {
// the proof that parsing continued past it.
for name, call := range map[string]func() error{
"fleet metrics": func() error { return cmdFleet([]string{"metrics", "someNode", "--nope"}) },
"remote env": func() error { return cmdRemoteEnv([]string{"somePath", "--nope"}) },
"remote env": func() error { return cmdRemoteEnv([]string{"--env", "default", "somePath", "--nope"}) },
} {
if err := call(); err == nil || !strings.Contains(err.Error(), "unknown flag: --nope") {
t.Errorf("%s <positional> --nope = %v, want unknown-flag error", name, err)
Expand Down
18 changes: 9 additions & 9 deletions cmd/spinloop/last_active_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func TestRemoteMetricsBarShowsLastActive(t *testing.T) {
statsServer(t, runningWithActivity)

out := captureStdout(t, func() {
if err := cmdRemoteMetrics([]string{"--format=bar"}); err != nil {
if err := cmdRemoteMetrics([]string{"--env", "default", "--format=bar"}); err != nil {
t.Fatalf("cmdRemoteMetrics: %v", err)
}
})
Expand All @@ -69,7 +69,7 @@ func TestRemoteMetricsTableShowsLastActive(t *testing.T) {
statsServer(t, runningWithActivity)

out := captureStdout(t, func() {
if err := cmdRemoteMetrics([]string{"--format=table"}); err != nil {
if err := cmdRemoteMetrics([]string{"--env", "default", "--format=table"}); err != nil {
t.Fatalf("cmdRemoteMetrics: %v", err)
}
})
Expand All @@ -86,7 +86,7 @@ func TestRemoteMetricsJSONCarriesLastActive(t *testing.T) {
statsServer(t, runningWithActivity)

out := captureStdout(t, func() {
if err := cmdRemoteMetrics([]string{"--format=json"}); err != nil {
if err := cmdRemoteMetrics([]string{"--env", "default", "--format=json"}); err != nil {
t.Fatalf("cmdRemoteMetrics: %v", err)
}
})
Expand Down Expand Up @@ -124,7 +124,7 @@ func TestRemoteMetricsStoppedStillShowsLastActive(t *testing.T) {
} {
t.Run(format, func(t *testing.T) {
out := captureStdout(t, func() {
if err := cmdRemoteMetrics([]string{"--format=" + format}); err != nil {
if err := cmdRemoteMetrics([]string{"--env", "default", "--format=" + format}); err != nil {
t.Fatalf("cmdRemoteMetrics: %v", err)
}
})
Expand Down Expand Up @@ -154,7 +154,7 @@ func TestLastActiveZeroIdleStillRenders(t *testing.T) {
} {
t.Run(format, func(t *testing.T) {
out := captureStdout(t, func() {
if err := cmdRemoteMetrics([]string{"--format=" + format}); err != nil {
if err := cmdRemoteMetrics([]string{"--env", "default", "--format=" + format}); err != nil {
t.Fatalf("cmdRemoteMetrics: %v", err)
}
})
Expand All @@ -176,7 +176,7 @@ func TestLastActiveOmittedWithoutATimestamp(t *testing.T) {
for _, format := range []string{"bar", "table"} {
t.Run(format, func(t *testing.T) {
out := captureStdout(t, func() {
if err := cmdRemoteMetrics([]string{"--format=" + format}); err != nil {
if err := cmdRemoteMetrics([]string{"--env", "default", "--format=" + format}); err != nil {
t.Fatalf("cmdRemoteMetrics: %v", err)
}
})
Expand Down Expand Up @@ -215,7 +215,7 @@ func TestRemoteStatusShowsLastActive(t *testing.T) {
}`)

out := captureStdout(t, func() {
if err := cmdRemoteStatus(nil); err != nil {
if err := cmdRemoteStatus([]string{"--env", "default"}); err != nil {
t.Fatalf("cmdRemoteStatus: %v", err)
}
})
Expand All @@ -234,7 +234,7 @@ func TestRemoteStatusZeroIdleStillRenders(t *testing.T) {
statusServer(t, `{"state": "running", "healthy": true, "lastActiveAt": "2026-08-10T10:00:00Z"}`)

out := captureStdout(t, func() {
if err := cmdRemoteStatus(nil); err != nil {
if err := cmdRemoteStatus([]string{"--env", "default"}); err != nil {
t.Fatalf("cmdRemoteStatus: %v", err)
}
})
Expand All @@ -249,7 +249,7 @@ func TestRemoteStatusOmitsLastActiveWhenAbsent(t *testing.T) {
statusServer(t, `{"state": "stopped", "healthy": false}`)

out := captureStdout(t, func() {
if err := cmdRemoteStatus(nil); err != nil {
if err := cmdRemoteStatus([]string{"--env", "default"}); err != nil {
t.Fatalf("cmdRemoteStatus: %v", err)
}
})
Expand Down
4 changes: 2 additions & 2 deletions cmd/spinloop/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ func applySelection(sel spinloop.Selection, h harness.Harness, spinloopPath, env
}
return err
}
cfg, err := remote.LoadConfigFile(envPath, viperGetenv())
cfg, err := remote.LoadEnvironment(envName, viperGetenv())
if err != nil {
return err
}
Expand Down Expand Up @@ -1624,7 +1624,7 @@ func fetchRemoteEnv(sel spinloop.Selection, envName string, resolve func(string)
}
// The call crosses the network, and a cold control plane is not instant.
fmt.Fprintf(os.Stderr, "Fetching the endpoint's environment from %s...\n", envName)
cfg, err := remote.LoadConfigFile(envPath, viperGetenv())
cfg, err := remote.LoadEnvironment(envName, viperGetenv())
if err == nil {
ctx, cancel := context.WithTimeout(context.Background(), remoteEnvTimeout)
defer cancel()
Expand Down
46 changes: 29 additions & 17 deletions cmd/spinloop/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
Expand Down Expand Up @@ -93,7 +94,7 @@ func applySpinloopEnv(sel spinloop.Selection, spinloopPath string) error {

// envFlagUsage is the --env flag's help text on every remote subcommand that
// acts on one environment.
const envFlagUsage = "the registered environment to act on (defaults to the default environment)"
const envFlagUsage = "the registered environment to act on (required)"

// resolveRemoteConfig loads the remote config a subcommand acts on. The --env
// flag names an environment in the per-user registry, read from
Expand Down Expand Up @@ -125,23 +126,34 @@ func resolveRemoteConfig(envName, spinloopArg string) (remote.Config, error) {
return remote.Config{}, err
}
}
if envName != "" {
if !remote.IsEnvName(envName) {
return remote.Config{}, fmt.Errorf("%q is not an environment name: an environment name is a plain identifier, with no path", envName)
}
path, err := remote.EnvConfigPath(envName)
if err != nil {
return remote.Config{}, err
}
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return remote.Config{}, fmt.Errorf("environment %q is not registered: run `spinloop remote deploy --env %q` to create it", envName, envName)
}
return remote.Config{}, err
}
return remote.LoadConfigFile(path, viperGetenv())
if envName == "" {
return remote.Config{}, errNoEnvironment()
}
if !remote.IsEnvName(envName) {
return remote.Config{}, fmt.Errorf("%q is not an environment name: an environment name is a plain identifier, with no path", envName)
}
return remote.LoadEnvironment(envName, viperGetenv())
}

// errNoEnvironment is what a remote subcommand fails with when it names no
// environment. There is no environment to fall back to: several of these
// subcommands change the state of a cloud instance, and one nobody named is
// not one to start, stop or terminate. The registered names are listed, so the
// next thing to type is in the error rather than a directory listing away.
func errNoEnvironment() error {
var b strings.Builder
b.WriteString("no environment named: pass --env <name>")
envs, err := remote.ListEnvironments()
if err != nil || len(envs) == 0 {
b.WriteString(" (none registered yet — `spinloop remote deploy --env <name>` creates one)")
return errors.New(b.String())
}
names := make([]string, len(envs))
for i, e := range envs {
names[i] = e.Name
}
return remote.LoadDefault(viperGetenv())
fmt.Fprintf(&b, " (registered: %s)", strings.Join(names, ", "))
return errors.New(b.String())
}

// defaultSpinloopNamed reports whether there is a default Spinloop for readSpinloop
Expand Down
8 changes: 4 additions & 4 deletions cmd/spinloop/remote_deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,7 @@ func TestRemoteStart_ReportsProgressWhileWaiting(t *testing.T) {
writeRemoteConfig(t, server.URL)

stderr := captureStderr(t, func() {
if err := cmdRemoteStart(nil); err != nil {
if err := cmdRemoteStart([]string{"--env", "default"}); err != nil {
t.Errorf("cmdRemoteStart: %v", err)
}
})
Expand Down Expand Up @@ -1022,7 +1022,7 @@ func TestRemoteStart_HeartbeatTracksTheCapacityWaitEnding(t *testing.T) {
writeRemoteConfig(t, server.URL)

stderr := captureStderr(t, func() {
if err := cmdRemoteStart(nil); err != nil {
if err := cmdRemoteStart([]string{"--env", "default"}); err != nil {
t.Fatalf("cmdRemoteStart: %v", err)
}
})
Expand Down Expand Up @@ -1069,7 +1069,7 @@ func TestRemoteStart_TimeoutShorthand(t *testing.T) {
defer func() { os.Stderr = oldStderr; w.Close() }()

done := make(chan error, 1)
go func() { done <- cmdRemoteStart([]string{"-t", "80ms"}) }()
go func() { done <- cmdRemoteStart([]string{"--env", "default", "-t", "80ms"}) }()
select {
case err := <-done:
if err == nil {
Expand All @@ -1093,7 +1093,7 @@ func TestRemoteStart_StdoutCarriesOnlyTheResult(t *testing.T) {
writeRemoteConfig(t, server.URL)

out := captureStdout(t, func() {
if err := cmdRemoteStart([]string{"--print-env"}); err != nil {
if err := cmdRemoteStart([]string{"--env", "default", "--print-env"}); err != nil {
t.Errorf("cmdRemoteStart: %v", err)
}
})
Expand Down
26 changes: 14 additions & 12 deletions cmd/spinloop/remote_environments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"testing"

"github.com/spinloop-ai/spinloop/internal/config"
"github.com/spinloop-ai/spinloop/internal/remote"
)

Expand Down Expand Up @@ -67,7 +68,7 @@ func TestRemote_DefaultEnvironment(t *testing.T) {

t.Chdir(t.TempDir()) // no ./Spinloop here
out := captureStdout(t, func() {
if err := cmdRemoteStatus(nil); err != nil {
if err := cmdRemoteStatus([]string{"--env", "default"}); err != nil {
t.Errorf("status via default env: %v", err)
}
})
Expand All @@ -76,28 +77,29 @@ func TestRemote_DefaultEnvironment(t *testing.T) {
}
}

// A pre-existing ~/.config/spinloop/remote.json is read as the default env.
func TestRemote_LegacyFileReadThrough(t *testing.T) {
// A file at the superseded path configures nothing: no path outside the
// registry is read for any name.
func TestRemote_SupersededFileIsNotRead(t *testing.T) {
isolateConfig(t)
stubAWSEnv(t)
server := stateServer(t)
defer server.Close()

data, _ := json.Marshal(remote.Config{StartURL: server.URL, StopURL: server.URL, Region: "eu-west-1"})
if err := os.MkdirAll(filepath.Dir(must1(remote.ConfigPath())), 0o700); err != nil {
home := must1(config.Dir())
if err := os.MkdirAll(home, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(must1(remote.ConfigPath()), data, 0o600); err != nil {
if err := os.WriteFile(filepath.Join(home, "remote.json"), data, 0o600); err != nil {
t.Fatal(err)
}
t.Chdir(t.TempDir())
out := captureStdout(t, func() {
if err := cmdRemoteStatus(nil); err != nil {
t.Errorf("status via legacy file: %v", err)
}
})
if !strings.Contains(out, "state: running") {
t.Errorf("legacy remote.json should be read as default, got:\n%s", out)
err := cmdRemoteMetrics([]string{"--env", "default"})
if err == nil {
t.Fatal("the superseded file must not configure an environment")
}
if !strings.Contains(err.Error(), "remotes/default/remote.json") {
t.Errorf("the failure should name the registry path, got %v", err)
}
}

Expand Down
Loading