Skip to content

Commit 4cb5a6b

Browse files
committed
feat: interactive config picker on push when no sync source
- openboot push with no sync source now fetches the user's existing configs and presents an interactive select list: choose an existing config to update, or create a new one - Updating an existing config (slug known) skips all prompts entirely - Only the create-new path asks for name, description, and visibility - fetchUserConfigs + pickOrCreateConfig unit tests added
1 parent 5991a39 commit 4cb5a6b

File tree

2 files changed

+166
-9
lines changed

2 files changed

+166
-9
lines changed

internal/cli/push.go

Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ func init() {
5959
}
6060

6161
// runPushAuto captures the current system snapshot and uploads it to openboot.dev.
62-
// If a sync source is configured, it updates that config; otherwise, creates a new one.
62+
// If a sync source is configured, it updates that config silently; otherwise, it
63+
// presents an interactive picker so the user can choose an existing config or create a new one.
6364
func runPushAuto(slugOverride, message string) error {
6465
apiBase := auth.GetAPIBase()
6566

@@ -93,13 +94,19 @@ func runPushAuto(slugOverride, message string) error {
9394
return fmt.Errorf("marshal snapshot: %w", err)
9495
}
9596

96-
// Determine slug: use override, then sync source, then blank (create new)
97+
// Determine slug: --slug flag → sync source → interactive picker
9798
slug := slugOverride
9899
if slug == "" {
99100
if source, loadErr := syncpkg.LoadSource(); loadErr == nil && source != nil && source.Slug != "" {
100101
slug = source.Slug
101102
}
102103
}
104+
if slug == "" {
105+
slug, err = pickOrCreateConfig(stored.Token, apiBase)
106+
if err != nil {
107+
return err
108+
}
109+
}
103110

104111
return pushSnapshot(data, slug, message, stored.Token, stored.Username, apiBase)
105112
}
@@ -149,16 +156,26 @@ func pushSnapshot(data []byte, slug, message, token, username, apiBase string) e
149156
return fmt.Errorf("parse snapshot: %w", err)
150157
}
151158

152-
name, desc, visibility, err := promptPushDetails("")
153-
if err != nil {
154-
return err
159+
// Updating an existing config: skip all prompts.
160+
// Creating a new config: ask for name, description, visibility.
161+
var name, desc, visibility string
162+
if slug == "" {
163+
var err error
164+
name, desc, visibility, err = promptPushDetails("")
165+
if err != nil {
166+
return err
167+
}
155168
}
156169

157170
reqBody := map[string]interface{}{
158-
"name": name,
159-
"description": desc,
160-
"snapshot": snap,
161-
"visibility": visibility,
171+
"snapshot": snap,
172+
"visibility": visibility,
173+
}
174+
if name != "" {
175+
reqBody["name"] = name
176+
}
177+
if desc != "" {
178+
reqBody["description"] = desc
162179
}
163180
if slug != "" {
164181
reqBody["config_slug"] = slug
@@ -319,6 +336,76 @@ func doUpload(url string, body []byte, token, username, slug string) error {
319336
return nil
320337
}
321338

339+
type remoteConfigSummary struct {
340+
Slug string `json:"slug"`
341+
Name string `json:"name"`
342+
}
343+
344+
// fetchUserConfigs calls GET /api/configs and returns the user's existing configs.
345+
func fetchUserConfigs(token, apiBase string) ([]remoteConfigSummary, error) {
346+
req, err := http.NewRequest(http.MethodGet, apiBase+"/api/configs", nil)
347+
if err != nil {
348+
return nil, fmt.Errorf("build request: %w", err)
349+
}
350+
req.Header.Set("Authorization", "Bearer "+token)
351+
352+
client := &http.Client{Timeout: 10 * time.Second}
353+
resp, err := httputil.Do(client, req)
354+
if err != nil {
355+
return nil, fmt.Errorf("fetch configs: %w", err)
356+
}
357+
defer resp.Body.Close()
358+
359+
if resp.StatusCode != http.StatusOK {
360+
return nil, nil // non-fatal — fall through to create-new flow
361+
}
362+
363+
var result struct {
364+
Configs []remoteConfigSummary `json:"configs"`
365+
}
366+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
367+
return nil, nil
368+
}
369+
return result.Configs, nil
370+
}
371+
372+
const createNewOption = "+ Create a new config"
373+
374+
// pickOrCreateConfig shows an interactive list of the user's existing configs plus a
375+
// "Create new" option. Returns the chosen slug (non-empty = update existing), or ""
376+
// (= create new, caller must ask for name/desc/visibility).
377+
func pickOrCreateConfig(token, apiBase string) (string, error) {
378+
configs, _ := fetchUserConfigs(token, apiBase) // ignore fetch errors — just show create-new
379+
380+
if len(configs) == 0 {
381+
return "", nil // no existing configs — skip picker, go straight to create-new
382+
}
383+
384+
options := make([]string, 0, len(configs)+1)
385+
for _, c := range configs {
386+
label := c.Slug
387+
if c.Name != "" && c.Name != c.Slug {
388+
label = fmt.Sprintf("%s — %s", c.Slug, c.Name)
389+
}
390+
options = append(options, label)
391+
}
392+
options = append(options, createNewOption)
393+
394+
fmt.Fprintln(os.Stderr)
395+
choice, err := ui.SelectOption("Push to which config?", options)
396+
if err != nil {
397+
return "", fmt.Errorf("select config: %w", err)
398+
}
399+
400+
if choice == createNewOption {
401+
return "", nil // caller will prompt for name/desc/visibility
402+
}
403+
404+
// Extract slug from "slug — Name" label
405+
slug := strings.SplitN(choice, " — ", 2)[0]
406+
return slug, nil
407+
}
408+
322409
func promptPushDetails(defaultName string) (string, string, string, error) {
323410
fmt.Fprintln(os.Stderr)
324411
var name string

internal/cli/push_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package cli
22

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

69
"github.com/openbootdotdev/openboot/internal/config"
710
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
812
)
913

1014
func TestRemoteConfigToAPIPackages(t *testing.T) {
@@ -106,3 +110,69 @@ func TestTapsNotInRequestBodyAsTopLevelField(t *testing.T) {
106110
assert.Len(t, tapEntries, 1)
107111
assert.Equal(t, "hashicorp/tap", tapEntries[0].Name)
108112
}
113+
114+
// ── fetchUserConfigs ───────────────────────────────────────────────────────────
115+
116+
func TestFetchUserConfigs_ReturnsConfigs(t *testing.T) {
117+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+
assert.Equal(t, "/api/configs", r.URL.Path)
119+
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
120+
w.Header().Set("Content-Type", "application/json")
121+
json.NewEncoder(w).Encode(map[string]any{
122+
"configs": []map[string]any{
123+
{"slug": "my-setup", "name": "My Mac Setup"},
124+
{"slug": "work-mac", "name": "Work Machine"},
125+
},
126+
})
127+
}))
128+
defer server.Close()
129+
130+
configs, err := fetchUserConfigs("test-token", server.URL)
131+
132+
require.NoError(t, err)
133+
require.Len(t, configs, 2)
134+
assert.Equal(t, "my-setup", configs[0].Slug)
135+
assert.Equal(t, "My Mac Setup", configs[0].Name)
136+
assert.Equal(t, "work-mac", configs[1].Slug)
137+
}
138+
139+
func TestFetchUserConfigs_EmptyList(t *testing.T) {
140+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
141+
json.NewEncoder(w).Encode(map[string]any{"configs": []any{}})
142+
}))
143+
defer server.Close()
144+
145+
configs, err := fetchUserConfigs("test-token", server.URL)
146+
147+
require.NoError(t, err)
148+
assert.Empty(t, configs)
149+
}
150+
151+
func TestFetchUserConfigs_ServerError_ReturnsNilNotError(t *testing.T) {
152+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
153+
w.WriteHeader(http.StatusInternalServerError)
154+
}))
155+
defer server.Close()
156+
157+
configs, err := fetchUserConfigs("test-token", server.URL)
158+
159+
// non-200 is treated as non-fatal: nil slice, no error
160+
assert.NoError(t, err)
161+
assert.Nil(t, configs)
162+
}
163+
164+
// ── pickOrCreateConfig ────────────────────────────────────────────────────────
165+
166+
func TestPickOrCreateConfig_NoConfigs_ReturnsEmptySlug(t *testing.T) {
167+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
168+
json.NewEncoder(w).Encode(map[string]any{"configs": []any{}})
169+
}))
170+
defer server.Close()
171+
172+
// With no existing configs there is nothing to select — returns "" immediately
173+
// (caller will run the create-new prompt flow).
174+
slug, err := pickOrCreateConfig("test-token", server.URL)
175+
176+
require.NoError(t, err)
177+
assert.Equal(t, "", slug)
178+
}

0 commit comments

Comments
 (0)