Skip to content

Commit 53bba66

Browse files
committed
feat: allow updating existing config when uploading snapshot
When uploading via `openboot snapshot`, detect sync source and offer "Update existing config" vs "Create new". Uses PUT with config_slug for updates. Also consolidates promptConfigDetails into promptPushDetails, adds 409 conflict handling, and fixes missing TrimSpace in push prompts.
1 parent e7688c6 commit 53bba66

File tree

2 files changed

+65
-41
lines changed

2 files changed

+65
-41
lines changed

internal/cli/push.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ func promptPushDetails() (string, string, string, error) {
262262
if err != nil {
263263
return "", "", "", fmt.Errorf("get config name: %w", err)
264264
}
265+
name = strings.TrimSpace(name)
265266
if name == "" {
266267
name = "My Mac Setup"
267268
}
@@ -271,6 +272,7 @@ func promptPushDetails() (string, string, string, error) {
271272
if err != nil {
272273
return "", "", "", fmt.Errorf("get description: %w", err)
273274
}
275+
desc = strings.TrimSpace(desc)
274276

275277
fmt.Fprintln(os.Stderr)
276278
options := []string{

internal/cli/snapshot.go

Lines changed: 63 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"bytes"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"net/http"
@@ -18,6 +19,7 @@ import (
1819
"github.com/openbootdotdev/openboot/internal/httputil"
1920
"github.com/openbootdotdev/openboot/internal/installer"
2021
"github.com/openbootdotdev/openboot/internal/snapshot"
22+
syncpkg "github.com/openbootdotdev/openboot/internal/sync"
2123
"github.com/openbootdotdev/openboot/internal/ui"
2224
"github.com/spf13/cobra"
2325
)
@@ -223,85 +225,82 @@ func uploadSnapshot(snap *snapshot.Snapshot) error {
223225
return fmt.Errorf("no valid auth token found — please log in again")
224226
}
225227

226-
configName, configDesc, visibility, err := promptConfigDetails()
228+
updateSlug, err := promptUpdateOrCreate()
227229
if err != nil {
228230
return err
229231
}
230232

231-
slug, err := postSnapshotToAPI(snap, configName, configDesc, visibility, stored.Token, apiBase)
233+
configName, configDesc, visibility, err := promptPushDetails()
232234
if err != nil {
233235
return err
234236
}
235237

236-
configURL := fmt.Sprintf("%s/%s/%s", apiBase, stored.Username, slug)
237-
installURL := fmt.Sprintf("openboot -u %s/%s", stored.Username, slug)
238+
resultSlug, err := postSnapshotToAPI(snap, configName, configDesc, visibility, stored.Token, apiBase, updateSlug)
239+
if err != nil {
240+
return err
241+
}
242+
243+
configURL := fmt.Sprintf("%s/%s/%s", apiBase, stored.Username, resultSlug)
244+
installURL := fmt.Sprintf("openboot -u %s/%s", stored.Username, resultSlug)
238245

239246
fmt.Fprintln(os.Stderr)
240-
fmt.Fprintln(os.Stderr, snapSuccessStyle.Render("✓ Config uploaded successfully!"))
247+
if updateSlug != "" {
248+
fmt.Fprintln(os.Stderr, snapSuccessStyle.Render("✓ Config updated successfully!"))
249+
} else {
250+
fmt.Fprintln(os.Stderr, snapSuccessStyle.Render("✓ Config uploaded successfully!"))
251+
}
241252
fmt.Fprintln(os.Stderr)
242253
showUploadedConfigInfo(visibility, configURL, installURL)
243254
fmt.Fprintln(os.Stderr)
244255

245256
return nil
246257
}
247258

248-
func promptConfigDetails() (string, string, string, error) {
249-
fmt.Fprintln(os.Stderr)
250-
configName, err := ui.Input("Config name", "My Mac Setup")
251-
if err != nil {
252-
return "", "", "", fmt.Errorf("get config name: %w", err)
253-
}
254-
configName = strings.TrimSpace(configName)
255-
if configName == "" {
256-
configName = "My Mac Setup"
259+
func promptUpdateOrCreate() (string, error) {
260+
source, err := syncpkg.LoadSource()
261+
if err != nil || source == nil || source.Username == "" || source.Slug == "" {
262+
return "", nil
257263
}
258264

259-
fmt.Fprintln(os.Stderr)
260-
configDesc, err := ui.Input("Description (optional)", "")
261-
if err != nil {
262-
return "", "", "", fmt.Errorf("get config description: %w", err)
263-
}
264-
configDesc = strings.TrimSpace(configDesc)
265+
label := fmt.Sprintf("@%s/%s", source.Username, source.Slug)
266+
updateOption := fmt.Sprintf("Update existing config (%s)", label)
267+
options := []string{updateOption, "Create new config"}
265268

266269
fmt.Fprintln(os.Stderr)
267-
visibilityOptions := []string{
268-
"Public - Anyone can discover and use this config",
269-
"Unlisted - Only people with the link can access",
270-
"Private - Only you can see this config",
271-
}
272-
visibilityChoice, err := ui.SelectOption("Who can see this config?", visibilityOptions)
270+
choice, err := ui.SelectOption("Upload mode:", options)
273271
if err != nil {
274-
return "", "", "", fmt.Errorf("select visibility: %w", err)
272+
return "", fmt.Errorf("select upload mode: %w", err)
275273
}
276274

277-
visibility := "unlisted"
278-
if strings.HasPrefix(visibilityChoice, "Public") {
279-
visibility = "public"
280-
} else if strings.HasPrefix(visibilityChoice, "Private") {
281-
visibility = "private"
275+
if choice == updateOption {
276+
return source.Slug, nil
282277
}
283-
284-
return configName, configDesc, visibility, nil
278+
return "", nil
285279
}
286280

287-
func postSnapshotToAPI(snap *snapshot.Snapshot, configName, configDesc, visibility, token, apiBase string) (string, error) {
281+
func postSnapshotToAPI(snap *snapshot.Snapshot, configName, configDesc, visibility, token, apiBase, slug string) (string, error) {
282+
method := "POST"
288283
reqBody := map[string]interface{}{
289284
"name": configName,
290285
"description": configDesc,
291286
"snapshot": snap,
292287
"visibility": visibility,
293288
}
289+
if slug != "" {
290+
method = "PUT"
291+
reqBody["config_slug"] = slug
292+
}
294293
bodyBytes, err := json.Marshal(reqBody)
295294
if err != nil {
296295
return "", fmt.Errorf("marshal request: %w", err)
297296
}
298297

299298
uploadURL := fmt.Sprintf("%s/api/configs/from-snapshot", apiBase)
300-
req, err := http.NewRequest("POST", uploadURL, bytes.NewReader(bodyBytes))
299+
req, err := http.NewRequest(method, uploadURL, bytes.NewReader(bodyBytes))
301300
if err != nil {
302301
return "", fmt.Errorf("create upload request: %w", err)
303302
}
304-
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
303+
req.Header.Set("Authorization", "Bearer "+token)
305304
req.Header.Set("Content-Type", "application/json")
306305

307306
client := &http.Client{Timeout: 30 * time.Second}
@@ -312,9 +311,28 @@ func postSnapshotToAPI(snap *snapshot.Snapshot, configName, configDesc, visibili
312311
defer resp.Body.Close()
313312

314313
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
315-
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) // 64 KB for error responses
316-
if err != nil {
317-
return "", fmt.Errorf("upload failed (status %d): read response: %w", resp.StatusCode, err)
314+
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
315+
if readErr != nil {
316+
return "", fmt.Errorf("upload failed (status %d): read response: %w", resp.StatusCode, readErr)
317+
}
318+
if resp.StatusCode == http.StatusConflict {
319+
var errResp struct {
320+
Message string `json:"message"`
321+
Error string `json:"error"`
322+
}
323+
if jsonErr := json.Unmarshal(respBody, &errResp); jsonErr == nil {
324+
msg := errResp.Message
325+
if msg == "" {
326+
msg = errResp.Error
327+
}
328+
if msg != "" && strings.Contains(strings.ToLower(msg), "maximum") {
329+
return "", errors.New("config limit reached (max 20): delete an existing config with 'openboot delete <slug>' first")
330+
}
331+
if msg != "" {
332+
return "", errors.New(msg)
333+
}
334+
}
335+
return "", fmt.Errorf("conflict: %s", string(respBody))
318336
}
319337
return "", fmt.Errorf("upload failed (status %d): %s", resp.StatusCode, string(respBody))
320338
}
@@ -325,7 +343,11 @@ func postSnapshotToAPI(snap *snapshot.Snapshot, configName, configDesc, visibili
325343
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
326344
return "", fmt.Errorf("parse upload response: %w", err)
327345
}
328-
return result.Slug, nil
346+
resultSlug := result.Slug
347+
if resultSlug == "" {
348+
resultSlug = slug
349+
}
350+
return resultSlug, nil
329351
}
330352

331353
func showUploadedConfigInfo(visibility, configURL, installURL string) {

0 commit comments

Comments
 (0)