Files
pad/cmd/pad/init.go
T
xarmian d915cc3cf8 feat(cli): per-server credentials in credentials.json with v1 → v2 migration (TASK-1228) (#435)
Implements IDEA-1226. ~/.pad/credentials.json is now a map keyed by
server URL so one developer machine can stay logged in to multiple Pad
instances simultaneously — `apm/` repo on Pad Cloud, `target/` repo on
local, `testing/` repo on staging — without each `pad init --url <other>`
clobbering the previous server's credentials.

## On-disk format

v2 (new):
  {
    "version": 2,
    "credentials": {
      "https://app.getpad.dev":  {"token": "...", "user_id": "...", ...},
      "http://127.0.0.1:7777":   {"token": "...", "user_id": "...", ...}
    }
  }

v1 (legacy, read-only): {"server_url": "...", "token": "...", "user_id": "...", ...}

Reads transparently migrate v1 → v2 in memory; writes always emit v2.
Side-effect-free reads — the on-disk file stays v1 until login/logout/
setup triggers a Save, which is when migration becomes durable. This
keeps `pad <read-only-command>` from rewriting credentials.json on
every invocation just because the binary upgraded.

## API

Replaces the three top-level helpers (LoadCredentials / SaveCredentials /
DeleteCredentials) with a CredentialStore type:

  - LoadStore() (*CredentialStore, error)
  - (s).Get(serverURL) *Credentials       // nil-receiver safe
  - (s).Set(serverURL, *Credentials)
  - (s).Delete(serverURL)
  - (s).Save() error
  - WipeCredentialsFile() error           // file-level — replaces DeleteCredentials

URL canonicalization is built in: trailing slash + surrounding whitespace
are stripped before lookup/store, so http://x:7777 and http://x:7777/
hit the same bucket. Same rule cmd/pad/server_info.go was already
applying via its now-redundant normalizeURL — removed.

No top-level `default` field. The configured server (cfg.BaseURL() from
~/.pad/config.toml or --url) is always the source of truth for "which
server am I targeting" — a separate `default` would create a second
source of truth and the split-brain bugs that follow.

## Behavioral changes

- `pad init --url <other>` against a server you've authed to before now
  reuses the saved credential instead of clobbering it.
- `pad auth logout` removes only the configured server's entry. Other
  servers' tokens stay intact (pre-fix: wiped the whole file).
- `pad auth whoami` reads only the entry matching the configured server.
- Single-server users see no behavior change — one entry, identical
  shape per entry, identical UX.

## Compat shims removed

LoadCredentials / SaveCredentials / DeleteCredentials are deleted
outright (no // Deprecated lifecycle) — they're internal package
helpers with no external API contract. All 10 call sites in cmd/pad/
and internal/cli/ are migrated to the per-server API in this PR.

## Tests

internal/cli/credentials_test.go (15 tests):
- File missing / empty → empty store (callers don't need nil checks)
- v1 format reads + migrates in memory
- v1 with empty token → empty store (no phantom entries)
- v1 migration is durable on first Save (file flips to v2)
- v2 round-trip preserves multiple entries
- Set adds + replaces; mirrors URL into ServerURL field
- Delete keeps siblings (multi-server keystone behavior)
- Delete on absent key is a no-op
- Nil receiver Get/Delete don't panic (NewClientFromURL relies on this)
- URL normalization (trailing slash + whitespace)
- Save preserves all entries across the file boundary
- Save uses 0600 permissions
- WipeCredentialsFile removes the file + is idempotent
- Garbage file errors loudly (so we never silently lose data)

Existing tests unchanged. Full suite + lint + web-check green.

Closes: TASK-1228.
Implements: IDEA-1226.
2026-05-07 20:23:13 -04:00

567 lines
19 KiB
Go

package main
import (
"bytes"
"context"
"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
var cliPromptFlag bool
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()
if cliPromptFlag {
// Legacy --cli-prompt path: in-terminal email/name/password
// prompts. Same Bootstrap call the pre-TASK-1217 flow used,
// kept verbatim as a zero-cost hedge per IDEA-1179.
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()
} else {
// Default browser path. RunBrowserBootstrap returns once
// the server reports setup_required: false but the CLI
// has no credentials — the browser owns the session
// cookie. Chain doBrowserLogin afterwards so the rest
// of pad init (workspace creation, etc.) can hit
// authenticated endpoints.
//
// SIGINT is already handled by installInitCancelHandler
// at the top of this RunE — it short-circuits the whole
// process via os.Exit(130), so the helper doesn't need
// its own signal-aware context. Background suffices.
if err := cli.RunBrowserBootstrap(context.Background(), client, cfg); err != nil {
return err
}
green.Print("✓ ")
fmt.Println("First admin account created")
fmt.Println()
fmt.Println(" Authenticating the CLI…")
if err := doBrowserLogin(client, cfg); err != nil {
return fmt.Errorf("login: %w", err)
}
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 for THIS server that
// still work. Per-server lookup (TASK-1228) — credentials
// for other servers are silently ignored here.
store, _ := cli.LoadStore()
creds := store.Get(cfg.BaseURL())
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)")
// --cli-prompt is the same hedge `pad auth setup` exposes (TASK-1216 /
// IDEA-1179). When the browser path won't work — broken X11, headless
// box without an SSH tunnel — this falls back to the legacy in-
// terminal email/name/password prompts for the admin-creation step.
// Workspace creation (cwd-bound) stays CLI in either case.
cmd.Flags().BoolVar(&cliPromptFlag, "cli-prompt", false, "Use the legacy in-terminal email/name/password prompts for the admin-account step instead of the browser /setup flow.")
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 — entry for the configured server only
store, _ := cli.LoadStore()
creds := store.Get(cfg.BaseURL())
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()
}