fix: resolve first-run experience errors (#721, #771, #730)

- cmdInit (--init) now substitutes EDITOR_PATH, PAGER_PATH, and
  WORK_PATH instead of leaving them as literal strings
- Installer now substitutes WORK_PATH and always creates personal
  and work directories regardless of community cheatsheet choice
- When community cheatsheets are declined, the community cheatpath
  is commented out in the generated config
- config.New() skips nonexistent cheatpaths with a warning instead
  of hard-erroring on EvalSymlinks failure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christopher Allen Lane
2026-02-14 20:42:33 -05:00
parent 8eafa5adfe
commit 00ec2c130d
5 changed files with 74 additions and 20 deletions

View File

@@ -44,13 +44,23 @@ func cmdInit() {
confpath := confpaths[0] confpath := confpaths[0]
confdir := filepath.Dir(confpath) confdir := filepath.Dir(confpath)
// create paths for community and personal cheatsheets // create paths for community, personal, and work cheatsheets
community := filepath.Join(confdir, "cheatsheets", "community") community := filepath.Join(confdir, "cheatsheets", "community")
personal := filepath.Join(confdir, "cheatsheets", "personal") personal := filepath.Join(confdir, "cheatsheets", "personal")
work := filepath.Join(confdir, "cheatsheets", "work")
// template the above paths into the default configs // template the above paths into the default configs
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1) configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1) configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
configs = strings.Replace(configs, "WORK_PATH", work, -1)
// locate and set a default pager
configs = strings.Replace(configs, "PAGER_PATH", config.Pager(), -1)
// locate and set a default editor
if editor, err := config.Editor(); err == nil {
configs = strings.Replace(configs, "EDITOR_PATH", editor, -1)
}
// output the templated configs // output the templated configs
fmt.Println(configs) fmt.Println(configs)

View File

@@ -64,7 +64,8 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
} }
// process cheatpaths // process cheatpaths
for i, cheatpath := range conf.Cheatpaths { var validPaths []cp.Cheatpath
for _, cheatpath := range conf.Cheatpaths {
// expand ~ in config paths // expand ~ in config paths
expanded, err := homedir.Expand(cheatpath.Path) expanded, err := homedir.Expand(cheatpath.Path)
@@ -83,6 +84,14 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
if resolve { if resolve {
evaled, err := filepath.EvalSymlinks(expanded) evaled, err := filepath.EvalSymlinks(expanded)
if err != nil { if err != nil {
// if the path simply doesn't exist, warn and skip it
if os.IsNotExist(err) {
fmt.Fprintf(os.Stderr,
"WARNING: cheatpath '%s' does not exist, skipping\n",
expanded,
)
continue
}
return Config{}, fmt.Errorf( return Config{}, fmt.Errorf(
"failed to resolve symlink: %s: %v", "failed to resolve symlink: %s: %v",
expanded, expanded,
@@ -93,8 +102,10 @@ func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error
expanded = evaled expanded = evaled
} }
conf.Cheatpaths[i].Path = expanded cheatpath.Path = expanded
validPaths = append(validPaths, cheatpath)
} }
conf.Cheatpaths = validPaths
// trim editor whitespace // trim editor whitespace
conf.Editor = strings.TrimSpace(conf.Editor) conf.Editor = strings.TrimSpace(conf.Editor)

View File

@@ -189,10 +189,14 @@ cheatpaths:
t.Fatalf("failed to write config: %v", err) t.Fatalf("failed to write config: %v", err)
} }
// Load config with symlink resolution should fail // Load config with symlink resolution should skip the broken cheatpath
_, err = New(map[string]interface{}{}, configFile, true) // (warn to stderr) rather than hard-error
if err == nil { conf, err := New(map[string]interface{}{}, configFile, true)
t.Error("expected error for broken symlink, got nil") if err != nil {
t.Errorf("expected no error for broken symlink (should skip), got: %v", err)
}
if len(conf.Cheatpaths) != 0 {
t.Errorf("expected broken cheatpath to be filtered out, got %d cheatpaths", len(conf.Cheatpaths))
} }
} }

View File

@@ -17,13 +17,15 @@ func Run(configs string, confpath string) error {
// cheatsheets based on the user's platform // cheatsheets based on the user's platform
confdir := filepath.Dir(confpath) confdir := filepath.Dir(confpath)
// create paths for community and personal cheatsheets // create paths for community, personal, and work cheatsheets
community := filepath.Join(confdir, "cheatsheets", "community") community := filepath.Join(confdir, "cheatsheets", "community")
personal := filepath.Join(confdir, "cheatsheets", "personal") personal := filepath.Join(confdir, "cheatsheets", "personal")
work := filepath.Join(confdir, "cheatsheets", "work")
// set default cheatpaths // set default cheatpaths
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1) configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1) configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
configs = strings.Replace(configs, "WORK_PATH", work, -1)
// locate and set a default pager // locate and set a default pager
configs = strings.Replace(configs, "PAGER_PATH", config.Pager(), -1) configs = strings.Replace(configs, "PAGER_PATH", config.Pager(), -1)
@@ -44,15 +46,29 @@ func Run(configs string, confpath string) error {
// clone the community cheatsheets if so instructed // clone the community cheatsheets if so instructed
if yes { if yes {
// clone the community cheatsheets
fmt.Printf("Cloning community cheatsheets to %s.\n", community) fmt.Printf("Cloning community cheatsheets to %s.\n", community)
if err := repo.Clone(community); err != nil { if err := repo.Clone(community); err != nil {
return fmt.Errorf("failed to clone cheatsheets: %v", err) return fmt.Errorf("failed to clone cheatsheets: %v", err)
} }
} else {
// comment out the community cheatpath in the config since
// the directory won't exist
configs = strings.Replace(configs,
" - name: community\n"+
" path: "+community+"\n"+
" tags: [ community ]\n"+
" readonly: true",
" #- name: community\n"+
" # path: "+community+"\n"+
" # tags: [ community ]\n"+
" # readonly: true",
-1,
)
}
// also create a directory for personal cheatsheets // always create personal and work directories
fmt.Printf("Cloning personal cheatsheets to %s.\n", personal) for _, dir := range []string{personal, work} {
if err := os.MkdirAll(personal, os.ModePerm); err != nil { if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create directory: %v", err) return fmt.Errorf("failed to create directory: %v", err)
} }
} }

View File

@@ -54,8 +54,8 @@ cheatpaths:
confpath: filepath.Join(tempDir, "conf1", "conf.yml"), confpath: filepath.Join(tempDir, "conf1", "conf.yml"),
userInput: "n\n", userInput: "n\n",
wantErr: false, wantErr: false,
checkFiles: []string{"conf1/conf.yml"}, checkFiles: []string{"conf1/conf.yml", "conf1/cheatsheets/personal", "conf1/cheatsheets/work"},
dontWantFiles: []string{"conf1/cheatsheets/community", "conf1/cheatsheets/personal"}, dontWantFiles: []string{"conf1/cheatsheets/community"},
}, },
{ {
name: "user accepts but clone fails", name: "user accepts but clone fails",
@@ -177,10 +177,18 @@ func TestRunStringReplacements(t *testing.T) {
editor: EDITOR_PATH editor: EDITOR_PATH
pager: PAGER_PATH pager: PAGER_PATH
cheatpaths: cheatpaths:
- name: community
path: COMMUNITY_PATH
- name: personal - name: personal
path: PERSONAL_PATH path: PERSONAL_PATH
tags: [ personal ]
readonly: false
- name: work
path: WORK_PATH
tags: [ work ]
readonly: false
- name: community
path: COMMUNITY_PATH
tags: [ community ]
readonly: true
` `
// Create temp directory // Create temp directory
@@ -194,7 +202,6 @@ cheatpaths:
confdir := filepath.Dir(confpath) confdir := filepath.Dir(confpath)
// Expected paths // Expected paths
expectedCommunity := filepath.Join(confdir, "cheatsheets", "community")
expectedPersonal := filepath.Join(confdir, "cheatsheets", "personal") expectedPersonal := filepath.Join(confdir, "cheatsheets", "personal")
// Save original stdin/stdout // Save original stdin/stdout
@@ -244,10 +251,16 @@ cheatpaths:
if strings.Contains(contentStr, "PAGER_PATH") && !strings.Contains(contentStr, fmt.Sprintf("pager: %s", "")) { if strings.Contains(contentStr, "PAGER_PATH") && !strings.Contains(contentStr, fmt.Sprintf("pager: %s", "")) {
t.Error("PAGER_PATH was not replaced") t.Error("PAGER_PATH was not replaced")
} }
if strings.Contains(contentStr, "WORK_PATH") {
t.Error("WORK_PATH was not replaced")
}
// Verify correct paths were used // Verify community path is commented out (user declined)
if !strings.Contains(contentStr, expectedCommunity) { if strings.Contains(contentStr, " - name: community") {
t.Errorf("expected community path %q in config", expectedCommunity) t.Error("expected community cheatpath to be commented out when declined")
}
if !strings.Contains(contentStr, " #- name: community") {
t.Error("expected commented-out community cheatpath")
} }
if !strings.Contains(contentStr, expectedPersonal) { if !strings.Contains(contentStr, expectedPersonal) {
t.Errorf("expected personal path %q in config", expectedPersonal) t.Errorf("expected personal path %q in config", expectedPersonal)