Files
pad/cmd/pad/init.go
T
xarmian 63d113624c fix(cli): retry password prompt on weak/mismatched passwords (BUG-1155) (#413)
* fix(cli): retry password prompt on weak/mismatched passwords during admin bootstrap (BUG-1155)

`pad auth setup` and `pad init` collected admin credentials with a single-
shot prompt: any rejection — local password mismatch, or server-side weak-
password / length error from validatePasswordStrength — bubbled up and
exited the command. The user had to re-run the whole flow (and in `pad
init`, redo configure + server-start) over a typo.

Replaces promptForAccountDetails() with promptAndBootstrap(client) which
collects email + name once, then loops the password / confirm pair (up to
5 attempts) on:

- local password mismatch
- *cli.APIError from /auth/bootstrap (covers all three messages from
  internal/server/password_strength.go: too short, too long, too weak)

Network failures and other non-API errors still bail immediately.

Both call sites — cmd/pad/main.go (auth setup) and cmd/pad/init.go (init
step 3) — now use the new helper.

* fix(cli): only retry password-strength rejections, not all API errors per Codex review (round 1)

Round 1 retried on every *cli.APIError from /auth/bootstrap, but only
password-strength rejections are fixable by re-prompting the password
pair. The server also emits validation_error for invalid email / missing
name, conflict ("Pad instance has already been initialized"), and
forbidden (non-loopback bootstrap) — re-prompting just the password for
those traps the user in a 5-attempt loop that can never succeed.

Narrows the retry gate to validation_error whose message begins with
"Password" — the three messages emitted by validatePasswordStrength
(internal/server/password_strength.go: too-short, too-long, too-weak).
All other APIError codes and message shapes now fall through to the
fail-fast branch, so the user sees the real reason and can re-run with
the right correction.
2026-05-04 17:56:20 -04:00

526 lines
17 KiB
Go

package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
pad "github.com/PerpetualSoftware/pad"
"github.com/PerpetualSoftware/pad/internal/cli"
"github.com/PerpetualSoftware/pad/internal/collections"
"github.com/PerpetualSoftware/pad/internal/config"
"github.com/PerpetualSoftware/pad/internal/models"
)
func padInitCmd() *cobra.Command {
var templateFlag string
cmd := &cobra.Command{
Use: "init [name]",
Short: "Set up Pad — configure, authenticate, and create a workspace",
Long: `Initialize Pad for this project. This smart command detects what's needed and
walks you through each step:
1. Configure connection (local server, remote, or Docker)
2. Start local server if needed
3. Create the first admin account (fresh installs)
4. Log in if not authenticated
5. Create or link a workspace for the current directory
6. Install/update the /pad skill for detected AI tools
Safe to re-run anytime — it skips steps that are already done and shows
your current status.
Examples:
pad init # Auto-detect everything, use directory name
pad init myproject # Specify workspace name
pad init --template scrum # Use scrum template for new workspace`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) (retErr error) {
// Install the SIGINT/SIGTERM handler first so the user can
// abort cleanly at any interactive prompt. Defers run in LIFO
// order: the cancellation check fires before the cleanup
// removes the signal listener, so a sentinel error propagated
// from a prompt is converted into the canonical exit before
// returning to cobra.
cleanup := installInitCancelHandler()
defer cleanup()
defer func() {
if isCancellation(retErr) {
cancelInit()
}
}()
// Validate template name up front before any state changes
if templateFlag != "" {
tmpl := collections.GetTemplate(templateFlag)
if tmpl == nil {
fmt.Fprintf(os.Stderr, "Unknown template: %s\n\n", templateFlag)
fmt.Fprintln(os.Stderr, "Available templates:")
fmt.Fprintln(os.Stderr)
printGroupedTemplates(os.Stderr)
return fmt.Errorf("unknown template %q", templateFlag)
}
}
green := color.New(color.FgGreen)
bold := color.New(color.Bold)
// Track whether we performed any actions (vs everything already set up)
actioned := false
// ── Step 1: Configuration ──────────────────────────────────
cfg := getConfig()
if !cfg.IsConfigured() {
if !canPromptForConfig() {
return fmt.Errorf("Pad is not configured. Run 'pad auth configure' first, or run 'pad init' in an interactive terminal")
}
fmt.Println("Welcome to Pad! Let's get you set up.")
fmt.Println()
fmt.Println(bold.Sprint("Step 1: Configure connection"))
fmt.Println()
if err := runConfigureFlow(cfg, configureValues{}); err != nil {
return fmt.Errorf("configure: %w", err)
}
// Reload config after saving
cfg = getConfig()
green.Print("✓ ")
fmt.Printf("Configured: %s mode", cfg.Mode)
if cfg.Mode == config.ModeLocal {
fmt.Printf(" (%s)", cfg.Addr())
} else {
fmt.Printf(" (%s)", cfg.BaseURL())
}
fmt.Println()
fmt.Println()
actioned = true
} else if urlFlag != "" && !cfg.LoadedFromFile {
// Cold-start shortcut: `pad init --url <server>` on a fresh
// machine. The flag override made IsConfigured() true, but
// nothing is persisted yet — so without saving, every
// subsequent command would also need --url. Persist now so
// this is a true one-shot configure.
if err := cfg.Save(); err != nil {
return fmt.Errorf("save config: %w", err)
}
green.Print("✓ ")
fmt.Printf("Configured: %s mode (%s)\n", cfg.Mode, cfg.BaseURL())
fmt.Println()
actioned = true
}
// ── Step 2: Ensure server is running ──────────────────────
if err := cli.EnsureServer(cfg); err != nil {
return fmt.Errorf("start server: %w", err)
}
client := cli.NewClientFromURL(cfg.BaseURL())
// ── Step 3: First-time setup (bootstrap) ──────────────────
session, err := client.CheckSession()
if err != nil {
return fmt.Errorf("failed to connect to server at %s: %w", cfg.BaseURL(), err)
}
if session.SetupRequired {
// Only local mode can bootstrap inline
if cfg.Mode != config.ModeLocal && cfg.Mode != "" {
printSetupRequiredHint(cfg)
return fmt.Errorf("this Pad instance has not been initialized yet")
}
if !canPromptForConfig() {
printSetupRequiredHint(cfg)
return fmt.Errorf("this Pad instance has not been initialized yet (run 'pad auth setup' in an interactive terminal)")
}
if actioned {
fmt.Println(bold.Sprint("Step 2: Create admin account"))
} else {
fmt.Println(bold.Sprint("Create admin account"))
}
fmt.Println()
resp, err := promptAndBootstrap(client)
if err != nil {
return err
}
if err := saveCredentials(cfg, resp); err != nil {
return err
}
green.Print("✓ ")
fmt.Printf("Admin account created — logged in as %s (%s)\n", resp.User.Name, resp.User.Email)
fmt.Println()
// Refresh client with credentials
client = cli.NewClientFromURL(cfg.BaseURL())
actioned = true
}
// ── Step 4: Authentication ────────────────────────────────
if !session.Authenticated && !session.SetupRequired {
// Check if we have saved credentials that still work
creds, _ := cli.LoadCredentials()
if creds != nil && creds.Token != "" {
client.SetAuthToken(creds.Token)
user, err := client.GetCurrentUser()
if err == nil && user != nil {
// Credentials are still valid, we're good
goto authenticated
}
}
if actioned {
fmt.Println(bold.Sprint("Step 3: Log in"))
} else {
fmt.Println("Log in to continue.")
}
fmt.Println()
if err := doBrowserLogin(client, cfg); err != nil {
return fmt.Errorf("login: %w", err)
}
fmt.Println()
client = cli.NewClientFromURL(cfg.BaseURL())
actioned = true
}
authenticated:
// ── Step 5: Workspace ─────────────────────────────────────
cwd, _ := os.Getwd()
var wsName string
if len(args) > 0 {
wsName = args[0]
} else {
wsName = filepath.Base(cwd)
}
// When both a positional name and --workspace <slug> are passed,
// the slug wins (it unambiguously identifies an existing workspace
// on the server). Warn the user so the silent override is visible.
if workspaceFlag != "" && len(args) > 0 {
fmt.Fprintf(os.Stderr, "Note: --workspace %q overrides positional name %q.\n", workspaceFlag, args[0])
}
ws, newlyCreated, createdTemplate, err := ensureWorkspace(client, cfg, cwd, wsName, workspaceFlag, templateFlag)
if err != nil {
return err
}
if newlyCreated {
actioned = true
}
// ── Step 6: Skill files ───────────────────────────────────
skillResults := ensureSkills()
if skillResults.installed > 0 || skillResults.updated > 0 {
actioned = true
}
// ── Status summary ────────────────────────────────────────
if !actioned {
printInitStatus(client, cfg, ws, skillResults)
} else if newlyCreated {
printOnboardingHints(cfg, createdTemplate)
}
return nil
},
}
cmd.Flags().StringVar(&templateFlag, "template", "", "workspace template (omit for interactive picker; run 'pad workspace init --list-templates' to see all)")
return cmd
}
// ── Shared helpers ────────────────────────────────────────────────────────────
// ensureWorkspace checks if the current directory is linked to a workspace.
// If not, it creates or links one. Returns the workspace, whether it was newly
// created, the resolved template name (empty when no workspace was created
// in this call — link paths reuse an existing workspace's settings), and
// any error.
//
// When wsSlug is non-empty, the caller has explicitly identified a workspace
// by slug (typically `pad init --workspace <slug>`). In that mode we will
// ONLY attach to a workspace with that exact slug — never silently fall
// through to "create a new workspace named after the slug." This is the
// keystone behavior for the web-first onboarding flow (IDEA-750/PLAN-859).
func ensureWorkspace(client *cli.Client, cfg *config.Config, cwd, name, wsSlug, templateFlag string) (*models.Workspace, bool, string, error) {
green := color.New(color.FgGreen)
bold := color.New(color.Bold)
dim := color.New(color.Faint)
// Always check for an existing CWD link first — never blindly clobber.
existingSlug, _ := cli.DetectWorkspace("")
// ── Slug-driven path ──────────────────────────────────────
// Caller said "use this exact workspace by slug." Look it up; never
// create. Refuse to relink a directory already pinned to a different
// workspace.
if wsSlug != "" {
if existingSlug != "" && existingSlug != wsSlug {
return nil, false, "", fmt.Errorf(
"this directory is already linked to workspace %q; refusing to relink to %q.\n"+
"Remove or edit the existing .pad.toml, or run from a different directory",
existingSlug, wsSlug)
}
ws, err := client.GetWorkspace(wsSlug)
if err != nil {
var apiErr *cli.APIError
if errors.As(err, &apiErr) && apiErr.Code == "not_found" {
return nil, false, "", fmt.Errorf(
"workspace %q not found on %s.\n"+
"Check the slug, or run 'pad workspace list' to see available workspaces",
wsSlug, cfg.BaseURL())
}
return nil, false, "", fmt.Errorf(
"look up workspace %q on %s: %w", wsSlug, cfg.BaseURL(), err)
}
// Already linked to this exact slug — idempotent, no-op.
if existingSlug == wsSlug {
return ws, false, "", nil
}
if err := cli.WriteWorkspaceLink(cwd, ws.Slug); err != nil {
return nil, false, "", fmt.Errorf("write .pad.toml: %w", err)
}
green.Print("✓ ")
fmt.Printf("Linked to existing workspace %s %s\n",
bold.Sprint(ws.Name),
dim.Sprintf("(slug: %s)", ws.Slug))
return ws, false, "", nil
}
// ── Name-driven path (legacy interactive behavior) ────────
if existingSlug != "" {
ws, err := client.GetWorkspace(existingSlug)
if err == nil && ws != nil {
return ws, false, "", nil
}
// Linked but workspace doesn't exist on server — fall through to create/link.
}
// Check if a workspace with this name already exists
var ws *models.Workspace
workspaces, err := client.ListWorkspaces()
if err == nil {
for i := range workspaces {
if strings.EqualFold(workspaces[i].Name, name) {
ws = &workspaces[i]
break
}
}
}
if ws != nil {
if err := cli.WriteWorkspaceLink(cwd, ws.Slug); err != nil {
return nil, false, "", fmt.Errorf("write .pad.toml: %w", err)
}
green.Print("✓ ")
fmt.Printf("Linked to existing workspace %s %s\n",
bold.Sprint(ws.Name),
dim.Sprintf("(slug: %s)", ws.Slug))
return ws, false, "", nil
}
// Create new workspace. When the caller didn't pass --template:
// - If stdin/stdout are TTYs, prompt interactively with the grouped
// template picker.
// - Otherwise fall back to the "startup" default so scripts and
// non-interactive runs get the curated starter pack.
// Tests and other API callers that want an empty workspace can still
// POST with Template="" directly.
effectiveTemplate := templateFlag
if effectiveTemplate == "" {
if canPromptForTemplate() {
picked, perr := pickTemplateInteractive(os.Stdin, os.Stdout)
if perr != nil {
return nil, false, "", perr
}
effectiveTemplate = picked
} else {
effectiveTemplate = defaultTemplateName
}
}
ws, err = client.CreateWorkspace(models.WorkspaceCreate{
Name: name,
Template: effectiveTemplate,
})
if err != nil {
return nil, false, "", fmt.Errorf("create workspace: %w", err)
}
if err := cli.WriteWorkspaceLink(cwd, ws.Slug); err != nil {
return nil, false, "", fmt.Errorf("write .pad.toml: %w", err)
}
tmplMsg := ""
if templateFlag != "" && templateFlag != "startup" {
tmplMsg = dim.Sprintf(" with %s template", templateFlag)
}
green.Print("✓ ")
fmt.Printf("Created workspace %s %s%s\n",
bold.Sprint(ws.Name),
dim.Sprintf("(slug: %s)", ws.Slug),
tmplMsg)
fmt.Printf(" Linked to %s\n", bold.Sprint(cwd))
return ws, true, effectiveTemplate, nil
}
// skillResult tracks what ensureSkills did.
type skillResult struct {
installed int
updated int
upToDate int
tools []string // labels of all detected+installed tools
}
// ensureSkills detects AI tools, installs missing skill files, and updates
// outdated ones. Returns a summary of what it did.
func ensureSkills() skillResult {
green := color.New(color.FgGreen)
dim := color.New(color.Faint)
result := skillResult{}
detected := cli.DetectTools()
// Always include Claude if not already detected
hasClaude := false
for _, t := range detected {
if t.Name == "claude" {
hasClaude = true
break
}
}
if !hasClaude {
detected = append([]cli.AgentTool{cli.SupportedTools[0]}, detected...)
}
for _, tool := range detected {
expected := cli.FormatForTool(tool, pad.PadSkill)
if cli.ToolInstalled(tool) {
// Check if content is up to date
path := cli.ToolSkillPath(tool)
existing, err := os.ReadFile(path)
if err == nil && bytes.Equal(existing, expected) {
result.upToDate++
result.tools = append(result.tools, tool.Label)
continue
}
// Outdated — update silently
path, err = cli.InstallForTool(tool, expected)
if err != nil {
continue
}
green.Print("✓ ")
fmt.Printf("Updated /pad skill for %s %s\n", tool.Label, dim.Sprint("→ "+path))
recordInstallation(tool.Name, path)
result.updated++
result.tools = append(result.tools, tool.Label)
} else {
// Not installed — install. In interactive mode this proceeds
// without prompting because it's part of the init flow and the
// user already opted in.
path, err := cli.InstallForTool(tool, expected)
if err != nil {
continue
}
green.Print("✓ ")
fmt.Printf("Installed /pad skill for %s %s\n", tool.Label, dim.Sprint("→ "+path))
recordInstallation(tool.Name, path)
result.installed++
result.tools = append(result.tools, tool.Label)
}
}
return result
}
// printInitStatus prints a clean status summary when everything is already configured.
func printInitStatus(client *cli.Client, cfg *config.Config, ws *models.Workspace, skills skillResult) {
green := color.New(color.FgGreen)
bold := color.New(color.Bold)
dim := color.New(color.Faint)
fmt.Println()
fmt.Println(bold.Sprint("Pad is ready."))
fmt.Println()
// Server
green.Print(" ✓ Server ")
serverAddr := cfg.BaseURL()
if cfg.Mode == config.ModeLocal {
serverAddr = cfg.Addr()
}
fmt.Println(serverAddr)
// Auth
creds, _ := cli.LoadCredentials()
if creds != nil && creds.Email != "" {
green.Print(" ✓ Logged in ")
fmt.Println(creds.Email)
}
// Workspace
if ws != nil {
green.Print(" ✓ Workspace ")
fmt.Print(bold.Sprint(ws.Name))
// Try to get workspace stats from dashboard
dashJSON, err := client.GetDashboard(ws.Slug)
if err == nil {
var dash struct {
Summary struct {
TotalItems int `json:"total_items"`
ByCollection map[string]map[string]int `json:"by_collection"`
} `json:"summary"`
}
if json.Unmarshal(dashJSON, &dash) == nil && dash.Summary.TotalItems > 0 {
// Count open + in-progress tasks
taskStats := dash.Summary.ByCollection["tasks"]
open := taskStats["open"]
inProgress := taskStats["in-progress"]
parts := []string{}
if open > 0 {
parts = append(parts, fmt.Sprintf("%d open", open))
}
if inProgress > 0 {
parts = append(parts, fmt.Sprintf("%d in progress", inProgress))
}
if len(parts) > 0 {
fmt.Print(dim.Sprintf(" (%s)", strings.Join(parts, ", ")))
} else {
fmt.Print(dim.Sprintf(" (%d items)", dash.Summary.TotalItems))
}
}
}
fmt.Println()
}
// Skills
if len(skills.tools) > 0 {
green.Print(" ✓ Skills ")
fmt.Print(strings.Join(skills.tools, ", "))
if skills.updated > 0 {
fmt.Print(dim.Sprintf(" (%d updated)", skills.updated))
}
fmt.Println()
}
// Version
green.Print(" ✓ Version ")
fmt.Println(fullVersion())
fmt.Println()
}