Skip to content

Commit b39d446

Browse files
robzolkosjeremy
andauthored
Add ASCII banner and CEO welcome message for new signups (#80)
* Add braille art banner to fizzy, setup, and signup commands Show the README's braille art banner in interactive terminal sessions: - bare `fizzy` command (before help output) - `fizzy setup` (before the setup wizard) - `fizzy signup` (before the signup wizard) The banner is printed to stderr and only displays when connected to a TTY, so it never appears in piped, --json, --agent, or --quiet output. * Add CEO welcome message for new user signups Show a personal welcome message from Jason Fried after new users complete signup. Includes braille signature art in interactive mode and a welcome_message field in JSON output for programmatic/agent use. Agent skill updated to require displaying the message. * Fix gosec lint: use blank identifier for cmd.Help() error * Print banner to stdout and include signature in machine welcome message Address Copilot review: use stdout for banner to match other interactive output, and include the signature in machine output welcome_message for consistency with interactive output. * Preserve existing profile fields in ensureProfile Read the existing profile before creating/replacing so that Extra entries (like board) and non-default BaseURL values survive calls that pass empty values for those fields (auth login, migrateLegacyToken). * Consolidate authLogoutAll into a single loop Eliminate the second profiles.List() call by deleting credentials and profiles in the same iteration over the collected names. * Use t.TempDir() instead of os.MkdirTemp in signup tests Removes manual cleanup and lets the test framework handle temp directory lifecycle. * Remove DNS resolution from validateSignupURL to fix TOCTOU Only allow literal loopback addresses (localhost, 127.0.0.1, ::1) for http:// URLs. DNS lookups at validation time can return different results than at connection time, creating a TOCTOU race. * Apply target profile's board when switching profiles Read the board from the target profile's Extra instead of unconditionally clearing it, so profile-specific boards survive auth switch. * Print banner to stderr to avoid mixing with command output * Use actual CLI version in signup User-Agent header * Warn when FIZZY_ACCOUNT env var is used instead of FIZZY_PROFILE * Check stderr TTY status to match banner output destination * Cache profileEnvVar() result to avoid duplicate deprecation warning * Fix ensureProfile preserving stale BaseURL on hosted re-signup Only preserve existing BaseURL when the caller passes empty string (meaning "I don't care"). Passing config.DefaultAPIURL now correctly overwrites a previously-saved self-hosted URL. * Guard banner on machine flags and stderr TTY, not IsMachineOutput IsMachineOutput checks stdout, but the banner writes to stderr. Check machine format flags directly and gate on stderr TTY state so the banner shows when stderr is a terminal regardless of stdout piping, and is suppressed when stderr itself is redirected. * Rewrite welcome message copy for CLI context Replace GUI-specific language ("You'll see the Playground when you close this message") with CLI-appropriate direction ("Try fizzy board list to see it"). * Fix ensureProfile doc comment to match implementation * Soften welcome message to not assert Playground board exists Use "You should find a Playground board" instead of asserting it was set up, since the CLI can't verify server-side board provisioning. * Emit FIZZY_ACCOUNT deprecation warning on no-profiles path The early return at resolveProfile:670-678 read FIZZY_ACCOUNT directly, bypassing profileEnvVar() and its deprecation warning. Route through profileEnvVar() so the warning fires consistently. * Add tests for ensureProfile BaseURL overwrite and preservation Cover the hosted re-signup case (default URL overwrites self-hosted) and the empty-string case (existing BaseURL is preserved). * Clear stale board from profile when setup user selects "None" ensureProfile preserves existing Extra entries when board is empty (to avoid clobbering boards during auth login/signup). Setup is the only caller where empty board means "user explicitly chose no board," so handle the clearing there directly. --------- Co-authored-by: Jeremy Daer <jeremy@37signals.com>
1 parent cca24e1 commit b39d446

9 files changed

Lines changed: 270 additions & 76 deletions

File tree

internal/commands/auth.go

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -131,38 +131,35 @@ var authLogoutCmd = &cobra.Command{
131131
}
132132

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

138-
if profiles != nil {
139-
allProfiles, _, _ := profiles.List()
140-
for name := range allProfiles {
141-
names[name] = true
142-
}
137+
if profiles != nil {
138+
allProfiles, _, _ := profiles.List()
139+
for name := range allProfiles {
140+
names[name] = true
143141
}
142+
}
144143

145-
// Also include the YAML config's Account in case it's not in the profile store
146-
globalCfg := config.LoadGlobal()
147-
if globalCfg.Account != "" {
148-
names[globalCfg.Account] = true
149-
}
144+
// Also include the YAML config's Account in case it's not in the profile store
145+
globalCfg := config.LoadGlobal()
146+
if globalCfg.Account != "" {
147+
names[globalCfg.Account] = true
148+
}
150149

151-
for name := range names {
150+
for name := range names {
151+
if creds != nil {
152152
_ = credsDeleteProfileToken(name) // "profile:<name>"
153153
_ = creds.Delete("token:" + name) // legacy "token:<account>"
154154
}
155-
// Legacy bare key
156-
_ = creds.Delete("token")
157-
}
158-
159-
// Delete all profiles from the store
160-
if profiles != nil {
161-
allProfiles, _, _ := profiles.List()
162-
for name := range allProfiles {
155+
if profiles != nil {
163156
_ = profiles.Delete(name)
164157
}
165158
}
159+
if creds != nil {
160+
// Legacy bare key
161+
_ = creds.Delete("token")
162+
}
166163

167164
// Clear config
168165
if err := config.Delete(); err != nil {
@@ -326,17 +323,28 @@ var authSwitchCmd = &cobra.Command{
326323
}
327324
}
328325

326+
// Read the target profile's board from Extra
327+
var profileBoard string
328+
if profiles != nil {
329+
if p, err := profiles.Get(profileName); err == nil {
330+
if boardRaw, ok := p.Extra["board"]; ok {
331+
_ = json.Unmarshal(boardRaw, &profileBoard)
332+
}
333+
}
334+
}
335+
329336
// Update YAML config for backward compat
330337
globalCfg := config.LoadGlobal()
331338
globalCfg.Account = profileName
332-
globalCfg.Board = "" // Clear board since it's profile-specific
339+
globalCfg.Board = profileBoard
333340
if err := globalCfg.Save(); err != nil {
334341
return &output.Error{Code: output.CodeAPI, Message: err.Error()}
335342
}
336343

337344
// Update in-memory config
338345
if cfg != nil {
339346
cfg.Account = profileName
347+
cfg.Board = profileBoard
340348
if creds != nil {
341349
if t, err := credsLoadProfileToken(profileName); err == nil {
342350
cfg.Token = t

internal/commands/auth_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,6 +1136,67 @@ func TestEnsureProfileUpdatesExisting(t *testing.T) {
11361136
t.Errorf("expected board 'new-board', got '%s'", board)
11371137
}
11381138
})
1139+
1140+
t.Run("overwrites self-hosted BaseURL with default on hosted re-signup", func(t *testing.T) {
1141+
profileDir := t.TempDir()
1142+
profileStore := profile.NewStore(filepath.Join(profileDir, "config.json"))
1143+
profileStore.Create(&profile.Profile{
1144+
Name: "acme",
1145+
BaseURL: "https://selfhosted.example.com",
1146+
Extra: map[string]json.RawMessage{
1147+
"board": json.RawMessage(`"old-board"`),
1148+
},
1149+
})
1150+
1151+
mock := NewMockClient()
1152+
SetTestMode(mock)
1153+
SetTestProfiles(profileStore)
1154+
defer ResetTestMode()
1155+
1156+
// Re-signup with default URL should overwrite the self-hosted URL
1157+
ensureProfile("acme", config.DefaultAPIURL, "")
1158+
1159+
p, err := profileStore.Get("acme")
1160+
if err != nil {
1161+
t.Fatalf("expected profile to exist: %v", err)
1162+
}
1163+
if p.BaseURL != config.DefaultAPIURL {
1164+
t.Errorf("expected BaseURL '%s', got '%s'", config.DefaultAPIURL, p.BaseURL)
1165+
}
1166+
// Board should be preserved since we passed empty
1167+
var board string
1168+
if boardRaw, ok := p.Extra["board"]; ok {
1169+
json.Unmarshal(boardRaw, &board)
1170+
}
1171+
if board != "old-board" {
1172+
t.Errorf("expected board 'old-board' to be preserved, got '%s'", board)
1173+
}
1174+
})
1175+
1176+
t.Run("preserves existing BaseURL when caller passes empty", func(t *testing.T) {
1177+
profileDir := t.TempDir()
1178+
profileStore := profile.NewStore(filepath.Join(profileDir, "config.json"))
1179+
profileStore.Create(&profile.Profile{
1180+
Name: "acme",
1181+
BaseURL: "https://custom.example.com",
1182+
})
1183+
1184+
mock := NewMockClient()
1185+
SetTestMode(mock)
1186+
SetTestProfiles(profileStore)
1187+
defer ResetTestMode()
1188+
1189+
// Empty baseURL should preserve the existing one
1190+
ensureProfile("acme", "", "")
1191+
1192+
p, err := profileStore.Get("acme")
1193+
if err != nil {
1194+
t.Fatalf("expected profile to exist: %v", err)
1195+
}
1196+
if p.BaseURL != "https://custom.example.com" {
1197+
t.Errorf("expected BaseURL 'https://custom.example.com', got '%s'", p.BaseURL)
1198+
}
1199+
})
11391200
}
11401201

11411202
func TestAuthLogoutAllCleansLegacyKeys(t *testing.T) {

internal/commands/banner.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/mattn/go-isatty"
8+
)
9+
10+
const banner = `
11+
⠀⡀⠄⠀⣤⣤⡄⠠⢠⣤⣤⠀⠄⣠⣤⣤⠀⠠⣠⣤⣄⠠⠀⣤⣤⡄⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀⠠⠀
12+
⠀⠀⠄⢸⣿⣿⡇⠠⢸⣿⣿⡇⠀⣿⣿⡿⡇⢈⣿⣿⣿⠀⢸⣿⣿⡇⢀⠈⡀⢈⠀⡈⢀⠈⡀⢈⠀⡈⢀⠈⡀⢈⠀⡈⢀⠈⡀⢈⠀⡈⢀⠈⡀⢈⠀⡈⢀⠈⡀⢈⠀⡈⢀⠈⠠⠈⢀⠈⡀⢈⠀⡈⢀⠈⡀⢈⠀⡈⠠⠀
13+
⠀⢁⠠⢸⣿⡿⡇⠀⢸⣿⡿⡇⠀⣿⣿⢿⡃⠠⣿⣿⣾⠀⢸⣿⣽⡇⠀⠠⠀⠄⠠⠀⣦⣦⣦⣦⣦⣦⣦⣦⣦⠀⢰⣾⣷⡦⠀⠄⠠⠀⠄⠠⠀⠄⠠⠀⠄⠠⠀⠄⠠⠀⠄⠐⢀⠈⡀⠠⠀⠄⠠⠀⠄⠠⠀⠄⠠⠀⠐⠀
14+
⠀⠠⠀⢸⣿⣿⡇⠈⢸⣿⣿⡇⠀⣿⣿⣿⠇⢈⣿⣷⣿⠀⢸⣿⣽⡇⠀⠂⠐⠀⠂⡀⣿⣿⡟⠛⠛⠛⠛⠛⠛⠀⠄⠉⠉⢁⠀⠂⠐⠀⠂⠐⠀⠂⠐⠀⠂⠐⠀⠂⠐⠀⠂⠁⠠⠀⠄⠐⠀⠂⠐⠀⠂⠐⠀⠂⠐⠈⡀⠁
15+
⠀⠂⠈⢸⣿⣯⡇⠠⢸⣿⣯⡇⠀⣿⣿⣾⡇⠐⣿⣯⣿⠀⢸⣿⣽⡇⠀⡁⢈⠀⡁⠀⣿⣿⡇⠀⠄⠂⠀⠂⠐⢀⢸⣿⣿⡇⠀⡁⣿⣿⣿⣿⣿⣿⣿⣿⡃⢈⠀⣿⣿⣿⣿⣿⣿⣿⣿⠅⠈⢿⣿⣿⡀⢁⠈⡀⣽⣿⣿⠃
16+
⠀⡈⠠⢸⣿⡿⡇⠀⢸⣿⡿⡇⠀⣿⣿⣽⡆⠨⣿⣟⣿⠀⢸⣯⣿⡇⠀⠄⠠⠀⠄⠂⣿⣿⢷⣶⣶⣶⣷⣾⡆⢀⢸⣿⣻⡇⠀⡀⠉⡈⠁⣁⣵⣿⣿⠋⠀⠠⠀⢉⠈⢁⢁⣵⣿⡿⠋⠀⠐⠘⣿⣿⣧⠀⠄⢠⣿⣿⠎⠀
17+
⠀⠠⠀⢸⣿⣿⡇⠈⢸⣿⣿⡇⠀⣿⣿⣻⡅⠐⠿⣿⠟⠀⢸⣿⣽⡇⠀⠂⠐⠀⠂⡀⣿⣿⡟⠛⠛⠋⠛⠛⠃⠀⢸⣟⣿⡇⠀⠄⠂⠠⣰⣾⣿⠟⠁⢀⠈⠠⠈⢀⢠⣰⣿⡿⠟⠀⡀⢁⠈⡀⠸⣿⣾⡇⢀⣿⣿⡟⠀⠄
18+
⠀⠂⠈⠸⣿⣯⡇⠠⢸⣿⣯⡇⠀⣿⣿⢿⡃⠠⠀⡢⠀⡁⠘⣿⣽⡇⠀⡁⢈⠀⢁⠀⣿⣿⡇⠀⠐⠀⠂⠐⠀⡁⢸⣿⣟⡇⢀⠐⣠⣶⣿⡟⠃⢀⠐⠀⡐⢀⠈⣠⣾⣿⡗⠋⠀⠄⠠⠀⠄⠀⠂⢹⣿⣷⣼⣿⡿⠀⠐⠀
19+
⠀⡈⢀⠁⠉⡏⠀⠠⢸⣿⡿⡇⠀⣿⣿⣿⠇⠀⠂⢜⠀⡀⠂⠉⡏⠀⠄⠠⠀⠐⢀⠀⣿⣿⡇⠀⡁⢈⠀⡁⠄⠠⢸⣿⣽⡇⠀⡀⣿⣿⣻⣿⣿⣿⣿⣿⡇⠀⢐⣿⣿⣷⣿⣿⣿⣿⣿⡇⢀⠁⡈⠀⢹⣿⣻⣽⠁⢀⠁⠄
20+
⠀⠠⠀⠐⠀⡇⠈⡀⢸⣿⣿⡇⠀⠙⢻⠊⠠⠈⡀⢕⠀⠠⠐⠀⡇⠐⠀⠂⢈⠠⠀⠄⢀⠀⡀⠄⠠⠀⠄⠠⠀⠂⢀⠀⡀⠠⠀⠄⢀⠀⡀⠀⡀⠀⡀⠀⡀⠐⢀⠀⡀⠀⡀⠀⡀⠀⡀⠀⠄⠠⣀⣈⣸⣿⣿⠃⠀⠄⠐⠀
21+
⠀⠐⠈⢀⠁⡇⠠⠀⠸⢿⡯⠃⢀⠁⢸⠀⠂⠠⠀⠪⠀⠐⠀⠁⡇⢀⠁⡈⢀⠠⠐⠀⠄⠠⠀⠄⠂⠐⠀⠂⡀⢁⠠⠀⠄⠐⠀⠂⠠⠀⠄⠂⠀⠂⠀⠂⡀⢈⠀⠠⠀⠂⠀⠂⠀⠂⠀⠂⠐⠰⣿⣿⠿⠟⠁⡀⠂⡀⠁⠄
22+
⠀⢁⠈⢀⠠⠐⠀⡈⠠⠀⡀⠄⠠⠐⠀⠂⠁⠐⠀⠂⡈⢀⠁⠐⡀⠄⠠⠀⠄⢀⠐⠀⠂⠐⠀⠂⡀⢁⠈⡀⠄⠠⠀⠐⢀⠈⡀⢁⠐⢀⠐⠀⡁⢈⠀⡁⠀⠄⠐⠀⠂⠁⡈⢀⠁⡈⢀⠁⡈⢀⠀⡀⠄⠂⠁⠀⠄⠠⠈⠀
23+
`
24+
25+
// printBanner prints the Fizzy braille art banner to stderr.
26+
// Suppressed when machine format flags are set or stderr is not a TTY.
27+
func printBanner() {
28+
if cfgAgent || cfgJSON || cfgQuiet || cfgIDsOnly || cfgCount {
29+
return
30+
}
31+
if !isatty.IsTerminal(os.Stderr.Fd()) && !isatty.IsCygwinTerminal(os.Stderr.Fd()) {
32+
return
33+
}
34+
fmt.Fprint(os.Stderr, banner)
35+
}

internal/commands/root.go

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ var rootCmd = &cobra.Command{
6565
6666
Use fizzy to manage boards, cards, comments, and more from your terminal.`,
6767
Version: "dev",
68+
Run: func(cmd *cobra.Command, args []string) {
69+
printBanner()
70+
_ = cmd.Help()
71+
},
6872
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
6973
// Resolve output format from parsed flags (must happen post-parse).
7074
format, err := resolveFormat()
@@ -666,24 +670,23 @@ func resolveProfile() error {
666670
allProfiles, defaultName, err := profiles.List()
667671
if err != nil || len(allProfiles) == 0 {
668672
// No profiles configured — fall back to env var for account
669-
if p := os.Getenv("FIZZY_PROFILE"); p != "" {
670-
cfg.Account = p
671-
} else if a := os.Getenv("FIZZY_ACCOUNT"); a != "" {
672-
cfg.Account = a
673+
if v := profileEnvVar(); v != "" {
674+
cfg.Account = v
673675
}
674676
return nil
675677
}
676678

679+
envProfile := profileEnvVar()
677680
resolved, err := profile.Resolve(profile.ResolveOptions{
678681
FlagValue: cfgProfile,
679-
EnvVar: profileEnvVar(),
682+
EnvVar: envProfile,
680683
DefaultProfile: defaultName,
681684
Profiles: allProfiles,
682685
})
683686
if err != nil {
684687
// If the user explicitly specified a profile (flag or env), that's a
685688
// hard error — don't silently fall back to a different account.
686-
if cfgProfile != "" || profileEnvVar() != "" {
689+
if cfgProfile != "" || envProfile != "" {
687690
return err
688691
}
689692
// Otherwise (ambiguous default, etc.) — not fatal, just skip profile
@@ -719,7 +722,11 @@ func profileEnvVar() string {
719722
if v := os.Getenv("FIZZY_PROFILE"); v != "" {
720723
return v
721724
}
722-
return os.Getenv("FIZZY_ACCOUNT")
725+
if v := os.Getenv("FIZZY_ACCOUNT"); v != "" {
726+
fmt.Fprintln(os.Stderr, "Warning: FIZZY_ACCOUNT is deprecated, use FIZZY_PROFILE instead")
727+
return v
728+
}
729+
return ""
723730
}
724731

725732
// resolveToken applies token precedence: YAML → credstore (with migration) → env → flag.
@@ -783,29 +790,45 @@ func migrateLegacyToken(profileName string) {
783790
}
784791

785792
// ensureProfile creates or updates a profile in the store.
786-
// If the profile already exists, it is replaced with the new settings.
793+
// If the profile already exists, fields are merged: BaseURL is
794+
// preserved only when the caller passes an empty string (meaning
795+
// "keep whatever is there"), and Extra entries are preserved unless
796+
// explicitly replaced.
787797
func ensureProfile(name, baseURL, board string) {
788798
if profiles == nil {
789799
return
790800
}
791-
if baseURL == "" {
792-
baseURL = config.DefaultAPIURL
801+
802+
existing, _ := profiles.Get(name)
803+
804+
newBaseURL := baseURL
805+
if newBaseURL == "" {
806+
if existing != nil && existing.BaseURL != "" {
807+
newBaseURL = existing.BaseURL
808+
} else {
809+
newBaseURL = config.DefaultAPIURL
810+
}
811+
}
812+
813+
extra := map[string]json.RawMessage{}
814+
if existing != nil {
815+
for k, v := range existing.Extra {
816+
extra[k] = v
817+
}
818+
}
819+
if board != "" {
820+
extra["board"] = func() json.RawMessage { b, _ := json.Marshal(board); return b }()
793821
}
794822

795823
p := &profile.Profile{
796824
Name: name,
797-
BaseURL: baseURL,
825+
BaseURL: newBaseURL,
798826
}
799-
if board != "" {
800-
p.Extra = map[string]json.RawMessage{
801-
"board": func() json.RawMessage { b, _ := json.Marshal(board); return b }(),
802-
}
827+
if len(extra) > 0 {
828+
p.Extra = extra
803829
}
804830

805831
if err := profiles.Create(p); err != nil {
806-
// Profile already exists — delete and recreate to update it.
807-
// Preserve default status: if this profile was the default, SetDefault
808-
// is called by the caller (login/setup/signup/switch) anyway.
809832
_ = profiles.Delete(name)
810833
_ = profiles.Create(p)
811834
}

internal/commands/setup.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ func runSetup(cmd *cobra.Command, args []string) error {
4141
return output.ErrUsageHint("setup requires an interactive terminal", "Run without --agent/--json/--quiet or in a TTY")
4242
}
4343

44+
printBanner()
4445
fmt.Println()
4546
fmt.Println("Welcome to Fizzy CLI setup!")
4647
fmt.Println()
@@ -255,6 +256,14 @@ func runSetup(cmd *cobra.Command, args []string) error {
255256

256257
// Create/update profile
257258
ensureProfile(selectedAccountSlug, apiURL, selectedBoardID)
259+
// If user chose "None (skip)", clear any previously saved board
260+
if selectedBoardID == "" && profiles != nil {
261+
if p, err := profiles.Get(selectedAccountSlug); err == nil {
262+
delete(p.Extra, "board")
263+
_ = profiles.Delete(selectedAccountSlug)
264+
_ = profiles.Create(p)
265+
}
266+
}
258267
if profiles != nil {
259268
_ = profiles.SetDefault(selectedAccountSlug)
260269
}

0 commit comments

Comments
 (0)