From a7fd5127941d2839e6618721bc073b0d5da82229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Thu, 2 Apr 2026 19:10:22 +0200 Subject: [PATCH 1/2] feat: add workstation setup apply flow --- client.go | 5 ++ client_test.go | 15 ++++ cmd/cloudstic/cmd_setup.go | 87 +++++++++++++++++++++-- cmd/cloudstic/cmd_setup_test.go | 46 ++++++++++-- cmd/cloudstic/usage.go | 8 +-- docs/user-guide.md | 21 +++++- internal/engine/workstation_setup.go | 42 +++++++++++ internal/engine/workstation_setup_test.go | 42 +++++++++++ 8 files changed, 248 insertions(+), 18 deletions(-) diff --git a/client.go b/client.go index 50d909f..60e1ccd 100644 --- a/client.go +++ b/client.go @@ -317,6 +317,7 @@ type WorkstationSetupOption = engine.WorkstationSetupOption type WorkstationProfileDraft = engine.WorkstationProfileDraft type WorkstationFolderCandidate = engine.WorkstationFolderCandidate type WorkstationCoverageSummary = engine.WorkstationCoverageSummary +type WorkstationApplyResult = engine.WorkstationApplyResult var ( WithVerbose = engine.WithVerbose @@ -353,6 +354,10 @@ func (c *Client) PlanWorkstationSetup(ctx context.Context, opts ...WorkstationSe return engine.PlanWorkstationSetup(ctx, opts...) } +func ApplyWorkstationSetupPlan(cfg *ProfilesConfig, plan *WorkstationSetupPlan) (*WorkstationApplyResult, error) { + return engine.ApplyWorkstationSetupPlan(cfg, plan) +} + // LoadProfilesFile parses a backup profiles YAML file. func LoadProfilesFile(path string) (*ProfilesConfig, error) { return engine.LoadProfilesFile(path) diff --git a/client_test.go b/client_test.go index dd45ca2..391ae85 100644 --- a/client_test.go +++ b/client_test.go @@ -230,6 +230,21 @@ func TestClientPlanWorkstationSetup(t *testing.T) { } } +func TestApplyWorkstationSetupPlan(t *testing.T) { + cfg := &ProfilesConfig{} + result, err := ApplyWorkstationSetupPlan(cfg, &WorkstationSetupPlan{ + Profiles: []WorkstationProfileDraft{ + {Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}}, + }, + }) + if err != nil { + t.Fatalf("ApplyWorkstationSetupPlan: %v", err) + } + if result.ProfilesCreated != 1 || cfg.Profiles["documents"].Store != "primary" { + t.Fatalf("unexpected apply result: %#v %#v", result, cfg) + } +} + // --------------------------------------------------------------------------- // Cat // --------------------------------------------------------------------------- diff --git a/cmd/cloudstic/cmd_setup.go b/cmd/cloudstic/cmd_setup.go index 05483f3..398b60e 100644 --- a/cmd/cloudstic/cmd_setup.go +++ b/cmd/cloudstic/cmd_setup.go @@ -69,18 +69,50 @@ func parseSetupWorkstationArgs() *setupWorkstationArgs { func (r *runner) runSetupWorkstation(ctx context.Context) int { args := parseSetupWorkstationArgs() - if !args.dryRun { - return r.fail("setup workstation write mode is not implemented yet; use -dry-run") - } - cfg, err := loadProfilesOrInit(args.profilesFile) if err != nil { return r.fail("Failed to load profiles: %v", err) } + ensureProfilesMaps(cfg) if r.client == nil { r.client = &cloudstic.Client{} } + if !args.dryRun && args.storeRef == "" { + if len(cfg.Stores) == 0 { + if !r.canPrompt() || args.yes { + return r.fail("No store is configured; create one first with 'cloudstic store new' or rerun interactively") + } + ref, created, code := r.promptStoreSelection(ctx, cfg) + if code != 0 { + return code + } + args.storeRef = ref + if created { + s := cfg.Stores[args.storeRef] + if !storeHasExplicitEncryption(s) { + r.promptEncryptionConfig(ctx, cfg, args.storeRef, args.profilesFile) + } + if err := r.checkOrInitStoreWithRecovery(ctx, cfg, args.storeRef, args.profilesFile, checkOrInitOptions{ + allowMissingSecrets: true, + warnOnMissingSecrets: true, + offerInit: true, + }, true); err != nil { + _, _ = fmt.Fprintf(r.errOut, "%v\n", err) + } + } + } else if len(cfg.Stores) > 1 { + if !r.canPrompt() || args.yes { + return r.fail("Multiple stores are configured; pass -store-ref or rerun interactively") + } + ref, _, code := r.promptStoreSelection(ctx, cfg) + if code != 0 { + return code + } + args.storeRef = ref + } + } + opts := []cloudstic.WorkstationSetupOption{cloudstic.WithWorkstationProfiles(cfg)} if args.storeRef != "" { opts = append(opts, cloudstic.WithWorkstationStoreRef(args.storeRef)) @@ -94,12 +126,53 @@ func (r *runner) runSetupWorkstation(ctx context.Context) int { return r.writeJSON(plan) } - printWorkstationSetupPlan(r.out, plan) + printWorkstationSetupPlan(r.out, plan, args.dryRun) + if args.dryRun { + return 0 + } + + if plan.StoreRef == "" { + return r.fail("Store selection is still unresolved; pass -store-ref or rerun interactively") + } + if len(plan.Profiles) == 0 { + _, _ = fmt.Fprintln(r.out, "\nNothing to save.") + return 0 + } + if !args.yes { + if !r.canPrompt() { + return r.fail("setup workstation requires an interactive terminal or -yes") + } + ok, err := r.promptConfirm(ctx, "Save workstation setup?", true) + if err != nil { + return r.fail("Failed to confirm workstation setup: %v", err) + } + if !ok { + _, _ = fmt.Fprintln(r.out, "Workstation setup cancelled.") + return 0 + } + } + + result, err := cloudstic.ApplyWorkstationSetupPlan(cfg, plan) + if err != nil { + return r.fail("Failed to apply workstation setup plan: %v", err) + } + if err := cloudstic.SaveProfilesFile(args.profilesFile, cfg); err != nil { + return r.fail("Failed to save profiles: %v", err) + } + _, _ = fmt.Fprintf(r.out, "\nSaved %d profile(s) in %s", len(result.ProfileNames), args.profilesFile) + if result.ProfilesCreated > 0 || result.ProfilesUpdated > 0 { + _, _ = fmt.Fprintf(r.out, " (%d created, %d updated)", result.ProfilesCreated, result.ProfilesUpdated) + } + _, _ = fmt.Fprintln(r.out) return 0 } -func printWorkstationSetupPlan(out io.Writer, plan *cloudstic.WorkstationSetupPlan) { - _, _ = fmt.Fprintln(out, "Workstation setup plan (dry-run)") +func printWorkstationSetupPlan(out io.Writer, plan *cloudstic.WorkstationSetupPlan, dryRun bool) { + if dryRun { + _, _ = fmt.Fprintln(out, "Workstation setup plan (dry-run)") + } else { + _, _ = fmt.Fprintln(out, "Workstation setup plan") + } _, _ = fmt.Fprintf(out, "Host: %s\n", plan.Hostname) if plan.StoreRef != "" { _, _ = fmt.Fprintf(out, "Store: %s (%s)\n", plan.StoreRef, plan.StoreAction) diff --git a/cmd/cloudstic/cmd_setup_test.go b/cmd/cloudstic/cmd_setup_test.go index a45fc94..09f12e2 100644 --- a/cmd/cloudstic/cmd_setup_test.go +++ b/cmd/cloudstic/cmd_setup_test.go @@ -64,16 +64,54 @@ func TestRunSetupWorkstation_JSON(t *testing.T) { } } -func TestRunSetupWorkstation_RequiresDryRun(t *testing.T) { +func TestRunSetupWorkstation_ApplyYes(t *testing.T) { + client := &stubClient{ + setupPlan: &cloudstic.WorkstationSetupPlan{ + Hostname: "testbox", + StoreRef: "primary", + StoreAction: "use-existing", + Profiles: []cloudstic.WorkstationProfileDraft{ + {Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}, Action: "create"}, + }, + }, + } + profilesPath := filepath.Join(t.TempDir(), "profiles.yaml") + if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{ + Version: 1, + Stores: map[string]cloudstic.ProfileStore{"primary": {URI: "local:/repo"}}, + }); err != nil { + t.Fatalf("SaveProfilesFile: %v", err) + } + + osArgs := os.Args + t.Cleanup(func() { os.Args = osArgs }) + os.Args = []string{"cloudstic", "setup", "workstation", "-yes", "-profiles-file", profilesPath} + + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut, client: client, noPrompt: true} + if code := r.runSetup(context.Background()); code != 0 { + t.Fatalf("code=%d err=%s", code, errOut.String()) + } + cfg, err := cloudstic.LoadProfilesFile(profilesPath) + if err != nil { + t.Fatalf("LoadProfilesFile: %v", err) + } + if got := cfg.Profiles["documents"].Store; got != "primary" { + t.Fatalf("documents store = %q, want primary", got) + } +} + +func TestRunSetupWorkstation_RequiresStoreResolutionWithoutPrompt(t *testing.T) { osArgs := os.Args t.Cleanup(func() { os.Args = osArgs }) - os.Args = []string{"cloudstic", "setup", "workstation"} + os.Args = []string{"cloudstic", "setup", "workstation", "-yes"} var out strings.Builder var errOut strings.Builder - r := &runner{out: &out, errOut: &errOut, client: &stubClient{}} + r := &runner{out: &out, errOut: &errOut, client: &stubClient{}, noPrompt: true} if code := r.runSetup(context.Background()); code == 0 { - t.Fatal("expected failure without -dry-run") + t.Fatal("expected failure without store resolution") } } diff --git a/cmd/cloudstic/usage.go b/cmd/cloudstic/usage.go index ca3af3f..2cf745a 100644 --- a/cmd/cloudstic/usage.go +++ b/cmd/cloudstic/usage.go @@ -28,7 +28,7 @@ func printUsage() { {"store show", "Show one store and its configuration"}, {"store verify", "Verify one store's credentials and connectivity"}, {"source discover", "Discover local source candidates for onboarding"}, - {"setup workstation", "Preview workstation onboarding plan"}, + {"setup workstation", "Guide workstation onboarding and profile scaffolding"}, {"profile new", "Create or update a backup profile in profiles.yaml"}, {"profile list", "List stores, auth entries, and backup profiles"}, {"profile show", "Show one profile and resolved store/auth references"}, @@ -233,14 +233,14 @@ func printUsage() { t.Command("setup workstation", "") t.Flags([][2]string{ {"-dry-run", "Preview generated profiles without writing configuration"}, - {"-yes", "Accept default selections without prompting"}, + {"-yes", "Accept the proposed plan without confirmation"}, {"-profiles-file ", ui.Env("Path to profiles YAML file", "CLOUDSTIC_PROFILES_FILE")}, {"-store-ref ", "Existing store reference to attach to generated profiles"}, {"-json", "Write onboarding plan as JSON"}, }) t.Note( - " Build a workstation onboarding plan using OS-aware folder suggestions and portable-drive discovery.", - " Only dry-run preview is implemented currently; no configuration is written.", + " Build a workstation onboarding flow using OS-aware folder suggestions and portable-drive discovery.", + " Interactive by default. Use -dry-run to preview without writing, or -yes to save without confirmation.", ) t.Blank() diff --git a/docs/user-guide.md b/docs/user-guide.md index 2de1ba8..1349542 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -543,10 +543,24 @@ considered portable. ### setup -Preview a workstation onboarding plan without writing configuration. +Guide workstation onboarding and profile scaffolding. #### setup workstation +Interactive onboarding flow: + +```bash +cloudstic setup workstation +``` + +Accept the proposed plan without confirmation: + +```bash +cloudstic setup workstation -yes -store-ref my-s3 +``` + +Preview only: + ```bash cloudstic setup workstation -dry-run ``` @@ -564,8 +578,9 @@ cloudstic setup workstation -dry-run -json ``` The preview uses OS-aware local folder suggestions and portable-drive discovery -to generate a review-first profile plan. At this stage only dry-run mode is -implemented; the command does not write `profiles.yaml`. +to generate a review-first profile plan. The command is interactive by default. +Use `-dry-run` for a side-effect-free preview or `-yes` to accept the proposed +plan without a confirmation prompt. --- diff --git a/internal/engine/workstation_setup.go b/internal/engine/workstation_setup.go index 510acff..6128e61 100644 --- a/internal/engine/workstation_setup.go +++ b/internal/engine/workstation_setup.go @@ -60,6 +60,12 @@ type WorkstationCoverageSummary struct { Warnings []string `json:"warnings,omitempty"` } +type WorkstationApplyResult struct { + ProfilesCreated int `json:"profiles_created"` + ProfilesUpdated int `json:"profiles_updated"` + ProfileNames []string `json:"profile_names,omitempty"` +} + type WorkstationSetupPlan struct { Hostname string `json:"hostname"` StoreRef string `json:"store_ref,omitempty"` @@ -157,6 +163,42 @@ func PlanWorkstationSetup(ctx context.Context, opts ...WorkstationSetupOption) ( return plan, nil } +func ApplyWorkstationSetupPlan(cfg *ProfilesConfig, plan *WorkstationSetupPlan) (*WorkstationApplyResult, error) { + if plan == nil { + return nil, fmt.Errorf("workstation setup plan is required") + } + cfg = normalizeProfilesConfig(cfg) + + result := &WorkstationApplyResult{ + ProfileNames: make([]string, 0, len(plan.Profiles)), + } + + for _, draft := range plan.Profiles { + if strings.TrimSpace(draft.Name) == "" { + return nil, fmt.Errorf("workstation setup plan contains a draft with no profile name") + } + if strings.TrimSpace(draft.SourceURI) == "" { + return nil, fmt.Errorf("workstation setup plan contains an empty source URI for profile %q", draft.Name) + } + + if _, ok := cfg.Profiles[draft.Name]; ok { + result.ProfilesUpdated++ + } else { + result.ProfilesCreated++ + } + + cfg.Profiles[draft.Name] = BackupProfile{ + Source: draft.SourceURI, + Store: draft.StoreRef, + Tags: slices.Clone(draft.Tags), + } + result.ProfileNames = append(result.ProfileNames, draft.Name) + } + + slices.Sort(result.ProfileNames) + return result, nil +} + func discoverWorkstationFolders() ([]WorkstationFolderCandidate, []string, error) { home, err := workstationUserHomeDirFunc() if err != nil { diff --git a/internal/engine/workstation_setup_test.go b/internal/engine/workstation_setup_test.go index 330af70..cfb8aa8 100644 --- a/internal/engine/workstation_setup_test.go +++ b/internal/engine/workstation_setup_test.go @@ -121,6 +121,48 @@ func TestPlanWorkstationSetup_ErrorPaths(t *testing.T) { } } +func TestApplyWorkstationSetupPlan(t *testing.T) { + cfg := &ProfilesConfig{ + Profiles: map[string]BackupProfile{ + "documents": {Source: "local:/old"}, + }, + } + result, err := ApplyWorkstationSetupPlan(cfg, &WorkstationSetupPlan{ + Profiles: []WorkstationProfileDraft{ + {Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}}, + {Name: "archive", SourceURI: "local:/Volumes/Archive", StoreRef: "primary", Tags: []string{"portable", "workstation"}}, + }, + }) + if err != nil { + t.Fatalf("ApplyWorkstationSetupPlan: %v", err) + } + if result.ProfilesCreated != 1 || result.ProfilesUpdated != 1 { + t.Fatalf("unexpected counts: %#v", result) + } + if got := cfg.Profiles["archive"].Store; got != "primary" { + t.Fatalf("archive store = %q, want primary", got) + } + if got := cfg.Profiles["documents"].Source; got != "local:/Users/test/Documents" { + t.Fatalf("documents source = %q", got) + } +} + +func TestApplyWorkstationSetupPlan_Errors(t *testing.T) { + if _, err := ApplyWorkstationSetupPlan(nil, nil); err == nil { + t.Fatal("expected nil plan error") + } + if _, err := ApplyWorkstationSetupPlan(nil, &WorkstationSetupPlan{ + Profiles: []WorkstationProfileDraft{{Name: "", SourceURI: "local:/tmp"}}, + }); err == nil { + t.Fatal("expected missing name error") + } + if _, err := ApplyWorkstationSetupPlan(nil, &WorkstationSetupPlan{ + Profiles: []WorkstationProfileDraft{{Name: "docs", SourceURI: ""}}, + }); err == nil { + t.Fatal("expected missing source error") + } +} + func stubWorkstationSetupEnv(t *testing.T) func() { t.Helper() oldDiscover := workstationDiscoverSourcesFunc From 38750c6b3721d195af500e9fdced8fd4504a1579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Thu, 2 Apr 2026 19:16:33 +0200 Subject: [PATCH 2/2] refactor: keep workstation apply internal --- client.go | 5 ----- client_test.go | 15 --------------- cmd/cloudstic/cmd_setup.go | 3 ++- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/client.go b/client.go index 60e1ccd..50d909f 100644 --- a/client.go +++ b/client.go @@ -317,7 +317,6 @@ type WorkstationSetupOption = engine.WorkstationSetupOption type WorkstationProfileDraft = engine.WorkstationProfileDraft type WorkstationFolderCandidate = engine.WorkstationFolderCandidate type WorkstationCoverageSummary = engine.WorkstationCoverageSummary -type WorkstationApplyResult = engine.WorkstationApplyResult var ( WithVerbose = engine.WithVerbose @@ -354,10 +353,6 @@ func (c *Client) PlanWorkstationSetup(ctx context.Context, opts ...WorkstationSe return engine.PlanWorkstationSetup(ctx, opts...) } -func ApplyWorkstationSetupPlan(cfg *ProfilesConfig, plan *WorkstationSetupPlan) (*WorkstationApplyResult, error) { - return engine.ApplyWorkstationSetupPlan(cfg, plan) -} - // LoadProfilesFile parses a backup profiles YAML file. func LoadProfilesFile(path string) (*ProfilesConfig, error) { return engine.LoadProfilesFile(path) diff --git a/client_test.go b/client_test.go index 391ae85..dd45ca2 100644 --- a/client_test.go +++ b/client_test.go @@ -230,21 +230,6 @@ func TestClientPlanWorkstationSetup(t *testing.T) { } } -func TestApplyWorkstationSetupPlan(t *testing.T) { - cfg := &ProfilesConfig{} - result, err := ApplyWorkstationSetupPlan(cfg, &WorkstationSetupPlan{ - Profiles: []WorkstationProfileDraft{ - {Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}}, - }, - }) - if err != nil { - t.Fatalf("ApplyWorkstationSetupPlan: %v", err) - } - if result.ProfilesCreated != 1 || cfg.Profiles["documents"].Store != "primary" { - t.Fatalf("unexpected apply result: %#v %#v", result, cfg) - } -} - // --------------------------------------------------------------------------- // Cat // --------------------------------------------------------------------------- diff --git a/cmd/cloudstic/cmd_setup.go b/cmd/cloudstic/cmd_setup.go index 398b60e..df52409 100644 --- a/cmd/cloudstic/cmd_setup.go +++ b/cmd/cloudstic/cmd_setup.go @@ -10,6 +10,7 @@ import ( "strings" cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/engine" "github.com/jedib0t/go-pretty/v6/table" ) @@ -152,7 +153,7 @@ func (r *runner) runSetupWorkstation(ctx context.Context) int { } } - result, err := cloudstic.ApplyWorkstationSetupPlan(cfg, plan) + result, err := engine.ApplyWorkstationSetupPlan(cfg, (*engine.WorkstationSetupPlan)(plan)) if err != nil { return r.fail("Failed to apply workstation setup plan: %v", err) }