From e906831fc5795ea887cba9ba2f5384a444292651 Mon Sep 17 00:00:00 2001 From: lei Date: Mon, 1 Jun 2026 18:36:28 +0300 Subject: [PATCH 1/3] fix(s3): pick existing profile or create new in configure wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The configure wizard's first step was a free-text profile prompt that ignored existing profiles — confusing when you already have default/production set up. Replace it with a Select of existing profiles (each tagged "S3 configured" / "no S3 credentials yet") plus "+ Create new profile…", defaulting the selection to the active profile. A conditional new-name step prompts only when creating. --profile and the other flags are unaffected (a preset profile skips the picker). The new-name step's Resetter is a no-op so skipping it (existing profile) doesn't clobber the chosen profile back to the default. Adds profileChoices + tests; updates the wizard-flow docs in README. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/README.md | 15 ++-- internal/verda-cli/cmd/s3/configure_test.go | 38 ++++++++++ internal/verda-cli/cmd/s3/wizard.go | 81 +++++++++++++++++++-- internal/verda-cli/cmd/s3/wizard_test.go | 17 +++-- 4 files changed, 134 insertions(+), 17 deletions(-) diff --git a/internal/verda-cli/cmd/s3/README.md b/internal/verda-cli/cmd/s3/README.md index 774f5ca..e0e6987 100644 --- a/internal/verda-cli/cmd/s3/README.md +++ b/internal/verda-cli/cmd/s3/README.md @@ -268,10 +268,13 @@ Business logic: Wizard flow (`configure`): -1. Profile name (default `default`) -2. S3 access key ID (masked) -3. S3 secret access key (password prompt) -4. S3 endpoint URL (must start with `http://` or `https://`) -5. S3 region (default `us-east-1`) +1. Profile — **pick an existing profile** (each tagged "S3 configured" / "no S3 credentials yet") **or "+ Create new profile…"**. The selection defaults to the active profile (`--profile` / `VERDA_PROFILE` / `verda auth use`). +2. New profile name — only when "Create new" was chosen. +3. S3 access key ID +4. S3 secret access key (password prompt) +5. S3 endpoint URL (must start with `http://` or `https://`) +6. S3 region (default `us-east-1`) -All five steps are skipped individually when the corresponding flag is already set — so `verda s3 configure --access-key X --endpoint Y` only prompts for the secret and region. +Steps are skipped individually when the corresponding flag is already set — so `verda s3 configure --access-key X --endpoint Y` only prompts for the secret and region, and `--profile staging` skips the profile picker entirely (targeting `[staging]`). + +> Note: `configure` writes to the profile you pick/name here; it does **not** auto-follow the active profile the way the read commands (`ls`/`cp`/…) do. The picker defaulting to the active profile keeps the two aligned, but if you create credentials for a non-active profile, pass `--profile` to the read commands or `verda auth use ` to switch. diff --git a/internal/verda-cli/cmd/s3/configure_test.go b/internal/verda-cli/cmd/s3/configure_test.go index c8f001c..e4683d0 100644 --- a/internal/verda-cli/cmd/s3/configure_test.go +++ b/internal/verda-cli/cmd/s3/configure_test.go @@ -47,6 +47,44 @@ func TestResolveCredentialsFileExplicit(t *testing.T) { } } +func TestProfileChoices(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "credentials") + content := `[default] +verda_s3_access_key = AKIA +verda_s3_secret_key = secret +verda_s3_endpoint = https://objects.example.storage + +[production] +verda_client_id = api-only +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + choices := profileChoices(path) + if len(choices) != 3 { + t.Fatalf("choices = %d, want 3 (default, production, create-new): %+v", len(choices), choices) + } + if choices[0].Value != "default" || choices[0].Description != "S3 configured" { + t.Errorf("choice[0] = %+v, want default / S3 configured", choices[0]) + } + if choices[1].Value != "production" || choices[1].Description != "no S3 credentials yet" { + t.Errorf("choice[1] = %+v, want production / no S3 credentials yet", choices[1]) + } + if choices[2].Value != newProfileSentinel { + t.Errorf("last choice = %+v, want the create-new sentinel", choices[2]) + } +} + +func TestProfileChoices_NoFile(t *testing.T) { + t.Parallel() + choices := profileChoices(filepath.Join(t.TempDir(), "does-not-exist")) + if len(choices) != 1 || choices[0].Value != newProfileSentinel { + t.Errorf("with no credentials file, want only the create-new choice, got %+v", choices) + } +} + func TestConfigureWritesINI(t *testing.T) { t.Parallel() diff --git a/internal/verda-cli/cmd/s3/wizard.go b/internal/verda-cli/cmd/s3/wizard.go index 99f3b26..37dfc3f 100644 --- a/internal/verda-cli/cmd/s3/wizard.go +++ b/internal/verda-cli/cmd/s3/wizard.go @@ -15,21 +15,29 @@ package s3 import ( + "context" "errors" "strings" + "github.com/verda-cloud/verdagostack/pkg/tui" "github.com/verda-cloud/verdagostack/pkg/tui/bubbletea" "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + + "github.com/verda-cloud/verda-cli/internal/verda-cli/options" ) const ( defaultProfileName = "default" defaultRegion = "us-east-1" + // newProfileSentinel is the Choice value for "create a new profile". A NUL + // byte can't occur in an INI section name, so it never collides with a real + // profile. + newProfileSentinel = "\x00new-profile" ) // buildConfigureFlow builds the interactive wizard flow for S3 credential configuration. // -// profile → access-key → secret-key → endpoint → region +// profile (pick or create) → [new name] → access-key → secret-key → endpoint → region func buildConfigureFlow(opts *configureOptions) *wizard.Flow { return &wizard.Flow{ Name: "s3-configure", @@ -39,6 +47,7 @@ func buildConfigureFlow(opts *configureOptions) *wizard.Flow { }, Steps: []wizard.Step{ configureStepProfile(opts), + configureStepNewProfileName(opts), configureStepAccessKey(opts), configureStepSecretKey(opts), configureStepEndpoint(opts), @@ -47,22 +56,82 @@ func buildConfigureFlow(opts *configureOptions) *wizard.Flow { } } +// profileChoices lists existing credential profiles (each tagged with whether it +// already holds S3 credentials) plus a trailing "create new" option. Reading the +// file fails soft: on any error the user still gets the create-new choice. +func profileChoices(path string) []wizard.Choice { + profiles, _ := options.ListProfiles(path) + choices := make([]wizard.Choice, 0, len(profiles)+1) + for _, p := range profiles { + desc := "no S3 credentials yet" + if creds, err := options.LoadS3CredentialsForProfile(path, p); err == nil && creds.HasCredentials() { + desc = "S3 configured" + } + choices = append(choices, wizard.Choice{Label: p, Value: p, Description: desc}) + } + return append(choices, wizard.Choice{Label: "+ Create new profile…", Value: newProfileSentinel}) +} + +// configureStepProfile lets the user pick an existing profile or create a new +// one, defaulting the selection to the active profile. A --profile flag +// (opts.Profile != default) pre-sets it and skips this step. func configureStepProfile(opts *configureOptions) wizard.Step { return wizard.Step{ Name: "profile", - Description: "Profile name", + Description: "Profile", + Prompt: wizard.SelectPrompt, + Required: true, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + path, err := resolveCredentialsFile("") + if err != nil { + return nil, err + } + return profileChoices(path), nil + }, + Default: func(_ map[string]any) any { + if p := options.ActiveProfile(""); p != "" { + return p + } + return defaultProfileName + }, + // Sentinel ("create new") leaves opts.Profile alone; the new-name step + // sets it. Picking an existing profile sets it directly. + Setter: func(v any) { + if s, _ := v.(string); s != "" && s != newProfileSentinel { + opts.Profile = s + } + }, + Resetter: func() { opts.Profile = defaultProfileName }, + IsSet: func() bool { return opts.Profile != "" && opts.Profile != defaultProfileName }, + Value: func() any { return opts.Profile }, + } +} + +// configureStepNewProfileName prompts for the name only when the user chose +// "create new" in the profile step. +func configureStepNewProfileName(opts *configureOptions) wizard.Step { + return wizard.Step{ + Name: "new-profile-name", + Description: "New profile name", Prompt: wizard.TextInputPrompt, Required: true, - Default: func(_ map[string]any) any { return defaultProfileName }, + DependsOn: []string{"profile"}, + ShouldSkip: func(collected map[string]any) bool { + v, _ := collected["profile"].(string) + return v != newProfileSentinel + }, Validate: func(v any) error { if strings.TrimSpace(v.(string)) == "" { return errors.New("profile name cannot be empty") } return nil }, - Setter: func(v any) { opts.Profile = strings.TrimSpace(v.(string)) }, - Resetter: func() { opts.Profile = defaultProfileName }, - IsSet: func() bool { return opts.Profile != "" && opts.Profile != defaultProfileName }, + Setter: func(v any) { opts.Profile = strings.TrimSpace(v.(string)) }, + // No-op: opts.Profile is owned by the profile step. Resetting it here + // (called when this step is skipped for an existing profile) would clobber + // the selected profile back to the default. + Resetter: func() {}, + IsSet: func() bool { return false }, Value: func() any { return opts.Profile }, } } diff --git a/internal/verda-cli/cmd/s3/wizard_test.go b/internal/verda-cli/cmd/s3/wizard_test.go index f22d3ae..c47b307 100644 --- a/internal/verda-cli/cmd/s3/wizard_test.go +++ b/internal/verda-cli/cmd/s3/wizard_test.go @@ -17,23 +17,28 @@ package s3 import ( "context" "io" + "path/filepath" "testing" "github.com/verda-cloud/verdagostack/pkg/tui/wizard" ) func TestBuildConfigureFlowHappyPath(t *testing.T) { - t.Parallel() + // No t.Parallel: t.Setenv isolates the credentials file the profile Loader reads. + t.Setenv("VERDA_SHARED_CREDENTIALS_FILE", filepath.Join(t.TempDir(), "credentials")) opts := &configureOptions{ - Profile: "default", + Profile: defaultProfileName, } + // No existing profiles → the only choice is "+ Create new profile…" (index 0); + // picking it prompts for the new name. flow := buildConfigureFlow(opts) engine := wizard.NewEngine(nil, nil, wizard.WithOutput(io.Discard), wizard.WithTestResults( - wizard.TextResult("staging"), // profile + wizard.SelectResult(0), // profile: create new + wizard.TextResult("staging"), // new profile name wizard.TextResult("AKIA1234567890EXAMPLE"), // access key wizard.TextResult("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLE"), // secret key wizard.TextResult("https://objects.lab.verda.storage"), // endpoint @@ -63,7 +68,8 @@ func TestBuildConfigureFlowHappyPath(t *testing.T) { } func TestBuildConfigureFlowWithPresetFlags(t *testing.T) { - t.Parallel() + // No t.Parallel: t.Setenv isolates the credentials file the profile Loader reads. + t.Setenv("VERDA_SHARED_CREDENTIALS_FILE", filepath.Join(t.TempDir(), "credentials")) opts := &configureOptions{ Profile: "prod", @@ -71,7 +77,8 @@ func TestBuildConfigureFlowWithPresetFlags(t *testing.T) { Endpoint: "https://preset.endpoint", } - // Only secret key and region need prompting. + // Profile preset (≠ default) skips the picker and the new-name step; only the + // secret key and region need prompting. flow := buildConfigureFlow(opts) engine := wizard.NewEngine(nil, nil, wizard.WithOutput(io.Discard), From a53aabbd13121c1a3bfbd92a172c1ff054044de7 Mon Sep 17 00:00:00 2001 From: lei Date: Mon, 1 Jun 2026 18:58:02 +0300 Subject: [PATCH 2/3] refactor(s3): rebuild mv wizard on the shared engine (matches configure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mv interactive wizard was hand-rolled (manual "Step N of M" headers + intro banner + per-prompt hint bars). Rebuild it on the verdagostack wizard engine, exactly like `s3 configure`: progress bar + engine-rendered hint bar + exit-confirmation on Ctrl+C. Steps — source bucket → source object → dest bucket (pick or "+ Create new bucket…") → dest key — use dynamic Loaders; the source-object list depends on the chosen bucket via the store. A source fixed by an argument pre-sets and skips those steps. Source selection, confirm, bucket creation, and the move run around engine.Run, mirroring configure's save-after-wizard. Removes the now-unused hand-rolled helpers (navIdx, selectStep, moveConfirmStep, printMoveStep, buildMoveSteps, the step consts, plus selectObjectKey / pickSourceBucket in picker.go). New tests: TestBuildMoveFlow_CollectsSelections (engine drives the steps into state) and TestFinalizeMove_S3ToS3 (confirm → CopyObject + DeleteObject), replacing the old hand-rolled wizard tests. make build / make lint (0 issues, incl. CI's golangci-lint v2.5.0) / make test pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/move_wizard.go | 377 +++++++++++------- internal/verda-cli/cmd/s3/picker.go | 52 --- .../verda-cli/cmd/s3/tui_interactive_test.go | 125 ++---- 3 files changed, 278 insertions(+), 276 deletions(-) diff --git a/internal/verda-cli/cmd/s3/move_wizard.go b/internal/verda-cli/cmd/s3/move_wizard.go index cc480b4..7d97154 100644 --- a/internal/verda-cli/cmd/s3/move_wizard.go +++ b/internal/verda-cli/cmd/s3/move_wizard.go @@ -16,44 +16,39 @@ package s3 import ( "context" + "errors" "fmt" "strings" - "charm.land/lipgloss/v2" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/spf13/cobra" "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verdagostack/pkg/tui/bubbletea" + "github.com/verda-cloud/verdagostack/pkg/tui/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) -// printMoveWizardIntro tells the user up front what the wizard will do — a move -// is a copy followed by deleting the source, so the heads-up matters. -func printMoveWizardIntro(ioStreams cmdutil.IOStreams) { - title := lipgloss.NewStyle().Bold(true) - dim := lipgloss.NewStyle().Faint(true) - _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s\n", title.Render("Move / rename an S3 object")) - _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n", dim.Render("Copies the object to a new location, then deletes the source.")) - _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n\n", dim.Render("Steps: pick source object → destination bucket → destination key → confirm. Esc: back · Ctrl+C: cancel")) -} - -// move wizard steps. -const ( - mvStepSourceBucket = iota - mvStepSourceObject - mvStepDestBucket - mvStepDestKey - mvStepConfirm -) +// newBucketSentinel is the dest-bucket Choice value for "create a new bucket". A +// NUL byte can't be a bucket name, so it never collides with a real one. +const newBucketSentinel = "\x00new-bucket" -// moveStepTitles is indexed by the step constants above. Keep in sync. -var moveStepTitles = []string{"Source bucket", "Source object", "Destination bucket", "Destination key", "Confirm"} +// moveWizardState holds the selections collected across the move wizard steps. +type moveWizardState struct { + srcBucket string + srcKey string + dstBucket string + newDstBucket string // set when the user chose "create new bucket" + dstKey string +} -// runMoveWizard guides an interactive S3->S3 move/rename as a stepped wizard. -// Every prompt is its own step, walked by an index into a steps slice, so Esc -// steps BACK exactly one prompt and Ctrl+C exits — the standard hint-bar -// contract. Steps the user can't act on (a source fixed by an argument) are -// dropped from the slice, so the "Step N of M" numbering always matches reality. -// On confirm it reuses the normal S3->S3 move path (CopyObject + delete). +// runMoveWizard guides an interactive S3->S3 move/rename using the shared wizard +// engine (same progress bar + hint bar + exit-confirmation as `s3 configure`): +// source bucket → source object → destination bucket (pick or create) → +// destination key. A source fixed by an argument pre-sets and skips those steps. +// After the engine collects the selections it previews + confirms, creates the +// destination bucket if new, and runs the normal S3->S3 move (CopyObject + delete). // // ctx is cmd.Context() (unbounded): the prompts involve user think-time and must // not hit the short per-request --timeout. @@ -64,66 +59,25 @@ func runMoveWizard(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOSt return err } - srcBucket, srcKey, sourceFixed, err := parseMoveSourceArg(cmd, args) + srcBucket, srcKey, _, err := parseMoveSourceArg(cmd, args) if err != nil { return err } - var dstBucket, dstKey string - - printMoveWizardIntro(ioStreams) - - steps := buildMoveSteps(sourceFixed, srcBucket) - // i < 0 (Esc on the first step) or i == len(steps) (done) ends the loop. - for i := 0; i >= 0 && i < len(steps); { - step := steps[i] - printMoveStep(ioStreams, i, len(steps), step) - - switch step { - case mvStepSourceBucket: - b, perr := pickSourceBucket(ctx, f, ioStreams, client) - if i, err = selectStep(i, b, perr, func() { srcBucket = b }); err != nil { - return err - } + st := &moveWizardState{srcBucket: srcBucket, srcKey: srcKey} - case mvStepSourceObject: - k, perr := selectObjectKey(ctx, f, ioStreams, client, srcBucket) - if i, err = selectStep(i, k, perr, func() { srcKey = k }); err != nil { - return err - } - - case mvStepDestBucket: - b, perr := selectBucketOrCreate(ctx, f, ioStreams, client) - // selectBucketOrCreate never returns an empty name without an error. - if i, err = navIdx(i, perr, func() { dstBucket = b }); err != nil { - return err - } - - case mvStepDestKey: - k, perr := f.Prompter().TextInput(ctx, "Destination key", tui.WithDefault(srcKey)) - i, err = navIdx(i, perr, func() { - if k = strings.TrimSpace(k); k == "" { - k = srcKey - } - dstKey = k - }) - if err != nil { - return err - } - - case mvStepConfirm: - done, cerr := moveConfirmStep(ctx, cmd, f, ioStreams, opts, URI{Bucket: srcBucket, Key: srcKey}, URI{Bucket: dstBucket, Key: dstKey}) - if cerr != nil || done { - return cerr - } - i-- // not done (Esc or identical src/dst) -> back to the destination key - } + engine := wizard.NewEngine(f.Prompter(), f.Status(), + wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) + if err := engine.Run(ctx, buildMoveFlow(f, client, st)); err != nil { + return err } - return nil + + return finalizeMove(ctx, cmd, f, ioStreams, client, opts, st) } // parseMoveSourceArg interprets an optional single s3:// argument: a full -// bucket/key fixes the source (sourceFixed=true); a bucket-only URI pre-fills -// srcBucket but still prompts for the object; no arg returns zeros. +// bucket/key fixes the source; a bucket-only URI pre-fills the bucket but still +// prompts for the object; no arg returns zeros. sourceFixed is informational — +// step skipping is driven by which of srcBucket/srcKey are non-empty. func parseMoveSourceArg(cmd *cobra.Command, args []string) (srcBucket, srcKey string, sourceFixed bool, err error) { if len(args) != 1 { return "", "", false, nil @@ -138,75 +92,226 @@ func parseMoveSourceArg(cmd *cobra.Command, args []string) (srcBucket, srcKey st return uri.Bucket, "", false, nil } -// buildMoveSteps returns the ordered steps the user will walk, dropping any that -// an argument already satisfied: a full bucket/key source skips both source -// steps; a bucket-only source skips just the bucket step. -func buildMoveSteps(sourceFixed bool, srcBucket string) []int { - var steps []int - if !sourceFixed { - if srcBucket == "" { - steps = append(steps, mvStepSourceBucket) - } - steps = append(steps, mvStepSourceObject) +// buildMoveFlow assembles the engine flow. Steps bound to a non-empty preset +// (source fixed by an arg) report IsSet and are skipped by the engine. +func buildMoveFlow(f cmdutil.Factory, client API, st *moveWizardState) *wizard.Flow { + return &wizard.Flow{ + Name: "s3-move", + Layout: []wizard.ViewDef{ + {ID: "progress", View: wizard.NewProgressView(wizard.WithProgressPercent())}, + {ID: "hints", View: wizard.NewHintBarView(wizard.WithHintStyle(bubbletea.HintStyle()))}, + }, + Steps: []wizard.Step{ + moveStepSourceBucket(f, client, st), + moveStepSourceObject(f, client, st), + moveStepDestBucket(f, client, st), + moveStepNewDestBucket(st), + moveStepDestKey(st), + }, + } +} + +func moveStepSourceBucket(f cmdutil.Factory, client API, st *moveWizardState) wizard.Step { + return wizard.Step{ + Name: "source-bucket", + Description: "Source bucket", + Prompt: wizard.SelectPrompt, + Required: true, + Loader: func(ctx context.Context, _ tui.Prompter, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + return bucketChoices(ctx, f, client, false) + }, + Setter: func(v any) { + if s, _ := v.(string); s != "" { + st.srcBucket = s + } + }, + Resetter: func() { st.srcBucket = "" }, + IsSet: func() bool { return st.srcBucket != "" }, + Value: func() any { return st.srcBucket }, + } +} + +func moveStepSourceObject(f cmdutil.Factory, client API, st *moveWizardState) wizard.Step { + return wizard.Step{ + Name: "source-object", + Description: "Source object", + Prompt: wizard.SelectPrompt, + Required: true, + DependsOn: []string{"source-bucket"}, + Loader: func(ctx context.Context, _ tui.Prompter, _ tui.Status, store *wizard.Store) ([]wizard.Choice, error) { + bucket, _ := store.Collected()["source-bucket"].(string) + if bucket == "" { + bucket = st.srcBucket + } + return objectChoices(ctx, f, client, bucket) + }, + Setter: func(v any) { st.srcKey, _ = v.(string) }, + Resetter: func() { st.srcKey = "" }, + IsSet: func() bool { return st.srcKey != "" }, + Value: func() any { return st.srcKey }, + } +} + +func moveStepDestBucket(f cmdutil.Factory, client API, st *moveWizardState) wizard.Step { + return wizard.Step{ + Name: "dest-bucket", + Description: "Destination bucket", + Prompt: wizard.SelectPrompt, + Required: true, + Loader: func(ctx context.Context, _ tui.Prompter, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + return bucketChoices(ctx, f, client, true) + }, + // Sentinel ("create new") leaves dstBucket empty; the new-bucket step sets + // newDstBucket and finalizeMove creates it. + Setter: func(v any) { + if s, _ := v.(string); s != "" && s != newBucketSentinel { + st.dstBucket = s + } + }, + Resetter: func() { st.dstBucket = "" }, + IsSet: func() bool { return false }, + Value: func() any { return st.dstBucket }, } - return append(steps, mvStepDestBucket, mvStepDestKey, mvStepConfirm) } -// selectStep handles a list-picker step: an empty value with no error ends the -// wizard (no buckets/objects to act on), otherwise it delegates to navIdx. -func selectStep(i int, value string, perr error, apply func()) (next int, out error) { - if perr == nil && value == "" { - return -1, nil +func moveStepNewDestBucket(st *moveWizardState) wizard.Step { + return wizard.Step{ + Name: "new-dest-bucket", + Description: "New bucket name", + Prompt: wizard.TextInputPrompt, + Required: true, + DependsOn: []string{"dest-bucket"}, + ShouldSkip: func(collected map[string]any) bool { + v, _ := collected["dest-bucket"].(string) + return v != newBucketSentinel + }, + Validate: func(v any) error { + if strings.TrimSpace(v.(string)) == "" { + return errors.New("bucket name cannot be empty") + } + return nil + }, + Setter: func(v any) { st.newDstBucket = strings.TrimSpace(v.(string)) }, + // No-op: skipping (an existing dest bucket was chosen) must not clobber state. + Resetter: func() {}, + IsSet: func() bool { return false }, + Value: func() any { return st.newDstBucket }, } - return navIdx(i, perr, apply) } -// navIdx advances the wizard index based on a prompter error: Esc steps back -// (i-1; -1 on the first step ends the loop = exit), Ctrl+C exits (returns a -// terminal index), a real error propagates, and success runs apply() then i+1. -func navIdx(i int, err error, apply func()) (next int, out error) { - back, exit, fatal := classifyNav(err, false) - switch { - case fatal != nil: - return i, fatal - case exit: - return -1, nil // terminate the loop without acting - case back: - return i - 1, nil - default: - apply() - return i + 1, nil +func moveStepDestKey(st *moveWizardState) wizard.Step { + return wizard.Step{ + Name: "dest-key", + Description: "Destination key", + Prompt: wizard.TextInputPrompt, + Required: false, // blank → Default (the source key) + DependsOn: []string{"source-object"}, + Default: func(collected map[string]any) any { + if k, _ := collected["source-object"].(string); k != "" { + return k + } + return st.srcKey + }, + Setter: func(v any) { st.dstKey = strings.TrimSpace(v.(string)) }, + Resetter: func() { st.dstKey = "" }, + IsSet: func() bool { return false }, + Value: func() any { return st.dstKey }, } } -// moveConfirmStep previews the move and confirms it. done=false means step back -// to the key prompt (Esc, or an identical src/dst); done=true ends the wizard — -// the move ran, or the user exited/declined, with err carrying any real failure. -func moveConfirmStep(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *cpOptions, srcURI, dstURI URI) (done bool, err error) { - if srcURI == dstURI { - _, _ = fmt.Fprintln(ioStreams.ErrOut, " Source and destination are identical — choose a different destination.") - return false, nil +// bucketChoices lists buckets as wizard choices, optionally appending a trailing +// "create new bucket" option (for destination selection). +func bucketChoices(ctx context.Context, f cmdutil.Factory, client API, withCreate bool) ([]wizard.Choice, error) { + out, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading buckets...", func() (*s3.ListBucketsOutput, error) { + return client.ListBuckets(ctx, &s3.ListBucketsInput{}) + }) + if err != nil { + return nil, translateError(err) + } + choices := make([]wizard.Choice, 0, len(out.Buckets)+1) + for i := range out.Buckets { + name := aws.ToString(out.Buckets[i].Name) + choices = append(choices, wizard.Choice{Label: name, Value: name}) } + if withCreate { + choices = append(choices, wizard.Choice{Label: "+ Create new bucket…", Value: newBucketSentinel}) + } + return choices, nil +} + +// objectChoices lists object keys in bucket (capped) as wizard choices. An empty +// bucket is an error — there is nothing to move out of it. +func objectChoices(ctx context.Context, f cmdutil.Factory, client API, bucket string) ([]wizard.Choice, error) { + res, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading objects...", func() (cappedKeys, error) { + k, truncated, e := listKeysCapped(ctx, client, bucket, objectPickerCap) + return cappedKeys{keys: k, truncated: truncated}, e + }) + if err != nil { + return nil, err + } + if len(res.keys) == 0 { + return nil, fmt.Errorf("no objects in s3://%s", bucket) + } + choices := make([]wizard.Choice, 0, len(res.keys)) + for _, k := range res.keys { + choices = append(choices, wizard.Choice{Label: k, Value: k}) + } + return choices, nil +} + +// finalizeMove resolves the destination (creating a new bucket if requested), +// previews + confirms, and runs the S3->S3 move. An identical src/dst re-prompts +// the key rather than aborting. A clean cancel returns nil. +func finalizeMove(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, opts *cpOptions, st *moveWizardState) error { + dstBucket := st.dstBucket + if st.newDstBucket != "" { + dstBucket = st.newDstBucket + } + dstKey := st.dstKey + if dstKey == "" { + dstKey = st.srcKey + } + srcURI := URI{Bucket: st.srcBucket, Key: st.srcKey} + + // Disallow moving onto itself; re-prompt the key until it differs. + dstURI := URI{Bucket: dstBucket, Key: dstKey} + for srcURI == dstURI { + _, _ = fmt.Fprintln(ioStreams.ErrOut, " Source and destination are identical — enter a different destination key.") + k, kerr := f.Prompter().TextInput(ctx, "Destination key", tui.WithDefault(dstKey)) + if kerr != nil { + if cmdutil.IsPromptCancel(kerr) { + return nil + } + return kerr + } + if dstKey = strings.TrimSpace(k); dstKey == "" { + dstKey = st.srcKey + } + dstURI = URI{Bucket: dstBucket, Key: dstKey} + } + _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n Will run: verda s3 mv %s %s\n\n", srcURI.String(), dstURI.String()) - confirmed, cerr := f.Prompter().Confirm(ctx, "Proceed with move? (esc to go back)", tui.WithConfirmDefault(true)) - back, exit, fatal := classifyNav(cerr, false) - switch { - case fatal != nil: - return true, fatal - case back: - return false, nil // Esc -> step back to the key prompt - case exit: - return true, nil // Ctrl+C - case !confirmed: + confirmed, cerr := f.Prompter().Confirm(ctx, "Proceed with move?", tui.WithConfirmDefault(true)) + if cerr != nil { + if cmdutil.IsPromptCancel(cerr) { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return cerr + } + if !confirmed { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") - return true, nil - default: - return true, runCopyMv(ctx, cmd, f, ioStreams, srcURI, dstURI, opts) + return nil + } + + if st.newDstBucket != "" { + if _, err := cmdutil.WithSpinner(ctx, f.Status(), "Creating bucket...", func() (*s3.CreateBucketOutput, error) { + return client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(dstBucket)}) + }); err != nil { + return translateError(err) + } + _, _ = fmt.Fprintf(ioStreams.ErrOut, " Created bucket %s\n", dstBucket) } -} -// printMoveStep renders the "Step N of M · Title" header for the i-th step of n. -func printMoveStep(ioStreams cmdutil.IOStreams, i, n, step int) { - header := fmt.Sprintf("Step %d of %d · %s", i+1, n, moveStepTitles[step]) - _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n", lipgloss.NewStyle().Bold(true).Render(header)) + return runCopyMv(ctx, cmd, f, ioStreams, srcURI, dstURI, opts) } diff --git a/internal/verda-cli/cmd/s3/picker.go b/internal/verda-cli/cmd/s3/picker.go index e69a548..d3796e8 100644 --- a/internal/verda-cli/cmd/s3/picker.go +++ b/internal/verda-cli/cmd/s3/picker.go @@ -132,58 +132,6 @@ func promptNewBucketName(ctx context.Context, f cmdutil.Factory) (string, error) return strings.TrimSpace(name), nil } -// selectObjectKey lists object keys in bucket (paginated, capped at -// objectPickerCap) and prompts for one. Returns ("", nil) on a clean cancel or -// an empty bucket. -func selectObjectKey(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, bucket string) (string, error) { - res, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading objects...", func() (cappedKeys, error) { - k, truncated, e := listKeysCapped(ctx, client, bucket, objectPickerCap) - return cappedKeys{keys: k, truncated: truncated}, e - }) - if err != nil { - return "", err - } - if len(res.keys) == 0 { - _, _ = fmt.Fprintf(ioStreams.ErrOut, "No objects in s3://%s.\n", bucket) - return "", nil - } - if res.truncated { - _, _ = fmt.Fprintf(ioStreams.ErrOut, "Showing the first %d objects of s3://%s; pass an explicit key for the rest.\n", objectPickerCap, bucket) - } - // Raw error on cancel so a wizard caller can tell Esc (go back) from Ctrl+C - // (exit). A non-wizard caller can still use cmdutil.IsPromptCancel. - idx, err := f.Prompter().Select(ctx, "Select object in s3://"+bucket, res.keys, tui.WithShowHints(true)) - if err != nil { - return "", err - } - return res.keys[idx], nil -} - -// pickSourceBucket lists existing buckets and returns the chosen name, surfacing -// the raw prompter error on cancel (so a wizard can distinguish Esc from Ctrl+C). -// Returns ("", nil) when there are no buckets. -func pickSourceBucket(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API) (string, error) { - out, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading buckets...", func() (*s3.ListBucketsOutput, error) { - return client.ListBuckets(ctx, &s3.ListBucketsInput{}) - }) - if err != nil { - return "", translateError(err) - } - if len(out.Buckets) == 0 { - _, _ = fmt.Fprintln(ioStreams.ErrOut, "No buckets found.") - return "", nil - } - labels := make([]string, len(out.Buckets)) - for i := range out.Buckets { - labels[i] = aws.ToString(out.Buckets[i].Name) - } - idx, err := f.Prompter().Select(ctx, "Select source bucket", labels, tui.WithShowHints(true)) - if err != nil { - return "", err - } - return aws.ToString(out.Buckets[idx].Name), nil -} - type cappedKeys struct { keys []string truncated bool diff --git a/internal/verda-cli/cmd/s3/tui_interactive_test.go b/internal/verda-cli/cmd/s3/tui_interactive_test.go index 3062c33..291801a 100644 --- a/internal/verda-cli/cmd/s3/tui_interactive_test.go +++ b/internal/verda-cli/cmd/s3/tui_interactive_test.go @@ -17,15 +17,15 @@ package s3 import ( "bytes" "context" - "errors" + "io" "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" - "github.com/verda-cloud/verdagostack/pkg/tui" tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + "github.com/verda-cloud/verdagostack/pkg/tui/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) @@ -52,36 +52,6 @@ func TestPromptNewBucketName_EmptyIsCleanCancel(t *testing.T) { } } -// ----- picker: flat object selection (mv source) ------------------------- - -func TestSelectObjectKey_PicksChosen(t *testing.T) { - fake := &fakeS3API{objects: []s3types.Object{ - {Key: aws.String("a.txt")}, - {Key: aws.String("b.txt")}, - }} - f := cmdutil.NewTestFactory(tuitest.New().AddSelect(1)) - got, err := selectObjectKey(context.Background(), f, ioBufs(), fake, "bucket") - if err != nil { - t.Fatalf("selectObjectKey: %v", err) - } - if got != "b.txt" { - t.Errorf("chosen key = %q, want b.txt", got) - } -} - -func TestSelectObjectKey_EmptyBucketReturnsBlank(t *testing.T) { - fake := &fakeS3API{} - f := cmdutil.NewTestFactory(tuitest.New()) - errOut := &bytes.Buffer{} - got, err := selectObjectKey(context.Background(), f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: errOut}, fake, "bucket") - if err != nil || got != "" { - t.Errorf("got (%q, %v), want empty for empty bucket", got, err) - } - if !strings.Contains(errOut.String(), "No objects") { - t.Errorf("missing empty-bucket note: %q", errOut.String()) - } -} - // ----- rm: interactive folder browser delete ----------------------------- // rmBrowseFake is prefix-aware (root exposes data/, data/ exposes one object) @@ -184,28 +154,47 @@ func (m *mvWizardFake) DeleteObject(ctx context.Context, in *s3.DeleteObjectInpu return &s3.DeleteObjectOutput{}, nil } -func TestMoveWizard_S3ToS3(t *testing.T) { - // no t.Parallel — clientBuilder/prompter state +// TestBuildMoveFlow_CollectsSelections drives the engine flow (no source arg) and +// asserts the steps populate the wizard state: source bucket → object → dest +// bucket (existing) → dest key. The dest-bucket choices are [src, dst, +create], +// so index 1 selects "dst" and the new-bucket step is skipped. +func TestBuildMoveFlow_CollectsSelections(t *testing.T) { + // no t.Parallel — clientBuilder/prompter state via fake fake := &mvWizardFake{buckets: []string{"src", "dst"}, objects: []string{"a.txt"}} + f := cmdutil.NewTestFactory(tuitest.New()) + st := &moveWizardState{} + + engine := wizard.NewEngine(nil, nil, + wizard.WithOutput(io.Discard), + wizard.WithTestResults( + wizard.SelectResult(0), // source bucket: src + wizard.SelectResult(0), // source object: a.txt + wizard.SelectResult(1), // dest bucket: dst + wizard.TextResult("renamed.txt"), // dest key + ), + ) + if err := engine.Run(context.Background(), buildMoveFlow(f, fake, st)); err != nil { + t.Fatalf("engine Run: %v", err) + } + if st.srcBucket != "src" || st.srcKey != "a.txt" || st.dstBucket != "dst" || st.dstKey != "renamed.txt" { + t.Errorf("state = %+v, want src/a.txt -> dst/renamed.txt", st) + } +} + +// TestFinalizeMove_S3ToS3 covers the post-wizard execution: confirm → CopyObject +// on the destination + DeleteObject on the source. +func TestFinalizeMove_S3ToS3(t *testing.T) { + // no t.Parallel — clientBuilder/prompter state + fake := &mvWizardFake{} restore := withFakeClient(fake) defer restore() - // src bucket(0) -> object a.txt(0) -> dst bucket(1) -> dest key -> confirm. - mock := tuitest.New(). - AddSelect(0).AddSelect(0).AddSelect(1). - AddTextInput("renamed.txt"). - AddConfirm(true) - - f := cmdutil.NewTestFactory(mock) + f := cmdutil.NewTestFactory(tuitest.New().AddConfirm(true)) cmd := NewCmdMv(f, ioBufs()) + st := &moveWizardState{srcBucket: "src", srcKey: "a.txt", dstBucket: "dst", dstKey: "renamed.txt"} - errOut := &bytes.Buffer{} - io := cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: errOut} - if err := runMoveWizard(cmd, f, io, &cpOptions{}, nil); err != nil { - t.Fatalf("runMoveWizard: %v", err) - } - if !strings.Contains(errOut.String(), "Move / rename an S3 object") { - t.Errorf("missing wizard intro banner:\n%s", errOut.String()) + if err := finalizeMove(context.Background(), cmd, f, ioBufs(), fake, &cpOptions{}, st); err != nil { + t.Fatalf("finalizeMove: %v", err) } if fake.deleted != "src/a.txt" { t.Errorf("source deleted = %q, want src/a.txt", fake.deleted) @@ -218,46 +207,6 @@ func TestMoveWizard_S3ToS3(t *testing.T) { } } -// TestNavIdx covers the wizard index navigation that makes Esc=back work: -// success advances and applies, Esc steps back, Ctrl+C/real errors terminate. -func TestNavIdx(t *testing.T) { - t.Parallel() - boom := errors.New("io failure") - cases := []struct { - name string - err error - wantNext int - wantErr error - wantApply bool - }{ - {"success advances + applies", nil, 3, nil, true}, - {"esc steps back", context.Canceled, 1, nil, false}, - {"ctrl+c terminates", tui.ErrInterrupted, -1, nil, false}, - {"real error propagates", boom, 2, boom, false}, - } - for _, tc := range cases { - applied := false - next, out := navIdx(2, tc.err, func() { applied = true }) - if next != tc.wantNext || !errors.Is(out, tc.wantErr) || applied != tc.wantApply { - t.Errorf("%s: navIdx = (next=%d err=%v applied=%v), want (%d %v %v)", - tc.name, next, out, applied, tc.wantNext, tc.wantErr, tc.wantApply) - } - } -} - -func TestSelectStep_EmptyValueExits(t *testing.T) { - t.Parallel() - applied := false - next, err := selectStep(0, "", nil, func() { applied = true }) - if next != -1 || err != nil || applied { - t.Errorf("selectStep(empty) = (next=%d err=%v applied=%v), want (-1, nil, false)", next, err, applied) - } - // Non-empty success still advances + applies. - if next, _ := selectStep(0, "bucket", nil, func() { applied = true }); next != 1 || !applied { - t.Errorf("selectStep(value) next=%d applied=%v, want 1/true", next, applied) - } -} - func ioBufs() cmdutil.IOStreams { return cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}} } From dd19b8b0f4d5b7839410b7b1bcc08db6878e32d0 Mon Sep 17 00:00:00 2001 From: lei Date: Tue, 2 Jun 2026 09:36:37 +0300 Subject: [PATCH 3/3] feat(s3): default configure endpoint to objects.fin-03; show where to get keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DefaultEndpoint is now https://objects.fin-03.verda.storage (replacing the stale s3.verda.cloud placeholder) — the configure wizard's pre-filled endpoint and the client fallback when none is set. - The wizard pre-fills the endpoint (Enter to accept) and region (us-east-1), so the user typically only picks a profile and pastes the access key + secret. - configure now triggers the wizard only when a key is missing; the endpoint defaults when omitted, so `configure --access-key X --secret-key Y` is fully non-interactive. - Tell users where to create keys (dashboard: log in -> project -> Project management -> Credentials -> Object Storage Access Keys) in --help and a note printed at the start of the wizard. Test: TestConfigureFlagMode_DefaultsEndpointAndRegion. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/README.md | 16 +++++++---- internal/verda-cli/cmd/s3/client.go | 6 ++-- internal/verda-cli/cmd/s3/configure.go | 21 ++++++++++++-- internal/verda-cli/cmd/s3/configure_test.go | 31 +++++++++++++++++++++ internal/verda-cli/cmd/s3/wizard.go | 3 ++ 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/internal/verda-cli/cmd/s3/README.md b/internal/verda-cli/cmd/s3/README.md index e0e6987..66b4fb9 100644 --- a/internal/verda-cli/cmd/s3/README.md +++ b/internal/verda-cli/cmd/s3/README.md @@ -19,17 +19,21 @@ AWS-CLI-style object storage commands for Verda's S3-compatible endpoint. Uses a ## Configuration -Interactive wizard (prompts for access key, secret key, endpoint, region): +First create an access key in the Verda dashboard: **log in → select your +project → Project management → Credentials → Object Storage Access Keys.** + +Interactive wizard — the endpoint and region come pre-filled with defaults +(`https://objects.fin-03.verda.storage`, `us-east-1`), so you normally just pick +a profile and paste the access key + secret: ```bash verda s3 configure ``` -Non-interactive: +Non-interactive (endpoint defaults if omitted; pass `--endpoint` for another region): ```bash verda s3 configure \ --access-key AKIA... \ - --secret-key ... \ - --endpoint https://objects.lab.verda.storage + --secret-key ... ``` Show the active configuration (no secrets are printed): @@ -229,7 +233,7 @@ omitted target returns the command help (or a structured error in `--agent`). Notes: -- **`configure` wizard**: triggers when any of `--access-key`, `--secret-key`, `--endpoint` is missing. Supply all three (plus optionally `--profile`, `--region`, `--credentials-file`) to skip the wizard entirely. +- **`configure` wizard**: triggers when `--access-key` or `--secret-key` is missing. Endpoint and region default (so `configure --access-key X --secret-key Y` is fully non-interactive); pass `--endpoint`/`--region`/`--profile`/`--credentials-file` to override. - **Destructive prompts** (`rb`, `rm`): an interactive `prompter.Confirm()` with a red warning + preview runs before deletion unless `--yes` is passed — in both the flag and TUI paths. `cp`, `mv`, `sync`, `sync --delete` do not prompt (AWS convention — the verb itself is the commitment), though the interactive `mv` wizard adds a final confirm. - **`mv` interactive scope**: the wizard covers S3→S3 moves/renames only; local↔S3 moves still require both explicit arguments (a local path can't be picked in the TUI). - **`rm` interactive scope**: multi-select is scoped to one folder level; drill into subfolders to delete within them, or use `rm --recursive` for bulk deletes across a whole prefix. @@ -272,7 +276,7 @@ Wizard flow (`configure`): 2. New profile name — only when "Create new" was chosen. 3. S3 access key ID 4. S3 secret access key (password prompt) -5. S3 endpoint URL (must start with `http://` or `https://`) +5. S3 endpoint URL (pre-filled with `https://objects.fin-03.verda.storage`; must start with `http://`/`https://`) 6. S3 region (default `us-east-1`) Steps are skipped individually when the corresponding flag is already set — so `verda s3 configure --access-key X --endpoint Y` only prompts for the secret and region, and `--profile staging` skips the profile picker entirely (targeting `[staging]`). diff --git a/internal/verda-cli/cmd/s3/client.go b/internal/verda-cli/cmd/s3/client.go index e1ddf05..8690afa 100644 --- a/internal/verda-cli/cmd/s3/client.go +++ b/internal/verda-cli/cmd/s3/client.go @@ -27,9 +27,9 @@ import ( "github.com/verda-cloud/verda-cli/internal/verda-cli/options" ) -// DefaultEndpoint is the bundled Verda S3 endpoint used when the user has not -// configured one explicitly. Update when the production URL is confirmed. -const DefaultEndpoint = "https://s3.verda.cloud" +// DefaultEndpoint is the bundled Verda S3 endpoint — the configure wizard's +// pre-filled default and the fallback when no endpoint is set. +const DefaultEndpoint = "https://objects.fin-03.verda.storage" // API is the minimal subset of the AWS S3 client used by this package. // It exists to allow tests to inject fake clients without depending on diff --git a/internal/verda-cli/cmd/s3/configure.go b/internal/verda-cli/cmd/s3/configure.go index 8611dd4..eb4a719 100644 --- a/internal/verda-cli/cmd/s3/configure.go +++ b/internal/verda-cli/cmd/s3/configure.go @@ -51,6 +51,11 @@ func NewCmdConfigure(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Comm Long: cmdutil.LongDesc(` Save S3 object storage credentials to the shared credentials file. + Create an access key first in the Verda dashboard: log in, select your + project, then open Project management → Credentials → Object Storage + Access Keys. The endpoint and region are pre-filled with sensible + defaults, so you usually only need to paste the access key and secret. + S3 credentials are stored alongside API credentials using verda_s3_ prefixed keys. Profile switching (--profile) works across both. @@ -76,7 +81,10 @@ func NewCmdConfigure(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Comm `), Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - if strings.TrimSpace(opts.AccessKey) == "" || strings.TrimSpace(opts.SecretKey) == "" || strings.TrimSpace(opts.Endpoint) == "" { + // Only the keys gate the wizard — endpoint/region have defaults, so + // `configure --access-key X --secret-key Y` is fully non-interactive. + if strings.TrimSpace(opts.AccessKey) == "" || strings.TrimSpace(opts.SecretKey) == "" { + printConfigureIntro(ioStreams) flow := buildConfigureFlow(opts) engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) if err := engine.Run(cmd.Context(), flow); err != nil { @@ -90,8 +98,9 @@ func NewCmdConfigure(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Comm if strings.TrimSpace(opts.SecretKey) == "" { return cmdutil.UsageErrorf(cmd, "--secret-key is required") } + // Endpoint defaults when omitted (e.g. flag mode without --endpoint). if strings.TrimSpace(opts.Endpoint) == "" { - return cmdutil.UsageErrorf(cmd, "--endpoint is required") + opts.Endpoint = DefaultEndpoint } path, err := resolveCredentialsFile(opts.CredentialsFile) @@ -147,3 +156,11 @@ func NewCmdConfigure(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Comm return cmd } + +// printConfigureIntro reminds the user where to obtain the access key before the +// wizard prompts for it. +func printConfigureIntro(ioStreams cmdutil.IOStreams) { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "\n Create an Object Storage Access Key in the Verda dashboard first:") + _, _ = fmt.Fprintln(ioStreams.ErrOut, " log in → select your project → Project management → Credentials → Object Storage Access Keys") + _, _ = fmt.Fprintln(ioStreams.ErrOut) +} diff --git a/internal/verda-cli/cmd/s3/configure_test.go b/internal/verda-cli/cmd/s3/configure_test.go index e4683d0..20462aa 100644 --- a/internal/verda-cli/cmd/s3/configure_test.go +++ b/internal/verda-cli/cmd/s3/configure_test.go @@ -15,13 +15,44 @@ package s3 import ( + "bytes" "os" "path/filepath" "testing" "gopkg.in/ini.v1" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) +// TestConfigureFlagMode_DefaultsEndpointAndRegion verifies that supplying only +// the keys runs non-interactively and fills in the default endpoint + region. +func TestConfigureFlagMode_DefaultsEndpointAndRegion(t *testing.T) { + // No t.Parallel: t.Setenv. + withTempVerdaHome(t) + path := filepath.Join(t.TempDir(), "credentials") + t.Setenv("VERDA_SHARED_CREDENTIALS_FILE", path) + + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdConfigure(f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{"--access-key", "AKIA", "--secret-key", "secret"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("configure: %v", err) + } + + cfg, err := ini.Load(path) + if err != nil { + t.Fatalf("load credentials: %v", err) + } + sec := cfg.Section("default") + if got := sec.Key("verda_s3_endpoint").String(); got != DefaultEndpoint { + t.Errorf("endpoint = %q, want default %q", got, DefaultEndpoint) + } + if got := sec.Key("verda_s3_region").String(); got != defaultRegion { + t.Errorf("region = %q, want default %q", got, defaultRegion) + } +} + func TestResolveCredentialsFileUsesEnv(t *testing.T) { path := filepath.Join(t.TempDir(), "credentials") t.Setenv("VERDA_SHARED_CREDENTIALS_FILE", path) diff --git a/internal/verda-cli/cmd/s3/wizard.go b/internal/verda-cli/cmd/s3/wizard.go index 37dfc3f..7399fe1 100644 --- a/internal/verda-cli/cmd/s3/wizard.go +++ b/internal/verda-cli/cmd/s3/wizard.go @@ -174,6 +174,9 @@ func configureStepEndpoint(opts *configureOptions) wizard.Step { Description: "S3 endpoint URL", Prompt: wizard.TextInputPrompt, Required: true, + // Pre-fill the production endpoint; the user accepts it with Enter or + // types their own region's URL. + Default: func(_ map[string]any) any { return DefaultEndpoint }, Validate: func(v any) error { s := strings.TrimSpace(v.(string)) if s == "" {