From 37b6ff538b49b42831fc09688b466d24639b26c4 Mon Sep 17 00:00:00 2001 From: Tink bot Date: Tue, 26 May 2026 22:39:18 +0200 Subject: [PATCH] feat(veans): orchestrate init bootstrap from probe to config write --- veans/internal/bootstrap/bootstrap.go | 413 ++++++++++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 veans/internal/bootstrap/bootstrap.go diff --git a/veans/internal/bootstrap/bootstrap.go b/veans/internal/bootstrap/bootstrap.go new file mode 100644 index 000000000..4f957410d --- /dev/null +++ b/veans/internal/bootstrap/bootstrap.go @@ -0,0 +1,413 @@ +// Package bootstrap orchestrates `veans init`. It chains together the steps +// outlined in the plan: probe /info, acquire the human's transient token, +// pick or create a project, designate a Kanban view, bootstrap canonical +// buckets, create the bot user, share the project with the bot, mint the +// bot's API token, and write .veans.yml. +// +// The flow is split into small functions so e2e tests can drive it with +// scripted answers without going through the cobra command surface. +package bootstrap + +import ( + "context" + "errors" + "fmt" + "io" + "sort" + "strconv" + "strings" + + "code.vikunja.io/veans/internal/auth" + "code.vikunja.io/veans/internal/client" + "code.vikunja.io/veans/internal/config" + "code.vikunja.io/veans/internal/credentials" + "code.vikunja.io/veans/internal/output" + "code.vikunja.io/veans/internal/status" +) + +// Options configures Init. All fields are optional unless noted; missing +// values are filled in interactively from Prompter. +type Options struct { + // ConfigPath is where .veans.yml will be written. Required. + ConfigPath string + + // Server is the Vikunja base URL (e.g. https://vikunja.example.com). + // If empty, the prompter asks. + Server string + + // HumanToken short-circuits POST /login when set. + HumanToken string + // HumanUsername / HumanPassword are forwarded to auth.LoginOptions. + HumanUsername string + HumanPassword string + HumanTOTP string + + // BotUsername overrides the bot- default. The "bot-" prefix is + // auto-prepended if missing — Vikunja will reject otherwise. + BotUsername string + + // ProjectID, when non-zero, skips the interactive project picker. + ProjectID int64 + + // ViewID, when non-zero, skips the interactive view picker. + ViewID int64 + + // Bucket bootstrap behavior: + // AutoApproveBuckets — skip the prompt, create missing canonical buckets. + // SkipBucketBootstrap — neither prompt nor create. + AutoApproveBuckets bool + SkipBucketBootstrap bool + + // Store and Prompter are dependency-injected for testing. + Store credentials.Store + Prompter auth.Prompter + + // Out is where progress is written. + Out io.Writer + + // RepoRoot, if empty, is detected via git rev-parse from cwd. + RepoRoot string +} + +// Result is returned on success. The caller (cobra command) prints +// hook snippets and the bot username for the user. +type Result struct { + Config *config.Config + Info *client.Info + BotUser *client.BotUser + Token *client.APIToken +} + +// Init runs the full onboarding flow. Steps are deliberately sequential and +// each prints a one-line progress note to opts.Out; failures are wrapped +// with output.Error codes so cobra's error handler renders them cleanly. +func Init(ctx context.Context, opts *Options) (*Result, error) { + if opts == nil { + opts = &Options{} + } + if opts.Out == nil { + opts = &Options{} + } + if opts.Out == nil { + opts = &Options{Out: io.Discard} + } + if opts.ConfigPath == "" { + return nil, output.New(output.CodeValidation, "ConfigPath is required") + } + + prompter := opts.Prompter + if prompter == nil { + prompter = auth.NewStdPrompter() + } + store := opts.Store + if store == nil { + store = credentials.Default() + } + + // 1. Repo root + suggested bot username. + repoRoot := opts.RepoRoot + if repoRoot == "" { + var err error + repoRoot, err = config.RepoRoot("") + if err != nil { + return nil, output.Wrap(output.CodeUnknown, err, "detect repo root: %v", err) + } + } + suggested := config.SuggestedBotUsername(repoRoot) + botUsername := normalizeBotUsername(opts.BotUsername, suggested) + progress(opts.Out, "Bot username will be %q", botUsername) + + // 2. Server URL. + if opts.Server == "" { + v, err := prompter.ReadLine("Vikunja server URL: ") + if err != nil { + return nil, err + } + opts.Server = strings.TrimSpace(v) + } + opts.Server = strings.TrimRight(opts.Server, "/") + if opts.Server == "" { + return nil, output.New(output.CodeValidation, "server URL is required") + } + + // 3. Probe /info. + human := client.New(opts.Server, "") + info, err := human.Info(ctx) + if err != nil { + return nil, output.Wrap(output.CodeUnknown, err, "GET /info on %s: %v", opts.Server, err) + } + progress(opts.Out, "Connected to Vikunja %s", info.Version) + + // 4. Acquire human JWT (transient — used until step 11). + tok, err := auth.AcquireHumanToken(ctx, human, auth.LoginOptions{ + Token: opts.HumanToken, + Username: opts.HumanUsername, + Password: opts.HumanPassword, + TOTP: opts.HumanTOTP, + }, prompter) + if err != nil { + return nil, err + } + human.Token = tok + + // 5. Pick (or accept passed) project. + project, err := pickProject(ctx, human, opts.ProjectID, prompter, opts.Out) + if err != nil { + return nil, err + } + progress(opts.Out, "Using project #%d %q (identifier=%q)", project.ID, project.Title, project.Identifier) + + // 6. Pick (or accept passed) Kanban view. + view, err := pickKanbanView(ctx, human, project.ID, opts.ViewID, prompter, opts.Out) + if err != nil { + return nil, err + } + progress(opts.Out, "Using view #%d %q", view.ID, view.Title) + + // 7. Bucket bootstrap (with strict-with-override prompt). + buckets, err := bootstrapBuckets(ctx, human, project.ID, view.ID, opts, prompter) + if err != nil { + return nil, err + } + + // 8. Create the bot user. + bot, err := human.CreateBotUser(ctx, botUsername, "veans bot for "+project.Title) + if err != nil { + return nil, err + } + progress(opts.Out, "Created bot user %q (id=%d)", bot.Username, bot.ID) + + // 9. Share the project with the bot. + if _, err := human.ShareProjectWithUser(ctx, project.ID, &client.ProjectUser{ + Username: bot.Username, + Permission: client.PermissionReadWrite, + }); err != nil { + return nil, output.Wrap(output.CodeUnknown, err, "share project with bot: %v", err) + } + progress(opts.Out, "Shared project with %q (read+write)", bot.Username) + + // 10. Discover available API permission scopes, mint the bot's token. + routes, err := human.Routes(ctx) + if err != nil { + return nil, output.Wrap(output.CodeUnknown, err, "fetch /routes: %v", err) + } + perms := client.PermissionsForBot(routes) + if len(perms) == 0 { + return nil, output.New(output.CodeUnknown, "no API token permissions available — Vikunja /routes returned no matching groups") + } + mintedToken, err := human.CreateToken(ctx, &client.APIToken{ + Title: "veans for " + project.Title, + Permissions: perms, + ExpiresAt: client.FarFuture, + OwnerID: bot.ID, + }) + if err != nil { + return nil, output.Wrap(output.CodeUnknown, err, "mint bot token: %v", err) + } + if mintedToken.Token == "" { + return nil, output.New(output.CodeUnknown, "PUT /tokens did not return a token plaintext — cannot continue") + } + + // 11. Persist credentials. Discard human JWT immediately after. + if err := store.Set(opts.Server, bot.Username, mintedToken.Token); err != nil { + return nil, output.Wrap(output.CodeUnknown, err, "store bot token: %v", err) + } + human.Token = "" + + // 12. Write .veans.yml. + cfg := &config.Config{ + Server: opts.Server, + ProjectID: project.ID, + ProjectIdentifier: project.Identifier, + ViewID: view.ID, + Buckets: buckets, + Bot: config.Bot{ + Username: bot.Username, + UserID: bot.ID, + }, + } + if err := cfg.SaveAs(opts.ConfigPath); err != nil { + return nil, output.Wrap(output.CodeUnknown, err, "write %s: %v", opts.ConfigPath, err) + } + progress(opts.Out, "Wrote %s", opts.ConfigPath) + + return &Result{Config: cfg, Info: info, BotUser: bot, Token: mintedToken}, nil +} + +func normalizeBotUsername(override, suggested string) string { + if override == "" { + return suggested + } + if !strings.HasPrefix(override, "bot-") { + return "bot-" + override + } + return override +} + +func pickProject(ctx context.Context, c *client.Client, id int64, p auth.Prompter, out io.Writer) (*client.Project, error) { + if id != 0 { + return c.GetProject(ctx, id) + } + projects, err := c.ListProjects(ctx) + if err != nil { + return nil, err + } + // Filter out archived projects to keep the list short. + var active []*client.Project + for _, pr := range projects { + if pr.IsArchived { + continue + } + active = append(active, pr) + } + if len(active) == 0 { + return nil, output.New(output.CodeNotFound, "no projects visible to this user — create one in the Vikunja UI first") + } + sort.Slice(active, func(i, j int) bool { return active[i].Title < active[j].Title }) + + fmt.Fprintln(out, "Available projects:") + for i, pr := range active { + ident := pr.Identifier + if ident == "" { + ident = "(no identifier)" + } + fmt.Fprintf(out, " [%d] #%d %s — %s\n", i+1, pr.ID, pr.Title, ident) + } + choice, err := p.ReadLine("Pick a project [1]: ") + if err != nil { + return nil, err + } + choice = strings.TrimSpace(choice) + idx := 1 + if choice != "" { + v, err := strconv.Atoi(choice) + if err != nil || v < 1 || v > len(active) { + return nil, output.New(output.CodeValidation, "invalid project choice %q", choice) + } + idx = v + } + return active[idx-1], nil +} + +func pickKanbanView(ctx context.Context, c *client.Client, projectID int64, viewID int64, p auth.Prompter, out io.Writer) (*client.ProjectView, error) { + views, err := c.ListProjectViews(ctx, projectID) + if err != nil { + return nil, err + } + var kanban []*client.ProjectView + for _, v := range views { + if v.ViewKind == client.ViewKindKanban { + kanban = append(kanban, v) + } + } + if len(kanban) == 0 { + return nil, output.New(output.CodeNotFound, "no Kanban views on this project — create one in the Vikunja UI first") + } + if viewID != 0 { + for _, v := range kanban { + if v.ID == viewID { + return v, nil + } + } + return nil, output.New(output.CodeNotFound, "view %d is not a Kanban view on this project", viewID) + } + if len(kanban) == 1 { + return kanban[0], nil + } + fmt.Fprintln(out, "Available Kanban views:") + for i, v := range kanban { + fmt.Fprintf(out, " [%d] #%d %s\n", i+1, v.ID, v.Title) + } + choice, err := p.ReadLine("Pick a view [1]: ") + if err != nil { + return nil, err + } + choice = strings.TrimSpace(choice) + idx := 1 + if choice != "" { + v, err := strconv.Atoi(choice) + if err != nil || v < 1 || v > len(kanban) { + return nil, output.New(output.CodeValidation, "invalid view choice %q", choice) + } + idx = v + } + return kanban[idx-1], nil +} + +func bootstrapBuckets(ctx context.Context, c *client.Client, projectID, viewID int64, opts *Options, p auth.Prompter) (config.Buckets, error) { + existing, err := c.ListBuckets(ctx, projectID, viewID) + if err != nil { + return config.Buckets{}, err + } + byTitle := map[string]*client.Bucket{} + for _, b := range existing { + byTitle[b.Title] = b + } + + missing := []string{} + for _, t := range status.CanonicalBucketTitles { + if _, ok := byTitle[t]; !ok { + missing = append(missing, t) + } + } + + if len(missing) > 0 && !opts.SkipBucketBootstrap { + approve := opts.AutoApproveBuckets + if !approve { + fmt.Fprintf(opts.Out, "Missing canonical buckets: %s\n", strings.Join(missing, ", ")) + ans, err := p.ReadLine("Bootstrap missing buckets? [Y/n/abort]: ") + if err != nil { + return config.Buckets{}, err + } + ans = strings.ToLower(strings.TrimSpace(ans)) + switch ans { + case "", "y", "yes": + approve = true + case "n", "no": + approve = false + case "a", "abort": + return config.Buckets{}, output.New(output.CodeValidation, "user aborted bucket bootstrap") + } + } + if approve { + for _, title := range missing { + b, err := c.CreateBucket(ctx, projectID, viewID, &client.Bucket{Title: title}) + if err != nil { + return config.Buckets{}, output.Wrap(output.CodeUnknown, err, "create bucket %q: %v", title, err) + } + byTitle[title] = b + progress(opts.Out, "Created bucket %q (id=%d)", title, b.ID) + } + } + } + + out := config.Buckets{ + Todo: lookupBucket(byTitle, "Todo"), + InProgress: lookupBucket(byTitle, "In Progress"), + InReview: lookupBucket(byTitle, "In Review"), + Done: lookupBucket(byTitle, "Done"), + Scrapped: lookupBucket(byTitle, "Scrapped"), + } + if out.Todo == 0 || out.InProgress == 0 || out.InReview == 0 || out.Done == 0 || out.Scrapped == 0 { + return config.Buckets{}, output.New(output.CodeValidation, + "canonical buckets missing — re-run with bucket bootstrap approved or create them manually") + } + return out, nil +} + +func lookupBucket(by map[string]*client.Bucket, title string) int64 { + if b, ok := by[title]; ok { + return b.ID + } + return 0 +} + +func progress(w io.Writer, format string, args ...any) { + if w == nil { + return + } + fmt.Fprintf(w, " → "+format+"\n", args...) +} + +// silence the unused-import linter when errors isn't used elsewhere. +var _ = errors.New