Skip to content

Commit 181b29b

Browse files
committed
feat: openboot edit shows interactive config picker
Instead of requiring --slug or relying on sync source, edit now fetches the user's configs and presents a select list. --slug still skips the picker for direct access.
1 parent 84439bd commit 181b29b

File tree

2 files changed

+89
-26
lines changed

2 files changed

+89
-26
lines changed

internal/cli/edit.go

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,20 @@ import (
55
"os/exec"
66

77
"github.com/openbootdotdev/openboot/internal/auth"
8-
syncpkg "github.com/openbootdotdev/openboot/internal/sync"
98
"github.com/openbootdotdev/openboot/internal/ui"
109
"github.com/spf13/cobra"
1110
)
1211

1312
var editCmd = &cobra.Command{
1413
Use: "edit",
15-
Short: "Open your config on openboot.dev in the browser",
16-
Long: `Open your remote config on openboot.dev in the default browser.
14+
Short: "Open a config on openboot.dev in the browser",
15+
Long: `Pick a config from your openboot.dev account and open it in the browser.
1716
18-
Resolves the config slug from the current sync source (set when you last ran
19-
'openboot install' or 'openboot push'). Use --slug to open a specific config.`,
20-
Example: ` # Open your linked config in the browser
17+
Use --slug to skip the picker and open a specific config directly.`,
18+
Example: ` # Pick a config interactively
2119
openboot edit
2220
23-
# Open a specific config
21+
# Open a specific config directly
2422
openboot edit --slug my-config`,
2523
SilenceUsage: true,
2624
RunE: func(cmd *cobra.Command, args []string) error {
@@ -30,7 +28,7 @@ Resolves the config slug from the current sync source (set when you last ran
3028
}
3129

3230
func init() {
33-
editCmd.Flags().String("slug", "", "config slug to open (default: current sync source)")
31+
editCmd.Flags().String("slug", "", "config slug to open (skips the picker)")
3432
rootCmd.AddCommand(editCmd)
3533
}
3634

@@ -49,12 +47,14 @@ func runEdit(slugOverride string) error {
4947

5048
slug := slugOverride
5149
if slug == "" {
52-
if source, loadErr := syncpkg.LoadSource(); loadErr == nil && source != nil && source.Slug != "" {
53-
slug = source.Slug
50+
slug, err = pickConfig(stored.Token, auth.GetAPIBase())
51+
if err != nil {
52+
return err
53+
}
54+
if slug == "" {
55+
ui.Info("No config selected.")
56+
return nil
5457
}
55-
}
56-
if slug == "" {
57-
return fmt.Errorf("no config slug — use --slug or run 'openboot install <config>' first")
5858
}
5959

6060
url := fmt.Sprintf("https://openboot.dev/%s/%s", stored.Username, slug)
@@ -66,3 +66,41 @@ func runEdit(slugOverride string) error {
6666
ui.Success(fmt.Sprintf("Opened %s", url))
6767
return nil
6868
}
69+
70+
// pickConfig fetches the user's configs and shows an interactive select list.
71+
// Returns the chosen slug, or "" if the user has no configs.
72+
func pickConfig(token, apiBase string) (string, error) {
73+
configs, _ := fetchUserConfigs(token, apiBase)
74+
75+
if len(configs) == 0 {
76+
return "", fmt.Errorf("no configs found — run 'openboot push' to create one")
77+
}
78+
79+
options := make([]string, 0, len(configs))
80+
for _, c := range configs {
81+
label := c.Slug
82+
if c.Name != "" && c.Name != c.Slug {
83+
label = fmt.Sprintf("%s — %s", c.Slug, c.Name)
84+
}
85+
options = append(options, label)
86+
}
87+
88+
fmt.Println()
89+
choice, err := ui.SelectOption("Which config would you like to edit?", options)
90+
if err != nil {
91+
return "", fmt.Errorf("select config: %w", err)
92+
}
93+
94+
// Extract slug from "slug — Name" label
95+
slug := splitBefore(choice, " — ")
96+
return slug, nil
97+
}
98+
99+
func splitBefore(s, sep string) string {
100+
for i := 0; i < len(s)-len(sep)+1; i++ {
101+
if s[i:i+len(sep)] == sep {
102+
return s[:i]
103+
}
104+
}
105+
return s
106+
}

internal/cli/edit_test.go

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package cli
22

33
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
47
"testing"
58

69
"github.com/stretchr/testify/assert"
@@ -16,34 +19,56 @@ func TestRunEdit_NotAuthenticated(t *testing.T) {
1619
assert.Contains(t, err.Error(), "not logged in")
1720
}
1821

19-
func TestRunEdit_NoSlug_NoSyncSource(t *testing.T) {
22+
func TestRunEdit_WithSlugFlag_OpensDirectly(t *testing.T) {
2023
setupTestAuth(t, true)
2124

25+
// With --slug, no API call is made (no picker needed).
26+
// exec.Command("open", url) succeeds on macOS; error is from "open" only.
27+
err := runEdit("my-config")
28+
29+
if err != nil {
30+
assert.Contains(t, err.Error(), "open browser")
31+
}
32+
}
33+
34+
func TestRunEdit_NoConfigs_ReturnsError(t *testing.T) {
35+
setupTestAuth(t, true)
36+
37+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+
json.NewEncoder(w).Encode(map[string]any{"configs": []any{}})
39+
}))
40+
defer server.Close()
41+
42+
t.Setenv("OPENBOOT_API_URL", server.URL)
43+
2244
err := runEdit("")
2345

2446
assert.Error(t, err)
25-
assert.Contains(t, err.Error(), "no config slug")
47+
assert.Contains(t, err.Error(), "no configs found")
2648
}
2749

28-
func TestRunEdit_SlugFromSyncSource(t *testing.T) {
29-
tmpDir := setupTestAuth(t, true)
30-
writeSyncSource(t, tmpDir, "my-setup")
50+
func TestPickConfig_NoConfigs_ReturnsError(t *testing.T) {
51+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52+
json.NewEncoder(w).Encode(map[string]any{"configs": []any{}})
53+
}))
54+
defer server.Close()
3155

32-
// exec.Command("open", url) will fail in CI / test environment — that's fine,
33-
// we just verify the slug resolution works and the error is from "open", not auth/slug.
34-
err := runEdit("")
56+
_, err := pickConfig("test-token", server.URL)
3557

36-
// The only error allowed is from the "open" binary itself (not found in some envs).
37-
if err != nil {
38-
assert.Contains(t, err.Error(), "open browser")
39-
}
58+
assert.Error(t, err)
59+
assert.Contains(t, err.Error(), "no configs found")
60+
}
61+
62+
func TestSplitBefore(t *testing.T) {
63+
assert.Equal(t, "my-setup", splitBefore("my-setup — My Mac Setup", " — "))
64+
assert.Equal(t, "work-mac", splitBefore("work-mac — Work Machine", " — "))
65+
assert.Equal(t, "no-sep", splitBefore("no-sep", " — "))
4066
}
4167

4268
func TestEditCmd_CommandStructure(t *testing.T) {
4369
assert.Equal(t, "edit", editCmd.Use)
4470
assert.NotEmpty(t, editCmd.Short)
4571
assert.NotEmpty(t, editCmd.Long)
4672
assert.NotNil(t, editCmd.RunE)
47-
4873
assert.NotNil(t, editCmd.Flags().Lookup("slug"))
4974
}

0 commit comments

Comments
 (0)