// Vikunja is a to-do list application to facilitate your life. // Copyright 2018-present Vikunja and contributors. All rights reserved. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // 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" "os" "regexp" "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 all auth when set. HumanToken string // HumanUsePassword forces POST /login instead of the default OAuth flow. HumanUsePassword bool // HumanUsername / HumanPassword feed POST /login (used when set). 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 // Agent hook installation. If neither flag is set, the user is prompted // per-agent at the end of init. NoHooks skips the offering entirely // and falls back to printing the snippets. InstallClaudeCode bool InstallOpenCode bool ClaudeCodeFlagSet bool OpenCodeFlagSet bool NoHooks bool // Out is where progress is written. Out io.Writer // RepoRoot, if empty, is detected via git rev-parse from cwd. RepoRoot string // Prompter is the seam tests use to script prompt answers. Defaults // to auth.NewStdPrompter() (reads stdin, writes prompts to stderr) // when nil. Prompter auth.Prompter // OverwriteExistingConfig, when true, allows Init to clobber an // existing .veans.yml without prompting. Mostly for tests; the // interactive flow asks the user. OverwriteExistingConfig bool } // Result is returned on success — just the bits printPostInitSummary reads. type Result struct { Config *config.Config BotUser *client.BotUser AgentChoices AgentHookChoice } // 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.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 := credentials.Default() if err := confirmOverwriteExistingConfig(opts, prompter); err != nil { return nil, err } // Validate the bot-username override (if any) against server-side // rules now, so we fail fast before steps 4–7 do real work that // we'd then have to undo. SuggestedBotUsername's output is // always valid, so we only need to validate user input. if opts.BotUsername != "" { if err := validateBotUsername(normalizeBotUsername(opts.BotUsername, "")); err != nil { return nil, err } } // 1. Repo root + suggested bot username. repoRoot := opts.RepoRoot if repoRoot == "" { var err error repoRoot, err = config.RepoRoot(ctx, "") 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) } // 3. Discover the actual API URL: the user might have typed bare // "vikunja.example.com", or pasted the URL with /api/v1 already in // it, or be on a default-port localhost install. DiscoverServer // probes the plausible variants and returns the canonical base. canonical, info, err := client.DiscoverServer(ctx, opts.Server) if err != nil { return nil, err } opts.Server = canonical human := client.New(canonical, "") progress(opts.Out, "Connected to Vikunja %s at %s", info.Version, canonical) // 4. Acquire human JWT (transient — used until step 11). Default is the // OAuth flow; --token / --use-password / --username+--password override. tok, err := auth.AcquireHumanToken(ctx, human, auth.LoginOptions{ Token: opts.HumanToken, UsePassword: opts.HumanUsePassword, Username: opts.HumanUsername, Password: opts.HumanPassword, TOTP: opts.HumanTOTP, Out: opts.Out, }, 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. Resolve the bot user: reuse one we already own if the name is // taken by us, prompt for a fresh name (with a petname suggestion) // if the name is taken by someone else, otherwise create new. bot, err := resolveBotUser(ctx, human, botUsername, project.Title, prompter, opts.Out) if err != nil { return nil, err } // 9. Share the project with the bot. 409 ("user already has access") // is the expected response when reusing a bot that was set up by a // previous init run — treat it as a soft-success. _, shareErr := human.ShareProjectWithUser(ctx, project.ID, &client.ProjectUser{ Username: bot.Username, Permission: client.PermissionReadWrite, }) switch { case shareErr == nil: progress(opts.Out, "Shared project with %q (read+write)", bot.Username) case isConflictErr(shareErr): progress(opts.Out, "Project already shared with %q", bot.Username) default: return nil, output.Wrap(output.CodeUnknown, shareErr, "share project with bot: %v", shareErr) } // 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) // 13. Offer to install agent hooks. Pre-seeded from flags; the rest // is prompted unless --no-hooks. Failures here are non-fatal — the // repo is already configured; the user can install hooks by hand. choices := AgentHookChoice{ ClaudeCode: opts.InstallClaudeCode, OpenCode: opts.InstallOpenCode, } choices, err = offerAgentHooks(prompter, opts.Out, choices, opts.ClaudeCodeFlagSet, opts.OpenCodeFlagSet, opts.NoHooks) if err != nil { return nil, err } if err := installAgentHooks(repoRoot, choices, opts.Out); err != nil { // Log but don't abort — the repo is configured. fmt.Fprintf(opts.Out, " ! hook install failed: %v (you can paste the snippets manually)\n", err) } return &Result{ Config: cfg, BotUser: bot, AgentChoices: choices, }, nil } // confirmOverwriteExistingConfig refuses to silently clobber an existing // .veans.yml. The bot token in the credentials store is keyed on // (server, bot-username); a blind re-init can swap the project under // the agent's feet AND stomp the previous token in the keyring. func confirmOverwriteExistingConfig(opts *Options, p auth.Prompter) error { if opts.OverwriteExistingConfig { return nil } if _, err := os.Stat(opts.ConfigPath); err != nil { // File doesn't exist (or we can't stat it — let SaveAs surface // that error later). Either way, no overwrite confirmation is // needed. return nil //nolint:nilerr // intentional: any Stat error means "no existing file to overwrite" } ans, err := p.ReadLine(fmt.Sprintf( "%s already exists. Overwrite (token + project + view get replaced)? [y/N]: ", opts.ConfigPath)) if err != nil { return output.Wrap(output.CodeUnknown, err, "read overwrite confirmation: %v", err) } switch strings.ToLower(strings.TrimSpace(ans)) { case "y", "yes": return nil } return output.New(output.CodeConflict, "refusing to overwrite %s without confirmation (delete the file to re-init)", opts.ConfigPath) } func normalizeBotUsername(override, suggested string) string { if override == "" { return suggested } if !strings.HasPrefix(override, "bot-") { return "bot-" + override } return override } // botUsernamePattern mirrors the server's username regex closely enough // to catch the rejections that would otherwise blow up steps 4–7 mid-init. // The server allows lowercase letters, digits, hyphens, underscores, and // dots; we additionally require the `bot-` prefix and forbid the // `link-share-N` shape Vikunja reserves for share-links. var botUsernamePattern = regexp.MustCompile(`^bot-[a-z0-9][a-z0-9._-]*$`) var linkShareSuffix = regexp.MustCompile(`^bot-link-share-\d+$`) // validateBotUsername mirrors the server-side rules so a bad // `--bot-username` override (or interactive prompt answer) fails fast // instead of dying with a 400 deep in step 8. func validateBotUsername(name string) error { if !botUsernamePattern.MatchString(name) { return output.New(output.CodeValidation, "invalid bot username %q: must start with `bot-` and contain only lowercase letters, digits, hyphens, underscores, and dots", name) } if linkShareSuffix.MatchString(name) { return output.New(output.CodeValidation, "invalid bot username %q: `link-share-N` is reserved by Vikunja for share-link users", name) } return nil } 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) } sort.Slice(active, func(i, j int) bool { return active[i].Title < active[j].Title }) // The "create a new project" option sits at len(active)+1 in the menu; // when the user has nothing to pick from, it's the only choice. createIdx := len(active) + 1 if len(active) == 0 { fmt.Fprintln(out, "No projects yet — let's create one.") return createProject(ctx, c, p, out) } 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) } fmt.Fprintf(out, " [%d] Create a new project\n", createIdx) 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 > createIdx { return nil, output.New(output.CodeValidation, "invalid project choice %q", choice) } idx = v } if idx == createIdx { return createProject(ctx, c, p, out) } return active[idx-1], nil } // createProject prompts for the new project's title and identifier and // PUTs it. Title is required; identifier is optional (Vikunja caps it at // 10 chars). The fresh project comes with the default views — including // the Kanban view pickKanbanView is about to grab. func createProject(ctx context.Context, c *client.Client, p auth.Prompter, out io.Writer) (*client.Project, error) { title, err := p.ReadLine("New project title: ") if err != nil { return nil, err } title = strings.TrimSpace(title) if title == "" { return nil, output.New(output.CodeValidation, "project title is required") } ident, err := p.ReadLine("Identifier (optional, ≤10 letters/digits, used for task IDs like PROJ-NN): ") if err != nil { return nil, err } ident = strings.TrimSpace(ident) created, err := c.CreateProject(ctx, &client.Project{Title: title, Identifier: ident}) if err != nil { return nil, output.Wrap(output.CodeUnknown, err, "create project %q: %v", title, err) } progress(out, "Created project #%d %q", created.ID, created.Title) return created, 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 } // Resolve canonical statuses to existing buckets via the alias table. // Vikunja's default Kanban view ships with "To-Do" / "Doing" / "Done"; // matching them as Todo / InProgress / Done avoids creating a parallel // set of buckets every time veans runs against a vanilla project. matched := map[status.Status]*client.Bucket{} for _, s := range status.All() { for _, b := range existing { if b == nil { continue } if status.MatchBucketTitle(s, b.Title) { matched[s] = b break } } } var missing []string for _, s := range status.All() { if _, ok := matched[s]; !ok { missing = append(missing, s.BucketTitle()) } } if len(missing) > 0 && !opts.SkipBucketBootstrap { approve := opts.AutoApproveBuckets if !approve { fmt.Fprintf(opts.Out, "Missing canonical buckets: %s\n", strings.Join(missing, ", ")) const maxUnknownAnswers = 5 prompt := "Bootstrap missing buckets? [Y/n/abort]: " unknown := 0 promptLoop: for { ans, err := p.ReadLine(prompt) if err != nil { return config.Buckets{}, err } normalized := strings.ToLower(strings.TrimSpace(ans)) switch normalized { case "", "y", "yes": approve = true break promptLoop case "n", "no": return config.Buckets{}, output.New(output.CodeValidation, "canonical buckets missing — either re-run `veans init` and answer Y to let veans bootstrap them, "+ "or create the missing buckets (%s) manually in Vikunja's UI and re-run `veans init`", strings.Join(missing, ", ")) case "a", "abort": return config.Buckets{}, output.New(output.CodeValidation, "user aborted bucket bootstrap") default: unknown++ if unknown > maxUnknownAnswers { return config.Buckets{}, output.New(output.CodeValidation, "could not understand bucket bootstrap answer after %d attempts — aborting; "+ "re-run `veans init` and answer y, n, or abort", maxUnknownAnswers) } fmt.Fprintf(opts.Out, "didn't understand %q, please answer y or n (or abort)\n", ans) } } } if approve { for _, s := range status.All() { if _, ok := matched[s]; ok { continue } title := s.BucketTitle() 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) } matched[s] = b progress(opts.Out, "Created bucket %q (id=%d)", title, b.ID) } } } for _, s := range status.All() { if b, ok := matched[s]; ok && b != nil && b.Title != s.BucketTitle() { progress(opts.Out, "Reusing existing bucket %q as %s (id=%d)", b.Title, s.BucketTitle(), b.ID) } } out := config.Buckets{ Todo: bucketID(matched, status.Todo), InProgress: bucketID(matched, status.InProgress), InReview: bucketID(matched, status.InReview), Done: bucketID(matched, status.Completed), Scrapped: bucketID(matched, status.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 — either re-run `veans init` and let veans bootstrap them, "+ "or create the missing canonical buckets (Todo / In Progress / In Review / Done / Scrapped) manually in Vikunja's UI and re-run `veans init`") } return out, nil } func bucketID(m map[status.Status]*client.Bucket, s status.Status) int64 { if b, ok := m[s]; ok && b != nil { return b.ID } return 0 } func progress(w io.Writer, format string, args ...any) { if w == nil { return } fmt.Fprintf(w, " → "+format+"\n", args...) } // isConflictErr reports whether the wrapped HTTP error is a 409 — used by // init's "share project with bot" step, which legitimately gets one when // the bot is being reused from an earlier run. func isConflictErr(err error) bool { var oe *output.Error return errors.As(err, &oe) && oe.Code == output.CodeConflict }