mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-19 09:05:58 +00:00
63d113624c
* 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.
526 lines
17 KiB
Go
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()
|
|
}
|