Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2fbf5cf
Add braille art banner to fizzy, setup, and signup commands
robzolkos Mar 6, 2026
52b0f39
Add CEO welcome message for new user signups
robzolkos Mar 6, 2026
78df6ba
Fix gosec lint: use blank identifier for cmd.Help() error
robzolkos Mar 6, 2026
b6714d9
Print banner to stdout and include signature in machine welcome message
robzolkos Mar 6, 2026
0a73742
Preserve existing profile fields in ensureProfile
jeremy Mar 6, 2026
1355a34
Consolidate authLogoutAll into a single loop
jeremy Mar 6, 2026
dbf688e
Use t.TempDir() instead of os.MkdirTemp in signup tests
jeremy Mar 6, 2026
0bd15e5
Remove DNS resolution from validateSignupURL to fix TOCTOU
jeremy Mar 6, 2026
25d2261
Apply target profile's board when switching profiles
jeremy Mar 6, 2026
1ac47fe
Print banner to stderr to avoid mixing with command output
jeremy Mar 6, 2026
7c34460
Use actual CLI version in signup User-Agent header
jeremy Mar 6, 2026
2768d78
Warn when FIZZY_ACCOUNT env var is used instead of FIZZY_PROFILE
jeremy Mar 6, 2026
2e4f054
Check stderr TTY status to match banner output destination
jeremy Mar 6, 2026
4d88f2c
Cache profileEnvVar() result to avoid duplicate deprecation warning
jeremy Mar 6, 2026
b48d9f5
Fix ensureProfile preserving stale BaseURL on hosted re-signup
jeremy Mar 6, 2026
b294d34
Guard banner on machine flags and stderr TTY, not IsMachineOutput
jeremy Mar 6, 2026
5922afe
Rewrite welcome message copy for CLI context
jeremy Mar 6, 2026
dd36500
Fix ensureProfile doc comment to match implementation
jeremy Mar 6, 2026
6d630ef
Soften welcome message to not assert Playground board exists
jeremy Mar 7, 2026
e7aec52
Emit FIZZY_ACCOUNT deprecation warning on no-profiles path
jeremy Mar 7, 2026
73a1e61
Add tests for ensureProfile BaseURL overwrite and preservation
jeremy Mar 7, 2026
66c2e71
Clear stale board from profile when setup user selects "None"
jeremy Mar 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 31 additions & 23 deletions internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,38 +131,35 @@ var authLogoutCmd = &cobra.Command{
}

func authLogoutAll() error {
if creds != nil {
// Collect all known profile/account names to clean up every key format
names := map[string]bool{}
// Collect all known profile/account names to clean up every key format
names := map[string]bool{}

if profiles != nil {
allProfiles, _, _ := profiles.List()
for name := range allProfiles {
names[name] = true
}
if profiles != nil {
allProfiles, _, _ := profiles.List()
for name := range allProfiles {
names[name] = true
}
}

// Also include the YAML config's Account in case it's not in the profile store
globalCfg := config.LoadGlobal()
if globalCfg.Account != "" {
names[globalCfg.Account] = true
}
// Also include the YAML config's Account in case it's not in the profile store
globalCfg := config.LoadGlobal()
if globalCfg.Account != "" {
names[globalCfg.Account] = true
}

for name := range names {
for name := range names {
if creds != nil {
_ = credsDeleteProfileToken(name) // "profile:<name>"
_ = creds.Delete("token:" + name) // legacy "token:<account>"
}
// Legacy bare key
_ = creds.Delete("token")
}

// Delete all profiles from the store
if profiles != nil {
allProfiles, _, _ := profiles.List()
for name := range allProfiles {
if profiles != nil {
_ = profiles.Delete(name)
}
}
if creds != nil {
// Legacy bare key
_ = creds.Delete("token")
}

// Clear config
if err := config.Delete(); err != nil {
Expand Down Expand Up @@ -326,17 +323,28 @@ var authSwitchCmd = &cobra.Command{
}
}

// Read the target profile's board from Extra
var profileBoard string
if profiles != nil {
if p, err := profiles.Get(profileName); err == nil {
if boardRaw, ok := p.Extra["board"]; ok {
_ = json.Unmarshal(boardRaw, &profileBoard)
}
}
}

// Update YAML config for backward compat
globalCfg := config.LoadGlobal()
globalCfg.Account = profileName
globalCfg.Board = "" // Clear board since it's profile-specific
globalCfg.Board = profileBoard
if err := globalCfg.Save(); err != nil {
return &output.Error{Code: output.CodeAPI, Message: err.Error()}
}

// Update in-memory config
if cfg != nil {
cfg.Account = profileName
cfg.Board = profileBoard
if creds != nil {
if t, err := credsLoadProfileToken(profileName); err == nil {
cfg.Token = t
Expand Down
35 changes: 35 additions & 0 deletions internal/commands/banner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package commands
Comment thread
jeremy marked this conversation as resolved.

import (
"fmt"
"os"

"github.com/mattn/go-isatty"
)

const banner = `
⠀⡀⠄⠀⣤⣤⡄⠠⢠⣤⣤⠀⠄⣠⣤⣤⠀⠠⣠⣤⣄⠠⠀⣤⣤⡄⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀
⠀⠀⠄⢸⣿⣿⡇⠠⢸⣿⣿⡇⠀⣿⣿⡿⡇⢈⣿⣿⣿⠀⢸⣿⣿⡇⢀⠈⡀⢈⠀⡈⢀⠈⡀⢈⠀⡈⢀⠈⡀⢈⠀⡈⢀⠈⡀⢈⠀⡈⢀⠈⡀⢈⠀⡈⢀⠈⡀⢈⠀⡈⢀⠈⠠⠈⢀⠈⡀⢈⠀⡈⢀⠈⡀⢈⠀⡈⠠⠀
⠀⢁⠠⢸⣿⡿⡇⠀⢸⣿⡿⡇⠀⣿⣿⢿⡃⠠⣿⣿⣾⠀⢸⣿⣽⡇⠀⠠⠀⠄⠠⠀⣦⣦⣦⣦⣦⣦⣦⣦⣦⠀⢰⣾⣷⡦⠀⠄⠠⠀⠄⠠⠀⠄⠠⠀⠄⠠⠀⠄⠠⠀⠄⠐⢀⠈⡀⠠⠀⠄⠠⠀⠄⠠⠀⠄⠠⠀⠐⠀
⠀⠠⠀⢸⣿⣿⡇⠈⢸⣿⣿⡇⠀⣿⣿⣿⠇⢈⣿⣷⣿⠀⢸⣿⣽⡇⠀⠂⠐⠀⠂⡀⣿⣿⡟⠛⠛⠛⠛⠛⠛⠀⠄⠉⠉⢁⠀⠂⠐⠀⠂⠐⠀⠂⠐⠀⠂⠐⠀⠂⠐⠀⠂⠁⠠⠀⠄⠐⠀⠂⠐⠀⠂⠐⠀⠂⠐⠈⡀⠁
⠀⠂⠈⢸⣿⣯⡇⠠⢸⣿⣯⡇⠀⣿⣿⣾⡇⠐⣿⣯⣿⠀⢸⣿⣽⡇⠀⡁⢈⠀⡁⠀⣿⣿⡇⠀⠄⠂⠀⠂⠐⢀⢸⣿⣿⡇⠀⡁⣿⣿⣿⣿⣿⣿⣿⣿⡃⢈⠀⣿⣿⣿⣿⣿⣿⣿⣿⠅⠈⢿⣿⣿⡀⢁⠈⡀⣽⣿⣿⠃
⠀⡈⠠⢸⣿⡿⡇⠀⢸⣿⡿⡇⠀⣿⣿⣽⡆⠨⣿⣟⣿⠀⢸⣯⣿⡇⠀⠄⠠⠀⠄⠂⣿⣿⢷⣶⣶⣶⣷⣾⡆⢀⢸⣿⣻⡇⠀⡀⠉⡈⠁⣁⣵⣿⣿⠋⠀⠠⠀⢉⠈⢁⢁⣵⣿⡿⠋⠀⠐⠘⣿⣿⣧⠀⠄⢠⣿⣿⠎⠀
⠀⠠⠀⢸⣿⣿⡇⠈⢸⣿⣿⡇⠀⣿⣿⣻⡅⠐⠿⣿⠟⠀⢸⣿⣽⡇⠀⠂⠐⠀⠂⡀⣿⣿⡟⠛⠛⠋⠛⠛⠃⠀⢸⣟⣿⡇⠀⠄⠂⠠⣰⣾⣿⠟⠁⢀⠈⠠⠈⢀⢠⣰⣿⡿⠟⠀⡀⢁⠈⡀⠸⣿⣾⡇⢀⣿⣿⡟⠀⠄
⠀⠂⠈⠸⣿⣯⡇⠠⢸⣿⣯⡇⠀⣿⣿⢿⡃⠠⠀⡢⠀⡁⠘⣿⣽⡇⠀⡁⢈⠀⢁⠀⣿⣿⡇⠀⠐⠀⠂⠐⠀⡁⢸⣿⣟⡇⢀⠐⣠⣶⣿⡟⠃⢀⠐⠀⡐⢀⠈⣠⣾⣿⡗⠋⠀⠄⠠⠀⠄⠀⠂⢹⣿⣷⣼⣿⡿⠀⠐⠀
⠀⡈⢀⠁⠉⡏⠀⠠⢸⣿⡿⡇⠀⣿⣿⣿⠇⠀⠂⢜⠀⡀⠂⠉⡏⠀⠄⠠⠀⠐⢀⠀⣿⣿⡇⠀⡁⢈⠀⡁⠄⠠⢸⣿⣽⡇⠀⡀⣿⣿⣻⣿⣿⣿⣿⣿⡇⠀⢐⣿⣿⣷⣿⣿⣿⣿⣿⡇⢀⠁⡈⠀⢹⣿⣻⣽⠁⢀⠁⠄
⠀⠠⠀⠐⠀⡇⠈⡀⢸⣿⣿⡇⠀⠙⢻⠊⠠⠈⡀⢕⠀⠠⠐⠀⡇⠐⠀⠂⢈⠠⠀⠄⢀⠀⡀⠄⠠⠀⠄⠠⠀⠂⢀⠀⡀⠠⠀⠄⢀⠀⡀⠀⡀⠀⡀⠀⡀⠐⢀⠀⡀⠀⡀⠀⡀⠀⡀⠀⠄⠠⣀⣈⣸⣿⣿⠃⠀⠄⠐⠀
⠀⠐⠈⢀⠁⡇⠠⠀⠸⢿⡯⠃⢀⠁⢸⠀⠂⠠⠀⠪⠀⠐⠀⠁⡇⢀⠁⡈⢀⠠⠐⠀⠄⠠⠀⠄⠂⠐⠀⠂⡀⢁⠠⠀⠄⠐⠀⠂⠠⠀⠄⠂⠀⠂⠀⠂⡀⢈⠀⠠⠀⠂⠀⠂⠀⠂⠀⠂⠐⠰⣿⣿⠿⠟⠁⡀⠂⡀⠁⠄
⠀⢁⠈⢀⠠⠐⠀⡈⠠⠀⡀⠄⠠⠐⠀⠂⠁⠐⠀⠂⡈⢀⠁⠐⡀⠄⠠⠀⠄⢀⠐⠀⠂⠐⠀⠂⡀⢁⠈⡀⠄⠠⠀⠐⢀⠈⡀⢁⠐⢀⠐⠀⡁⢈⠀⡁⠀⠄⠐⠀⠂⠁⡈⢀⠁⡈⢀⠁⡈⢀⠀⡀⠄⠂⠁⠀⠄⠠⠈⠀
`

// printBanner prints the Fizzy braille art banner to stderr.
// Only prints in interactive TTY mode, never in machine/piped output.
func printBanner() {
if IsMachineOutput() {
return
}
if !isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd()) {
return
}
fmt.Fprint(os.Stderr, banner)
Comment thread
robzolkos marked this conversation as resolved.
}
Comment thread
jeremy marked this conversation as resolved.
47 changes: 35 additions & 12 deletions internal/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ var rootCmd = &cobra.Command{

Use fizzy to manage boards, cards, comments, and more from your terminal.`,
Version: "dev",
Run: func(cmd *cobra.Command, args []string) {
printBanner()
_ = cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Resolve output format from parsed flags (must happen post-parse).
format, err := resolveFormat()
Expand Down Expand Up @@ -719,7 +723,11 @@ func profileEnvVar() string {
if v := os.Getenv("FIZZY_PROFILE"); v != "" {
return v
}
return os.Getenv("FIZZY_ACCOUNT")
if v := os.Getenv("FIZZY_ACCOUNT"); v != "" {
fmt.Fprintln(os.Stderr, "Warning: FIZZY_ACCOUNT is deprecated, use FIZZY_PROFILE instead")
Comment thread
jeremy marked this conversation as resolved.
return v
}
return ""
Comment thread
jeremy marked this conversation as resolved.
}

// resolveToken applies token precedence: YAML → credstore (with migration) → env → flag.
Expand Down Expand Up @@ -783,29 +791,44 @@ func migrateLegacyToken(profileName string) {
}

// ensureProfile creates or updates a profile in the store.
// If the profile already exists, it is replaced with the new settings.
// If the profile already exists, fields are merged: BaseURL is only
// overwritten when a non-default value is provided, and Extra entries
// are preserved unless explicitly replaced.
func ensureProfile(name, baseURL, board string) {
if profiles == nil {
return
}
if baseURL == "" {
baseURL = config.DefaultAPIURL

existing, _ := profiles.Get(name)

newBaseURL := baseURL
if newBaseURL == "" || newBaseURL == config.DefaultAPIURL {
if existing != nil && existing.BaseURL != "" {
newBaseURL = existing.BaseURL
} else {
newBaseURL = config.DefaultAPIURL
}
}
Comment thread
jeremy marked this conversation as resolved.

extra := map[string]json.RawMessage{}
if existing != nil {
for k, v := range existing.Extra {
extra[k] = v
}
}
if board != "" {
extra["board"] = func() json.RawMessage { b, _ := json.Marshal(board); return b }()
Comment thread
jeremy marked this conversation as resolved.
}

p := &profile.Profile{
Name: name,
BaseURL: baseURL,
BaseURL: newBaseURL,
}
if board != "" {
p.Extra = map[string]json.RawMessage{
"board": func() json.RawMessage { b, _ := json.Marshal(board); return b }(),
}
if len(extra) > 0 {
p.Extra = extra
}

if err := profiles.Create(p); err != nil {
// Profile already exists — delete and recreate to update it.
// Preserve default status: if this profile was the default, SetDefault
// is called by the caller (login/setup/signup/switch) anyway.
_ = profiles.Delete(name)
_ = profiles.Create(p)
}
Expand Down
1 change: 1 addition & 0 deletions internal/commands/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func runSetup(cmd *cobra.Command, args []string) error {
return output.ErrUsageHint("setup requires an interactive terminal", "Run without --agent/--json/--quiet or in a TTY")
}

printBanner()
fmt.Println()
fmt.Println("Welcome to Fizzy CLI setup!")
fmt.Println()
Expand Down
82 changes: 61 additions & 21 deletions internal/commands/signup.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
stderrors "errors"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
Expand Down Expand Up @@ -67,12 +66,48 @@ func init() {
signupCompleteCmd.Flags().String("account", "", "Account slug (required for existing users)")
}

const welcomeSignature = `
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡤⠖⠚⠉⠉
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡼⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡤⠖⠋⠁
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡴⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠞⠉
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⠖⠋⠀⢀⠀⠀⠀⠉⠉⠉⠉⠉⢉⡽⠋⢉⣉⠉⠉⠉⠉⠉⡉⠉⠉⠉⠉
⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⠴⠋⢁⡴⠯⠞⠙⣦⠞⠙⠦⠤⠤⠤⢠⠞⣀⡴⠋⠈⠋⠙⠒⠒⠚⠁
⠀⠀⠀⠀⢀⣠⠤⠖⠉⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠏⠞⠁
⠤⠤⠖⠚⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡞
`

const welcomeMessage = `Welcome, and thanks for signing up for Fizzy.

To get you started, we set you up with a Fizzy board called Playground.
It's got a few cards designed to help you learn Fizzy itself. Open each
card, go through the simple steps, and you'll be an expert in Fizzy in
no time. You'll see the Playground when you close this message.

If you ever need a hand, please contact me directly at
jason@37signals.com. I'm here for you, we're all here for you.

Thanks again and all the best,`

const welcomeSignoff = `Jason Fried, jason@37signals.com
CEO & co-founder of 37signals, makers of Fizzy, Basecamp, and HEY`

// printWelcomeMessage prints the CEO welcome message for new users.
func printWelcomeMessage() {
fmt.Println()
fmt.Println(welcomeMessage)
fmt.Print(welcomeSignature)
fmt.Println(welcomeSignoff)
fmt.Println()
}

// runSignup is the interactive wizard that walks through the entire signup flow.
func runSignup(cmd *cobra.Command, args []string) error {
if IsMachineOutput() {
return output.ErrUsageHint("signup requires an interactive terminal — use subcommands (start, verify, complete) for programmatic access", "Run without --agent/--json/--quiet or in a TTY")
}

printBanner()
fmt.Println()
fmt.Println("Welcome to Fizzy CLI signup!")
fmt.Println()
Expand Down Expand Up @@ -311,8 +346,13 @@ func runSignup(cmd *cobra.Command, args []string) error {

fmt.Println()
fmt.Println("✓ Configuration saved.")
fmt.Println()
fmt.Println("You're all set! Try: fizzy board list")

if requiresCompletion {
printWelcomeMessage()
} else {
fmt.Println()
fmt.Println("You're all set! Try: fizzy board list")
}
return nil
}

Expand Down Expand Up @@ -478,15 +518,24 @@ func runSignupComplete(cmd *cobra.Command, args []string) error {
return err
}

result := map[string]any{
"token": token,
"account": accountSlug,
}

summary := "Configuration saved."

if name != "" {
result["welcome_message"] = welcomeMessage + welcomeSignature + welcomeSignoff
result["is_new_user"] = true
}

breadcrumbs := []Breadcrumb{
breadcrumb("boards", "fizzy board list", "List boards"),
breadcrumb("setup", "fizzy setup", "Full interactive setup"),
}

printSuccessWithBreadcrumbs(map[string]any{
"token": token,
"account": accountSlug,
}, "Configuration saved.", breadcrumbs)
printSuccessWithBreadcrumbs(result, summary, breadcrumbs)
return nil
}

Expand Down Expand Up @@ -543,7 +592,7 @@ func signupPost(client *http.Client, reqURL string, body any) (map[string]any, h
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "fizzy-cli/1.0")
req.Header.Set("User-Agent", fmt.Sprintf("fizzy-cli/%s", rootCmd.Version))

resp, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -599,7 +648,7 @@ func signupGet(client *http.Client, reqURL string) (map[string]any, error) {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "fizzy-cli/1.0")
req.Header.Set("User-Agent", fmt.Sprintf("fizzy-cli/%s", rootCmd.Version))

resp, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -648,8 +697,9 @@ func setSignedCookie(client *http.Client, apiURL, name, value string) {

// validateSignupURL checks that a URL is safe to use for signup HTTP requests.
// Only http:// and https:// schemes are allowed. Plain http:// is restricted to
// loopback addresses (localhost, 127.0.0.1, [::1]) to prevent SSRF against
// internal network services.
// literal loopback addresses (localhost, 127.0.0.1, [::1]) to prevent SSRF
// against internal network services. No DNS resolution is performed to avoid
// TOCTOU races between validation and connection time.
func validateSignupURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
Expand All @@ -664,16 +714,6 @@ func validateSignupURL(rawURL string) error {
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return nil
}
// Resolve the hostname to check for loopback IPs
resolver := &net.Resolver{}
ips, err := resolver.LookupHost(context.Background(), host)
if err == nil {
for _, ip := range ips {
if net.ParseIP(ip).IsLoopback() {
return nil
}
}
}
return fmt.Errorf("http:// is only allowed for localhost; use https:// for remote hosts")
Comment thread
jeremy marked this conversation as resolved.
default:
return fmt.Errorf("unsupported URL scheme %q; use https://", u.Scheme)
Expand Down
Loading
Loading