mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
d915cc3cf8
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.
567 lines
19 KiB
Go
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()
|
|
}
|