From fb66c3f2206d9eb3cd9ad63a5d01b8a5b1abbfa3 Mon Sep 17 00:00:00 2001 From: hectorsector Date: Fri, 8 May 2026 10:25:40 -0400 Subject: [PATCH] better error handling --- .gitignore | 2 ++ cli/cmd/enrich.go | 12 ++++++------ cli/cmd/import.go | 17 +++++++++++------ cli/cmd/list.go | 9 +++++---- cli/cmd/publish.go | 12 +++++++----- cli/cmd/root.go | 8 +++++--- cli/go.mod | 1 + cli/go.sum | 2 ++ cli/internal/apperrors/errors.go | 24 ++++++++++++++++++++++++ cli/internal/atproto/atproto.go | 24 ++++++++++++------------ cli/internal/openlibrary/openlibrary.go | 4 ++-- cli/internal/store/store.go | 12 ++++++------ 12 files changed, 83 insertions(+), 44 deletions(-) create mode 100644 cli/internal/apperrors/errors.go diff --git a/.gitignore b/.gitignore index 2c3c300..7d37df6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ data/ # Local notes and planning notes/ + +.env diff --git a/cli/cmd/enrich.go b/cli/cmd/enrich.go index 34de47d..b111fe4 100644 --- a/cli/cmd/enrich.go +++ b/cli/cmd/enrich.go @@ -9,6 +9,7 @@ import ( "path/filepath" "github.com/spf13/cobra" + "tangled.org/hectorsector.com/books/cli/internal/apperrors" "tangled.org/hectorsector.com/books/cli/internal/openlibrary" "tangled.org/hectorsector.com/books/cli/internal/store" ) @@ -16,15 +17,14 @@ import ( var enrichCmd = &cobra.Command{ Use: "enrich", Short: "looks up OL IDs for each book", - RunE: func(cmd *cobra.Command, args []string) error { + Run: func(cmd *cobra.Command, args []string) { storePath, _ := cmd.Root().PersistentFlags().GetString("store") err := runEnrich(storePath) if err != nil { - return fmt.Errorf("Can't enrich with OLWordIDs: %w", err) + fmt.Fprintf(os.Stderr, "enriching with OLWorkIDs:\n%s", apperrors.FormatError(err)) + os.Exit(1) } - - return nil }, } @@ -33,7 +33,7 @@ func runEnrich(storePath string) error { books, err := store.Load(absPath) if err != nil { - return fmt.Errorf("Couldn't load books from store at %s: %w", absPath, err) + return fmt.Errorf("loading books from store at %s: %w", absPath, err) } for i := range books { @@ -59,7 +59,7 @@ func runEnrich(storePath string) error { err = store.Save(absPath, books) if err != nil { - return fmt.Errorf("Unable to save to store %s: %w", absPath, err) + return fmt.Errorf("saving to store %s: %w", absPath, err) } fmt.Println("\nšŸ‘€ You can review books that couldn't be matched with:\n books list --no-olworkid") diff --git a/cli/cmd/import.go b/cli/cmd/import.go index 515e1e1..05c147e 100644 --- a/cli/cmd/import.go +++ b/cli/cmd/import.go @@ -4,12 +4,13 @@ Copyright Ā© 2026 Hector Alfaro package cmd import ( - "errors" "fmt" + "os" "path/filepath" "strings" "github.com/spf13/cobra" + "tangled.org/hectorsector.com/books/cli/internal/apperrors" "tangled.org/hectorsector.com/books/cli/internal/store" "tangled.org/hectorsector.com/books/cli/internal/storygraph" ) @@ -19,28 +20,32 @@ var importCmd = &cobra.Command{ Use: "import [file]", Short: "Import from a StoryGraph CSV", Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + Run: func(cmd *cobra.Command, args []string) { filename := args[0] _, csvSuffix := strings.CutSuffix(filename, ".csv") if !csvSuffix { - return errors.New("File isn't a csv") + fmt.Fprintln(os.Stderr, "file isn't a csv") + os.Exit(1) } books, err := storygraph.ParseCSV(filename) if err != nil { - return err + fmt.Fprintf(os.Stderr, "parsing csv %s:\n%s", filename, apperrors.FormatError(err)) + os.Exit(1) } booksWithStatuses := store.FromStorygraph(books) storePath, _ := cmd.Root().PersistentFlags().GetString("store") err = store.Save(storePath, booksWithStatuses) + if err != nil { + fmt.Fprintf(os.Stderr, "saving store:\n%s", apperrors.FormatError(err)) + os.Exit(1) + } fullPath, _ := filepath.Abs(storePath) fmt.Fprintf(cmd.OutOrStdout(), "āœ… Wrote store to %s\n", fullPath) - - return nil }, } diff --git a/cli/cmd/list.go b/cli/cmd/list.go index 2a56b93..c557894 100644 --- a/cli/cmd/list.go +++ b/cli/cmd/list.go @@ -5,8 +5,10 @@ package cmd import ( "fmt" + "os" "github.com/spf13/cobra" + "tangled.org/hectorsector.com/books/cli/internal/apperrors" "tangled.org/hectorsector.com/books/cli/internal/lexicon" "tangled.org/hectorsector.com/books/cli/internal/store" ) @@ -14,12 +16,13 @@ import ( var listCmd = &cobra.Command{ Use: "list", Short: "List the books in our local record.", - RunE: func(cmd *cobra.Command, args []string) error { + Run: func(cmd *cobra.Command, args []string) { storePath, _ := cmd.Root().PersistentFlags().GetString("store") books, err := store.Load(storePath) if err != nil { - return fmt.Errorf("Can't load the file: %s", storePath) + fmt.Fprintf(os.Stderr, "loading store at %s:\n%s", storePath, apperrors.FormatError(err)) + os.Exit(1) } books = store.Filter(books, filtersFromFlags(cmd)...) @@ -34,8 +37,6 @@ var listCmd = &cobra.Command{ } else { printSummary(books) } - - return nil }, } diff --git a/cli/cmd/publish.go b/cli/cmd/publish.go index f2c355f..2559bb2 100644 --- a/cli/cmd/publish.go +++ b/cli/cmd/publish.go @@ -9,28 +9,30 @@ import ( "github.com/spf13/cobra" "tangled.org/hectorsector.com/books/cli/internal/atproto" + "tangled.org/hectorsector.com/books/cli/internal/apperrors" ) // publishCmd represents the publish command var publishCmd = &cobra.Command{ Use: "publish", Short: "Publishes book and status records to a user's PDS", - RunE: func(cmd *cobra.Command, args []string) error { + Run: func(cmd *cobra.Command, args []string) { handle, _ := cmd.Flags().GetString("handle") pds, err := atproto.ResolveHandleToPDS(handle) if err != nil { - return fmt.Errorf("Couldn't get pds from handle %s: %w", handle, err) + fmt.Fprintf(os.Stderr, "getting pds from handle %s:\n%s", handle, apperrors.FormatError(err)) + os.Exit(1) } appPassword, _ := cmd.Flags().GetString("app-password") session, err := atproto.CreateSession(pds, handle, appPassword) if err != nil { - return fmt.Errorf("Couldn't create session from:\n handle %s\n pds: %s\n Error: %w", handle, pds, err) + fmt.Fprintf(os.Stderr, "creating session for %s:\n%s", handle, apperrors.FormatError(err)) + os.Exit(1) } - fmt.Printf("Session:\n%v", session) - return nil + fmt.Printf("Session:\n DID: %s\n JWT: %s", session.DID, session.AccessJwt) }, } diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 61528d9..fcc5089 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -5,8 +5,8 @@ package cmd import ( "os" - "path/filepath" + "github.com/joho/godotenv" "github.com/spf13/cobra" ) @@ -23,6 +23,8 @@ func Execute() { } func init() { - home, _ := os.UserHomeDir() - rootCmd.PersistentFlags().String("store", filepath.Join(home, ".books", "store.json"), "Path to the local book storage") + godotenv.Load() + + os.UserHomeDir() + rootCmd.PersistentFlags().String("store", os.Getenv("STORE_PATH"), "Path to the local book storage") } diff --git a/cli/go.mod b/cli/go.mod index cdb3ddc..da39b4e 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -4,6 +4,7 @@ go 1.26.2 require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect ) diff --git a/cli/go.sum b/cli/go.sum index a6ee3e0..38ac137 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= diff --git a/cli/internal/apperrors/errors.go b/cli/internal/apperrors/errors.go new file mode 100644 index 0000000..30e4c78 --- /dev/null +++ b/cli/internal/apperrors/errors.go @@ -0,0 +1,24 @@ +package apperrors + +import ( + "errors" + "fmt" + "strings" +) + +func FormatError(err error) string { + var lines []string + + for e := err; e != nil; e = errors.Unwrap(e) { + msg := e.Error() + if inner := errors.Unwrap(e); inner != nil { + msg = strings.TrimSuffix(msg, ": "+inner.Error()) + } + lines = append(lines, msg) + } + var sb strings.Builder + for i, line := range lines { + fmt.Fprintf(&sb, "%s%s\n", strings.Repeat(" ", i), line) + } + return sb.String() +} diff --git a/cli/internal/atproto/atproto.go b/cli/internal/atproto/atproto.go index d04c63d..16e7881 100644 --- a/cli/internal/atproto/atproto.go +++ b/cli/internal/atproto/atproto.go @@ -36,20 +36,20 @@ func CreateSession(pdsHost, handle, appPassword string) (Session, error) { Password: appPassword, }) if err != nil { - return Session{}, fmt.Errorf("Couldn't marshal JSON to create a session: %w", err) + return Session{}, fmt.Errorf("marshaling JSON to create a session: %w", err) } // the actual auth call. POST to `/xrpc/com.atproto.server.createSession`, url := pdsHost + "/xrpc/com.atproto.server.createSession" req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { - return Session{}, fmt.Errorf("Couldn't create request for url %s: %w", url, err) + return Session{}, fmt.Errorf("creating request for url %s: %w", url, err) } req.Header.Set("Content-Type", "application/json") response, err := http.DefaultClient.Do(req) if err != nil { - return Session{}, fmt.Errorf("Couldn't POST to the PDS: %w", err) + return Session{}, fmt.Errorf("POSTing to the PDS: %w", err) } defer response.Body.Close() @@ -64,7 +64,7 @@ func CreateSession(pdsHost, handle, appPassword string) (Session, error) { var sesh Session err = json.NewDecoder(response.Body).Decode(&sesh) if err != nil { - return Session{}, fmt.Errorf("Unable to decode response from calling for pds: %w", err) + return Session{}, fmt.Errorf("decoding response from pds: %w", err) } // return a `Session` struct with `AccessJwt` and `DID`. @@ -74,12 +74,12 @@ func CreateSession(pdsHost, handle, appPassword string) (Session, error) { func ResolveHandleToPDS(handle string) (string, error) { did, err := resolveHandleToDID(handle) if err != nil { - return "", fmt.Errorf("Problem resolving handle %s to did: %w", handle, err) + return "", fmt.Errorf("resolving handle to did: %w", err) } pds, err := resolveDIDtoPDSHost(did) if err != nil { - return "", fmt.Errorf("Problem resolving did %s for handle %s to pds: %w", did, handle, err) + return "", fmt.Errorf("resolving did to pds host: %w", err) } return pds, nil @@ -96,7 +96,7 @@ func resolveHandleToDID(handle string) (string, error) { var err error did, err = resolveViaHTTP(handle) if err != nil { - return "", fmt.Errorf("Problem resolving did for handle %s via http: %w", handle, err) + return "", fmt.Errorf("resolving did via http: %w", err) } } @@ -107,7 +107,7 @@ func resolveDIDtoPDSHost(did string) (string, error) { url := "https://plc.directory/" + did response, err := http.Get(url) if err != nil { - return "", fmt.Errorf("Couldn't call %s to get did %s: %w", url, did, err) + return "", fmt.Errorf("calling %s: %w", url, err) } defer response.Body.Close() @@ -118,7 +118,7 @@ func resolveDIDtoPDSHost(did string) (string, error) { var result didDoc err = json.NewDecoder(response.Body).Decode(&result) if err != nil { - return "", fmt.Errorf("Unable to decode response from calling %s: %w", url, err) + return "", fmt.Errorf("decoding response: %w", err) } for _, service := range result.Services { @@ -135,7 +135,7 @@ func resolveViaDNS(handle string) (string, error) { hostname := "_atproto." + handle dnsRecords, err := net.LookupTXT(hostname) if err != nil { - return "", fmt.Errorf("Couldn't lookup dns txt records for hostname %s: %w", hostname, err) + return "", fmt.Errorf("looking up dnstxt records: %w", err) } // find the record prefixed `did=`, @@ -156,7 +156,7 @@ func resolveViaHTTP(handle string) (string, error) { response, err := http.Get(url) if err != nil { - return "", fmt.Errorf("Couldn't call %s to get handle %s: %w", url, handle, err) + return "", fmt.Errorf("GET: %w", err) } defer response.Body.Close() @@ -167,7 +167,7 @@ func resolveViaHTTP(handle string) (string, error) { // return the response body trimmed as the DID. bytes, err := io.ReadAll(response.Body) if err != nil { - return "", fmt.Errorf("Unable to read response body from call to %s: %w", url, err) + return "", fmt.Errorf("reading response body from call to %s: %w", url, err) } didRaw := string(bytes) return strings.TrimSpace(didRaw), nil diff --git a/cli/internal/openlibrary/openlibrary.go b/cli/internal/openlibrary/openlibrary.go index 3d9a21e..8f42e2e 100644 --- a/cli/internal/openlibrary/openlibrary.go +++ b/cli/internal/openlibrary/openlibrary.go @@ -42,7 +42,7 @@ func lookup(params url.Values) (string, error) { url := fmt.Sprintf("%s?%s", OpenLibrarySearchBase, params.Encode()) response, err := http.Get(url) if err != nil { - return "", fmt.Errorf("Couldn't call %s to get work id: %w", url, err) + return "", fmt.Errorf("calling to get work id: %w", err) } defer response.Body.Close() @@ -53,7 +53,7 @@ func lookup(params url.Values) (string, error) { var result searchResponse err = json.NewDecoder(response.Body).Decode(&result) if err != nil { - return "", fmt.Errorf("Unable to decode response from calling %s: %w", url, err) + return "", fmt.Errorf("decoding response from calling %s: %w", url, err) } if result.NumFound == 0 { diff --git a/cli/internal/store/store.go b/cli/internal/store/store.go index 879cc1f..44b11ee 100644 --- a/cli/internal/store/store.go +++ b/cli/internal/store/store.go @@ -29,7 +29,7 @@ func Load(path string) ([]Book, error) { absPath, _ := filepath.Abs(path) file, err := os.Open(absPath) if err != nil { - return []Book{}, fmt.Errorf("Unable to open file at %s: %w", absPath, err) + return []Book{}, fmt.Errorf("opening file: %w", err) } defer file.Close() @@ -46,29 +46,29 @@ func Save(path string, books []Book) error { // marshall json data, err := json.MarshalIndent(books, "", " ") if err != nil { - return fmt.Errorf("Unable to marshall json from store.Save: %w", err) + return fmt.Errorf("marshalling json: %w", err) } // create a tmp file if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("Unable to create store directory: %w", err) + return fmt.Errorf("creating store directory: %w", err) } tmpFile, err := os.CreateTemp(filepath.Dir(path), "*.tmp") if err != nil { - return fmt.Errorf("Unable to create tmp file during store.Save: %w", err) + return fmt.Errorf("creating tmp file: %w", err) } // write to tmp file _, err = tmpFile.Write(data) if err != nil { - return fmt.Errorf("Unable to write tmp file during store.Save: %w", err) + return fmt.Errorf("writing tmp file: %w", err) } tmpFile.Close() err = os.Rename(tmpFile.Name(), path) if err != nil { - return fmt.Errorf("Unable to rename from tmp file to %s during store.Save: %w", path, err) + return fmt.Errorf("renaming tmp file: %w", err) } absPath, _ := filepath.Abs(path) -- 2.51.2